83bda78b5b
- 拆分 FlipClockSaver.swift(680 行)为: * FlipDigit.swift(单翻页数字渲染+动画) * FlipClockSaverView.swift(屏保主体、布局、绘制、设置面板入口) * FlipClockSettings.swift(设置存储 + .flipClockSettingsDidChange 通知) * FlipClockConfigController.swift(设置窗口 UI) - 工程采用 PBXFileSystemSynchronizedRootGroup,新增/删除源文件由 Xcode 自动同步,无需改 pbxproj - 设置窗口底部新增版本信息:“版本 1.0 · Build 1” (从 Bundle(for:) 读取 CFBundleShortVersionString / CFBundleVersion,比 Bundle.main 更可靠) - Release 构建 BUILD SUCCEEDED,Developer ID 正式签名通过
314 lines
13 KiB
Swift
314 lines
13 KiB
Swift
import ScreenSaver
|
||
import AppKit
|
||
|
||
// MARK: - 屏保主体
|
||
@MainActor
|
||
@objc(FlipClockSaverView)
|
||
final class FlipClockSaverView: ScreenSaverView {
|
||
|
||
// 顶部可配置项(运行时由设置驱动,不再写死)
|
||
private var showSeconds = true
|
||
private var use24Hour = true
|
||
|
||
// 尺寸 / 缩放(fitScale 可在设置面板中调整)
|
||
private let unitW: CGFloat = 108
|
||
private let unitH: CGFloat = 160
|
||
private let radius: CGFloat = 12
|
||
private let spacing: CGFloat = 16
|
||
private let colonW: CGFloat = 14
|
||
private var fitScale: CGFloat = 0.55
|
||
|
||
// 设置面板可开关:日期 / 农历
|
||
private var showDate = true
|
||
private var showLunar = true
|
||
private let infoRowH: CGFloat = 44
|
||
private let infoGap: CGFloat = 34
|
||
|
||
private let cardBg = NSColor(white: 0.13, alpha: 1)
|
||
private let textColor = NSColor.white
|
||
private let bgColor = NSColor.black
|
||
private lazy var digitFont: NSFont = NSFont.boldSystemFont(ofSize: unitH * 0.74)
|
||
|
||
private var digits: [FlipDigit] = []
|
||
private var rects: [CGRect] = []
|
||
private var colonRects: [CGRect] = []
|
||
private var contentW: CGFloat = 1
|
||
private var contentH: CGFloat = 1
|
||
private var ampmRect: CGRect?
|
||
private var ampmString = ""
|
||
private var lastKey = -1
|
||
private var frameTime: TimeInterval = 0
|
||
private var lastSettingsCheck: TimeInterval = 0
|
||
private var infoRect: CGRect = .zero
|
||
private var infoString = ""
|
||
private var cachedDayKey = -1
|
||
private var cachedGregorian = ""
|
||
private var cachedLunar = ""
|
||
|
||
override init?(frame frameRect: NSRect, isPreview: Bool) {
|
||
super.init(frame: frameRect, isPreview: isPreview)
|
||
commonInit()
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
super.init(coder: coder)
|
||
commonInit()
|
||
}
|
||
|
||
private func commonInit() {
|
||
FlipClockSettings.registerDefaults()
|
||
loadSettings()
|
||
NSLog("[FlipClock] view 初始化: scale=%.2f date=%d lunar=%d", Double(fitScale), showDate, showLunar)
|
||
NotificationCenter.default.addObserver(self, selector: #selector(handleSettingsChanged(_:)),
|
||
name: .flipClockSettingsDidChange, object: nil)
|
||
frameTime = CACurrentMediaTime()
|
||
buildLayout()
|
||
update(animated: false, at: frameTime)
|
||
}
|
||
|
||
private func buildLayout() {
|
||
// 卡片下方预留日期/农历行(仅在任一开关打开时占位)
|
||
let baseY: CGFloat = (showDate || showLunar) ? infoRowH + infoGap : 0
|
||
var cur: CGFloat = 0
|
||
func adv(_ w: CGFloat) -> CGRect {
|
||
let r = CGRect(x: cur, y: baseY, width: w, height: unitH)
|
||
cur += w + spacing
|
||
return r
|
||
}
|
||
let h0 = FlipDigit(); let h1 = FlipDigit()
|
||
let m0 = FlipDigit(); let m1 = FlipDigit()
|
||
let s0 = FlipDigit(); let s1 = FlipDigit()
|
||
|
||
var ds: [FlipDigit] = []
|
||
var rs: [CGRect] = []
|
||
var cols: [CGRect] = []
|
||
|
||
func addDigit(_ d: FlipDigit) { ds.append(d); rs.append(adv(unitW)) }
|
||
func addColon() { cols.append(adv(colonW)) }
|
||
|
||
addDigit(h0); addDigit(h1); addColon()
|
||
addDigit(m0); addDigit(m1)
|
||
if showSeconds { addColon(); addDigit(s0); addDigit(s1) }
|
||
|
||
digits = ds; rects = rs; colonRects = cols
|
||
|
||
if !use24Hour {
|
||
ampmRect = CGRect(x: cur, y: 0, width: 120, height: unitH)
|
||
cur += 120 + spacing
|
||
}
|
||
contentW = max(cur - spacing, 1)
|
||
contentH = baseY + unitH
|
||
infoRect = CGRect(x: 0, y: 0, width: contentW, height: infoRowH)
|
||
}
|
||
|
||
private func update(animated: Bool, at time: TimeInterval) {
|
||
let now = Calendar.current.dateComponents([.hour, .minute, .second], from: Date())
|
||
var h = now.hour ?? 0
|
||
var ampm = ""
|
||
if !use24Hour {
|
||
ampm = h >= 12 ? "PM" : "AM"
|
||
h = h % 12
|
||
if h == 0 { h = 12 }
|
||
}
|
||
let hh = String(format: "%02d", h)
|
||
let mm = String(format: "%02d", now.minute ?? 0)
|
||
let ss = String(format: "%02d", now.second ?? 0)
|
||
var vals: [Int] = [
|
||
Int(String(hh.prefix(1))) ?? 0, Int(String(hh.suffix(1))) ?? 0,
|
||
Int(String(mm.prefix(1))) ?? 0, Int(String(mm.suffix(1))) ?? 0
|
||
]
|
||
if showSeconds {
|
||
vals.append(Int(String(ss.prefix(1))) ?? 0)
|
||
vals.append(Int(String(ss.suffix(1))) ?? 0)
|
||
}
|
||
for i in digits.indices { digits[i].set(vals[i], at: time, animated: animated) }
|
||
ampmString = ampm
|
||
refreshInfo()
|
||
}
|
||
|
||
override var animationTimeInterval: TimeInterval {
|
||
get { 1.0 / 60 }
|
||
set {}
|
||
}
|
||
|
||
override func animateOneFrame() {
|
||
let now = CACurrentMediaTime()
|
||
frameTime = now
|
||
let comps = Calendar.current.dateComponents([.hour, .minute, .second], from: Date())
|
||
let key = (comps.hour ?? 0) * 3600 + (comps.minute ?? 0) * 60 + (comps.second ?? 0)
|
||
if key != lastKey {
|
||
lastKey = key
|
||
update(animated: true, at: now)
|
||
}
|
||
for d in digits { d.advance(to: now) }
|
||
syncSettingsIfNeeded(now: now)
|
||
setNeedsDisplay(bounds)
|
||
}
|
||
|
||
// 轮询兜底:即使通知未送达(如跨进程/模块缓存),也能在约 0.5 秒内同步设置
|
||
private func syncSettingsIfNeeded(now: TimeInterval) {
|
||
guard now - lastSettingsCheck > 0.5 else { return }
|
||
lastSettingsCheck = now
|
||
let newScale = FlipClockSettings.fitScale
|
||
let newShowDate = FlipClockSettings.showDate
|
||
let newShowLunar = FlipClockSettings.showLunar
|
||
let newShowSeconds = FlipClockSettings.showSeconds
|
||
let newUse24Hour = FlipClockSettings.use24Hour
|
||
guard newScale != fitScale || newShowDate != showDate || newShowLunar != showLunar
|
||
|| newShowSeconds != showSeconds || newUse24Hour != use24Hour else { return }
|
||
let togglesChanged = newShowDate != showDate || newShowLunar != showLunar
|
||
|| newShowSeconds != showSeconds || newUse24Hour != use24Hour
|
||
fitScale = newScale
|
||
showDate = newShowDate
|
||
showLunar = newShowLunar
|
||
showSeconds = newShowSeconds
|
||
use24Hour = newUse24Hour
|
||
if togglesChanged {
|
||
buildLayout()
|
||
update(animated: false, at: frameTime)
|
||
}
|
||
setNeedsDisplay(bounds)
|
||
}
|
||
|
||
override func draw(_ dirtyRect: NSRect) {
|
||
guard let ctx = NSGraphicsContext.current?.cgContext else { return }
|
||
ctx.setFillColor(bgColor.cgColor)
|
||
ctx.fill(bounds)
|
||
|
||
let s = min(bounds.width / contentW, bounds.height / contentH) * fitScale
|
||
ctx.saveGState()
|
||
ctx.translateBy(x: bounds.width / 2, y: bounds.height / 2)
|
||
ctx.scaleBy(x: s, y: s)
|
||
ctx.translateBy(x: -contentW / 2, y: -contentH / 2)
|
||
|
||
let now = frameTime
|
||
for i in digits.indices {
|
||
digits[i].draw(in: ctx, rect: rects[i], radius: radius, font: digitFont,
|
||
cardBg: cardBg, textColor: textColor, now: now)
|
||
}
|
||
for c in colonRects { drawColon(in: ctx, rect: c) }
|
||
if !use24Hour, let ar = ampmRect { drawAmPm(in: ctx, rect: ar) }
|
||
if showDate || showLunar { drawInfo(in: ctx, rect: infoRect) }
|
||
ctx.restoreGState()
|
||
}
|
||
|
||
private func drawColon(in ctx: CGContext, rect: CGRect) {
|
||
ctx.saveGState()
|
||
ctx.setFillColor(textColor.cgColor)
|
||
let cx = rect.midX
|
||
let r = min(rect.width, rect.height) * 0.12
|
||
for cy in [rect.minY + rect.height * 0.62, rect.minY + rect.height * 0.38] {
|
||
ctx.fillEllipse(in: CGRect(x: cx - r, y: cy - r, width: r * 2, height: r * 2))
|
||
}
|
||
ctx.restoreGState()
|
||
}
|
||
|
||
private func drawAmPm(in ctx: CGContext, rect: CGRect) {
|
||
let attrs: [NSAttributedString.Key: Any] = [
|
||
.font: NSFont.boldSystemFont(ofSize: unitH * 0.18),
|
||
.foregroundColor: textColor
|
||
]
|
||
(ampmString as NSString).draw(in: rect, withAttributes: attrs)
|
||
}
|
||
|
||
// MARK: 日期 / 农历
|
||
private func refreshInfo() {
|
||
let date = Date()
|
||
let dayKey = Calendar.current.ordinality(of: .day, in: .era, for: date) ?? -1
|
||
if dayKey != cachedDayKey {
|
||
cachedDayKey = dayKey
|
||
let df = DateFormatter()
|
||
df.locale = Locale(identifier: "zh_CN")
|
||
df.dateFormat = "yyyy年M月d日 EEEE"
|
||
cachedGregorian = df.string(from: date)
|
||
cachedLunar = Self.lunarString(from: date)
|
||
}
|
||
var parts: [String] = []
|
||
if showDate { parts.append(cachedGregorian) }
|
||
if showLunar { parts.append(cachedLunar) }
|
||
infoString = parts.joined(separator: " · ")
|
||
}
|
||
|
||
private static let tianGan = ["甲", "乙", "丙", "丁", "戊", "己", "庚", "辛", "壬", "癸"]
|
||
private static let diZhi = ["子", "丑", "寅", "卯", "辰", "巳", "午", "未", "申", "酉", "戌", "亥"]
|
||
private static let shengXiao = ["鼠", "牛", "虎", "兔", "龙", "蛇", "马", "羊", "猴", "鸡", "狗", "猪"]
|
||
private static let lunarMonths = ["正月", "二月", "三月", "四月", "五月", "六月",
|
||
"七月", "八月", "九月", "冬月", "十一月", "腊月"]
|
||
private static let lunarDays = ["初一", "初二", "初三", "初四", "初五", "初六", "初七", "初八", "初九", "初十",
|
||
"十一", "十二", "十三", "十四", "十五", "十六", "十七", "十八", "十九", "二十",
|
||
"廿一", "廿二", "廿三", "廿四", "廿五", "廿六", "廿七", "廿八", "廿九", "三十"]
|
||
|
||
// 农历:干支 + 生肖 + 月日,如“甲辰龙年正月初一”,闰月自动加“闰”
|
||
private static func lunarString(from date: Date) -> String {
|
||
let cal = Calendar(identifier: .chinese)
|
||
let c = cal.dateComponents([.year, .month, .day, .isLeapMonth], from: date)
|
||
let year = max(c.year ?? 1, 1)
|
||
let month = min(max(c.month ?? 1, 1), 12)
|
||
let day = min(max(c.day ?? 1, 1), 30)
|
||
let idx = (year - 1) % 60
|
||
let ganZhi = tianGan[idx % 10] + diZhi[idx % 12]
|
||
let zodiac = shengXiao[idx % 12]
|
||
let monthName = ((c.isLeapMonth ?? false) ? "闰" : "") + lunarMonths[month - 1]
|
||
return "\(ganZhi)\(zodiac)年\(monthName)\(lunarDays[day - 1])"
|
||
}
|
||
|
||
private func drawInfo(in ctx: CGContext, rect: CGRect) {
|
||
guard !infoString.isEmpty else { return }
|
||
ctx.saveGState()
|
||
let font = NSFont.systemFont(ofSize: unitH * 0.22, weight: .medium)
|
||
let size = (infoString as NSString).size(withAttributes: [.font: font])
|
||
let r = CGRect(x: rect.midX - size.width / 2,
|
||
y: rect.midY - size.height / 2,
|
||
width: size.width, height: size.height)
|
||
let attrs: [NSAttributedString.Key: Any] = [
|
||
.font: font,
|
||
.foregroundColor: textColor.withAlphaComponent(0.72)
|
||
]
|
||
(infoString as NSString).draw(in: r, withAttributes: attrs)
|
||
ctx.restoreGState()
|
||
}
|
||
|
||
// MARK: 设置面板
|
||
override var hasConfigureSheet: Bool { true }
|
||
// 复用同一个 controller/window:每次点“选项”框架都会重新调用 configureSheet,
|
||
// 返回同一个已存在的 window 即可反复弹出。若每次都新建,框架缓存的仍是首个窗口,
|
||
// 关掉后再次点击会复用那个已 endSheet 隐藏的旧窗口而无反应(只能重启“系统设置”才能再开)。
|
||
private lazy var configController: FlipClockConfigController = FlipClockConfigController()
|
||
override var configureSheet: NSWindow? {
|
||
configController.reloadValues()
|
||
return configController.window
|
||
}
|
||
|
||
@objc private func handleSettingsChanged(_ note: Notification) {
|
||
let oldShowDate = showDate
|
||
let oldShowLunar = showLunar
|
||
let oldShowSeconds = showSeconds
|
||
let oldUse24Hour = use24Hour
|
||
if let info = note.userInfo, !info.isEmpty {
|
||
// 直接从通知携带的值应用,不依赖 defaults 读回路径
|
||
if let v = info[FlipClockSettings.keyScale] as? Double { fitScale = CGFloat(v) }
|
||
if let v = info[FlipClockSettings.keyShowDate] as? Bool { showDate = v }
|
||
if let v = info[FlipClockSettings.keyShowLunar] as? Bool { showLunar = v }
|
||
if let v = info[FlipClockSettings.keyShowSeconds] as? Bool { showSeconds = v }
|
||
if let v = info[FlipClockSettings.keyUse24Hour] as? Bool { use24Hour = v }
|
||
} else {
|
||
loadSettings()
|
||
}
|
||
NSLog("[FlipClock] view 收到设置通知,已应用 scale=%.2f", Double(fitScale))
|
||
if oldShowDate != showDate || oldShowLunar != showLunar
|
||
|| oldShowSeconds != showSeconds || oldUse24Hour != use24Hour {
|
||
buildLayout()
|
||
update(animated: false, at: frameTime)
|
||
}
|
||
setNeedsDisplay(bounds)
|
||
}
|
||
|
||
private func loadSettings() {
|
||
fitScale = FlipClockSettings.fitScale
|
||
showDate = FlipClockSettings.showDate
|
||
showLunar = FlipClockSettings.showLunar
|
||
showSeconds = FlipClockSettings.showSeconds
|
||
use24Hour = FlipClockSettings.use24Hour
|
||
}
|
||
}
|