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 正式签名通过
142 lines
5.8 KiB
Swift
142 lines
5.8 KiB
Swift
import AppKit
|
|
|
|
// MARK: - 单个翻页数字(Core Graphics 绘制,无 layer 拼接,彻底无黑缝)
|
|
@MainActor
|
|
final class FlipDigit {
|
|
var current: Int = -1
|
|
private var previous: Int = -1
|
|
private var animStart: TimeInterval = 0
|
|
private var animating = false
|
|
let duration: TimeInterval = 0.55
|
|
|
|
func set(_ newValue: Int, at time: TimeInterval, animated: Bool) {
|
|
guard newValue != current else { return }
|
|
previous = current < 0 ? newValue : current
|
|
current = newValue
|
|
if animated {
|
|
animStart = time
|
|
animating = true
|
|
} else {
|
|
animating = false
|
|
}
|
|
}
|
|
|
|
func advance(to time: TimeInterval) {
|
|
if animating && (time - animStart) >= duration {
|
|
animating = false
|
|
}
|
|
}
|
|
|
|
func draw(in ctx: CGContext,
|
|
rect: CGRect,
|
|
radius: CGFloat,
|
|
font: NSFont,
|
|
cardBg: NSColor,
|
|
textColor: NSColor,
|
|
now: TimeInterval) {
|
|
let t01 = animating ? min(max((now - animStart) / duration, 0), 1) : 1
|
|
let midY = rect.midY
|
|
let topRect = CGRect(x: rect.minX, y: midY, width: rect.width, height: rect.height / 2)
|
|
let botRect = CGRect(x: rect.minX, y: rect.minY, width: rect.width, height: rect.height / 2)
|
|
|
|
let topDigit = current
|
|
let botDigit = animating ? previous : current
|
|
|
|
// 先铺整卡底色:中缝处底色即卡片色,彻底消除抗锯齿透出的黑缝
|
|
ctx.saveGState()
|
|
let basePath = CGMutablePath()
|
|
basePath.addRoundedRect(in: rect, cornerWidth: radius, cornerHeight: radius)
|
|
ctx.addPath(basePath); ctx.clip()
|
|
ctx.setFillColor(cardBg.cgColor)
|
|
ctx.fill(rect)
|
|
ctx.restoreGState()
|
|
|
|
drawCard(in: ctx, half: topRect, full: rect, digit: topDigit,
|
|
radius: radius, font: font, cardBg: cardBg, textColor: textColor)
|
|
drawCard(in: ctx, half: botRect, full: rect, digit: botDigit,
|
|
radius: radius, font: font, cardBg: cardBg, textColor: textColor)
|
|
|
|
if animating && t01 < 1 {
|
|
if t01 < 0.5 {
|
|
// 上半翻片:旧值从直立翻走(0 → -90°),用 cos 模拟 3D 压缩
|
|
let a = -t01 * 2 * (CGFloat.pi / 2)
|
|
let sy = max(cos(a), 0.0001)
|
|
ctx.saveGState()
|
|
ctx.beginPath(); ctx.addRect(topRect); ctx.clip()
|
|
ctx.translateBy(x: 0, y: midY)
|
|
ctx.scaleBy(x: 1, y: sy)
|
|
ctx.translateBy(x: 0, y: -midY)
|
|
drawCard(in: ctx, half: topRect, full: rect, digit: previous,
|
|
radius: radius, font: font, cardBg: cardBg, textColor: textColor)
|
|
ctx.setFillColor(CGColor(gray: 0, alpha: CGFloat(t01) * 0.40))
|
|
ctx.fill(topRect)
|
|
ctx.restoreGState()
|
|
} else {
|
|
// 下半翻片:新值从水平翻下(90° → 0°)
|
|
let t2 = (t01 - 0.5) * 2
|
|
let a = (1 - t2) * (CGFloat.pi / 2)
|
|
let sy = max(cos(a), 0.0001)
|
|
ctx.saveGState()
|
|
ctx.beginPath(); ctx.addRect(botRect); ctx.clip()
|
|
ctx.translateBy(x: 0, y: midY)
|
|
ctx.scaleBy(x: 1, y: sy)
|
|
ctx.translateBy(x: 0, y: -midY)
|
|
drawCard(in: ctx, half: botRect, full: rect, digit: current,
|
|
radius: radius, font: font, cardBg: cardBg, textColor: textColor)
|
|
ctx.setFillColor(CGColor(gray: 1, alpha: CGFloat(1 - t2) * 0.30))
|
|
ctx.fill(botRect)
|
|
ctx.restoreGState()
|
|
}
|
|
}
|
|
|
|
// 翻页钟铰链:一条细而自然的中缝分割线(位于翻片转轴处,始终可见)
|
|
drawHinge(in: ctx, rect: rect, midY: midY)
|
|
}
|
|
|
|
// 细而自然的中缝:主线为柔和阴影,下方一抹微弱高光模拟折边受光
|
|
private func drawHinge(in ctx: CGContext, rect: CGRect, midY: CGFloat) {
|
|
ctx.saveGState()
|
|
ctx.setFillColor(CGColor(gray: 0, alpha: 0.45))
|
|
ctx.fill(CGRect(x: rect.minX, y: midY - 0.75, width: rect.width, height: 1.5))
|
|
ctx.setFillColor(CGColor(gray: 1, alpha: 0.06))
|
|
ctx.fill(CGRect(x: rect.minX, y: midY + 0.75, width: rect.width, height: 1))
|
|
ctx.restoreGState()
|
|
}
|
|
|
|
// 整卡圆角 + 半区域双重裁剪:外缘圆角、接缝直边,且上下连续绘制 → 无黑缝
|
|
private func drawCard(in ctx: CGContext,
|
|
half: CGRect,
|
|
full: CGRect,
|
|
digit: Int,
|
|
radius: CGFloat,
|
|
font: NSFont,
|
|
cardBg: NSColor,
|
|
textColor: NSColor) {
|
|
ctx.saveGState()
|
|
let path = CGMutablePath()
|
|
path.addRoundedRect(in: full, cornerWidth: radius, cornerHeight: radius)
|
|
ctx.addPath(path); ctx.clip()
|
|
ctx.beginPath(); ctx.addRect(half); ctx.clip()
|
|
|
|
ctx.setFillColor(cardBg.cgColor)
|
|
ctx.fill(full)
|
|
|
|
// 数字在整卡内水平 + 垂直精确居中:先测量文字尺寸,再把绘制框对齐卡片中线
|
|
let str = "\(digit)" as NSString
|
|
let size = str.size(withAttributes: [.font: font])
|
|
let textRect = CGRect(x: full.minX,
|
|
y: full.midY - size.height / 2,
|
|
width: full.width,
|
|
height: size.height)
|
|
let ps = NSMutableParagraphStyle()
|
|
ps.alignment = .center
|
|
let attrs: [NSAttributedString.Key: Any] = [
|
|
.font: font,
|
|
.foregroundColor: textColor,
|
|
.paragraphStyle: ps
|
|
]
|
|
str.draw(in: textRect, withAttributes: attrs)
|
|
ctx.restoreGState()
|
|
}
|
|
}
|