Files
FlipClockSaver/FlipClockSaver/FlipClockSaverView.swift
T
lc 83bda78b5b 重构:拆分单文件为 4 个模块 + 设置面板显示版本号/构建号
- 拆分 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 正式签名通过
2026-08-06 10:32:20 +08:00

314 lines
13 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}