☰
Tendedero 源码剖析(五):让截图从取景框飞上晾衣绳的 Core Animation 动画实现全解析
2026/10/9 23:22:19 网站建设 项目流程

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,而是自己当导演:

  1. 120Hz 计时器:Timer(timeInterval: 1.0 / 120.0)加进主 RunLoop 的.common模式,每次tick()用CACurrentMediaTime()计算进度k(CaptureFlight.swift#L134-L143)
  2. 缓动曲线:用三次缓动easeInOutCubic把线性进度k变成"慢—快—慢"的节奏
  3. 每一帧在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弹簧回摆)接手,让绳子和照片在动画结束后依然有生命感。

六、复盘:这套实现的三个可复用思路

  1. 飞行画布独立成窗:动画要跨越界面边界时,用全屏透明NSPanel兜底,别让容器裁剪你的主角
  2. 手动时钟驱动多属性同步:Timer+CACurrentMediaTime()+setDisableActions(true),把缓动曲线写成纯函数,每帧全量重算几何
  3. 变形 + 交叉淡化代替切换:让"飞行中的卡片"和"最终的卡片"在终点完全一致,交接时观众只会觉得"它一直在那"

相关源码速查:

  • 飞行动画主体: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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询