Tendedero 源码剖析(五):让截图从取景框飞上晾衣绳的 Core Animation 动画实现全解析
【免费下载链接】tendederoScreenshots, hung out to dry. A tiny native macOS app that hangs every screenshot on a line at the top of your screen.项目地址: https://gitcode.com/gh_mirrors/te/tendedero
Tendedero 是一款免费开源的 macOS 截图管理工具:每拍一张截图,它都会从取景位置"飞"到屏幕顶部的晾衣绳上。本文带你剖析这个飞行动画的完整实现——它没有用 SwiftUI 的声明式动画,而是用 Core Animation 的CALayer+ 一个 120Hz 的手动时钟逐帧驱动,是理解 macOS 上"自定义几何变形动画"的优秀范例。
一、起飞点从哪来?读取截图区域
飞行动画的前提是知道"截图是在屏幕的哪里拍的"。答案藏在 macOS 的截图文件本身:系统会把截取区域以扩展属性的形式写进 PNG 文件里。
captureRect(of:)函数通过getxattr读取com.apple.metadata:kMDItemScreenCaptureGlobalRect这个键,拿到 4 个数值(x、y、宽、高),再做一次坐标翻转(macOS 元数据用屏幕左上角为原点,而 AppKit 用左下角),就得到了截图区域:
- 源码位置:CaptureFlight.swift#L7-L24
新截图则由 ScreenshotWatcher.swift 通过DispatchSource.makeFileSystemObjectSource监听截图文件夹发现;AppDelegate收到通知后判断"能不能飞":
- 若能从文件读出取景区域 → 走飞行动画
- 读不到(比如截图来自其他工具)→ 直接"掉"到绳子上
决策入口在 AppDelegate.swift#L202-L231 的hangCapture与fly两个方法:先让晾衣绳滑出并布局,延时 0.03 秒等 SwiftUI 完成排版,再量出卡片目标位置,才正式起飞。
二、为什么用整块全屏 CALayer 窗口做飞行?
卡片要穿越屏幕顶部那条半透明的晾衣绳面板(LinePanel.swift),如果只在面板内部做动画,卡片飞出面板边界就会被裁掉。所以CaptureFlight干脆创建了一块覆盖整块屏幕、完全透明、不响应鼠标的NSPanel:
window.ignoresMouseEvents = true // 鼠标直接穿过去 window.level = NSWindow.Level(rawValue: NSWindow.Level.floating.rawValue + 1) // 浮在晾衣绳之上在这块"飞行画布"上,它手工搭了一棵 5 层的CALayer树(CaptureFlight.swift#L99-L131):
| 图层 | 作用 |
|---|---|
container | 卡片容器,负责位移、旋转、阴影 |
glass | 毛玻璃底板,透明度由 0 渐显 |
photo | 截图本体,resizeAspectFill填充 |
edge | 用CAGradientLayer+ 描边mask做的高光边框 |
clip | 顶部那个"小夹子",用 4 段金属渐变绘制 |
关键设计是**"飞行即变形"**:截图在飞行过程中同步缩放、旋转、长出圆角、玻璃边框和夹子逐渐浮现——落地那一刻,飞行窗口里的卡片和绳子上真正的卡片已经完全同构,之后只需交叉淡化,观众看不到任何"切换"。
三、0.65 秒的飞行:120Hz 手动时钟逐帧插值
这是全文最核心的部分。CaptureFlight不用任何系统的隐式/显式动画 API,而是自己当导演:
- 120Hz 计时器:
Timer(timeInterval: 1.0 / 120.0)加进主 RunLoop 的.common模式,每次tick()用CACurrentMediaTime()计算进度k(CaptureFlight.swift#L134-L143) - 缓动曲线:用三次缓动
easeInOutCubic把线性进度k变成"慢—快—慢"的节奏 - 每一帧在
CATransaction.setDisableActions(true)里直接写几何值,关掉隐式动画,确保所见即所算
每一帧的插值逻辑非常值得抄进你的工具箱(CaptureFlight.swift#L186-L219):
- 位置:
lerp线性插值起点和终点的中心与顶边,再叠加sin(.pi * k) * 30——一个正弦拱形,让卡片沿 30 点高的"抛物线"掠过屏幕,而不是干巴巴的直线 - 尺寸与圆角:宽高从截图原始比例
lerp到卡片尺寸,圆角从 0 长到 16 - 配件浮现:玻璃、边框、夹子的透明度走另一条更晚的缓动
smooth(k, 0.35, 1)(smoothstep),即飞行 35% 之后才开始"穿衣" - 旋转:按卡片随机的
tilt值从 0 度旋到位,注意源码里那句注释——SwiftUI 顺时针为正,Core Animation 是反的,角度要取负
💡 小结:这套"手动时钟 + 逐帧插值 + 关闭隐式动画"的模式,本质上是把 Core Animation 降级成了一个可控的 2D 变换器。适合任何需要精确同步多个属性、或者中途要改变形的复杂动画。
四、落地的瞬间:交叉淡化交接
k走到 1 后,飞行窗口并不马上消失:
NSAnimationContext.runAnimationGroup({ ctx in ctx.duration = 0.16 window.animator().alphaValue = 0 })真正挂在绳子上的 SwiftUI 卡片(PeggedView)在同一位置淡入,飞行窗口淡出盖在它上面,0.16 秒内完成无缝交接(CaptureFlight.swift#L145-L163)。落地后 PeggedView.swift#L123-L126 还会给卡片一个 2.2 度的小晃,模拟"被夹子接住"的物理惯性。
五、反向复用:让卡片"掉下去"
CaptureFlight不是单向的。把falling = true,同一个类就变成了"脱落动画"(CaptureFlight.swift#L69-L79):卡片从绳上垂直下落 520 点,倾斜角继续加大,透明度用e = raw³的三次缓入同步衰减,0.55 秒完成——和"飞行"共用同一套图层结构和窗口机制,是"一个引擎、两种叙事"的漂亮复用。
之后 Line.swift 里的"微风系统"(每 7~16 秒随机来一阵风,卡片用interpolatingSpring弹簧回摆)接手,让绳子和照片在动画结束后依然有生命感。
六、复盘:这套实现的三个可复用思路
- 飞行画布独立成窗:动画要跨越界面边界时,用全屏透明
NSPanel兜底,别让容器裁剪你的主角 - 手动时钟驱动多属性同步:
Timer+CACurrentMediaTime()+setDisableActions(true),把缓动曲线写成纯函数,每帧全量重算几何 - 变形 + 交叉淡化代替切换:让"飞行中的卡片"和"最终的卡片"在终点完全一致,交接时观众只会觉得"它一直在那"
相关源码速查:
- 飞行动画主体:CaptureFlight.swift
- 起飞决策与落点计算:AppDelegate.swift#L202-L253
- 截图监听与取景区域:ScreenshotWatcher.swift、CaptureFlight.swift#L7-L24
- 落地后的卡片与晃动:PeggedView.swift
- 绳上状态管理:Line.swift
【免费下载链接】tendederoScreenshots, hung out to dry. A tiny native macOS app that hangs every screenshot on a line at the top of your screen.项目地址: https://gitcode.com/gh_mirrors/te/tendedero
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考