Log Entry

用 Core Animation 做一个会呼吸的冥想动画

core animation 进阶!!!

"The details are not the details. They make the design."
— Charles Eames

做「凪」的冥想页时,我想要一个沉浸感很强的呼吸引导动画——天空和水面随呼吸涨落,文字穿过水面时颜色自然切换,波纹随冥想时间逐渐平静。最终用大约 1000 行 Objective-C 实现了全部效果,没有依赖任何第三方动画库(但是强依赖了AI)

1. 画面搭建:两个渐变 + 一条波浪线

天空与水面

整个画面的思路很简单:用一条地平线把屏幕分成上下两半,上面是天空渐变,下面是水面渐变。

要画渐变,最直接的选择是 CAGradientLayer。它是 CALayer 的子类,专门画线性/径向渐变——不需要 drawRect: 手动画,设好属性 GPU 直接渲染,性能很好。

// 创建天空渐变层
 self.skyGradientLayer = [CAGradientLayer layer];
 // startPoint / endPoint 决定渐变方向,(0.5, 1) → (0.5, 0) 就是从下往上
 self.skyGradientLayer.startPoint = CGPointMake(0.5f, 1.0f);
 self.skyGradientLayer.endPoint   = CGPointMake(0.5f, 0.0f);
 // colors 数组接受 CGColorRef,OC 里用 __bridge id 桥接
 self.skyGradientLayer.colors = @[
   (__bridge id)theme.skyBottom.CGColor,
   (__bridge id)theme.skyTop.CGColor
 ];
 [self.layer addSublayer:self.skyGradientLayer];

水面同理,方向反过来(从上到下)。两个渐变的 frame 以地平线为界:

self.skyGradientLayer.frame   = CGRectMake(0, 0, w, horizonY);
 self.waterGradientLayer.frame = CGRectMake(0, horizonY, w, h - horizonY);

吸气时地平线上升,天空变小、水面变大;呼气时反过来。就这么两行 frame 调整,画面就有了"呼吸"的感觉。

配色由 XCBreathTheme 统一管理,按系统外观自动切换。把颜色抽出去之后,加新主题只需要多写一个工厂方法,View 层零改动。

地平线:一条正弦波

地平线不是直线,而是一条微微起伏的正弦曲线——这样才有水面的感觉。

画任意形状的线条,需要用到 CAShapeLayer。它也是 CALayer 子类,核心属性是 path——给它一条 CGPath,它就画出来。和 drawRect: 不同,CAShapeLayer 的路径由 GPU 光栅化,每帧重画也不卡。

路径本身通过 UIBezierPath 构建。UIBezierPath 是 UIKit 对 CGPath 的 OC 封装,用起来更方便:

UIBezierPath *p = [UIBezierPath bezierPath];
 BOOL first = YES;
 for (CGFloat x = 0; x <= w; x += 4.0f) {
   CGFloat y = horizonY + amp * sinf(freq * time + k * x);
   if (first) {
     [p moveToPoint:CGPointMake(x, y)];   // 移动画笔到起点(不画线)
     first = NO;
   } else {
     [p addLineToPoint:CGPointMake(x, y)]; // 从当前点画直线到目标点
   }
 }
 // .CGPath 转成 CGPathRef,赋给 CAShapeLayer
 self.horizonLineLayer.path = p.CGPath;

就是中学学的 y = A·sin(ωt + kx)。振幅 A 只有 5pt、波长 320pt,刻意选得很克制。

但这里有个问题——Core Animation 有个"贴心"的默认行为:修改 CALayer 的可动画属性(比如 path、frame)时,自动加 0.25 秒过渡动画。我们每帧都在更新路径,如果每次都自动过渡,画面会糊成一团。

解决方案是用 CATransaction 临时关掉隐式动画:

[CATransaction begin];
 [CATransaction setDisableActions:YES];  // 这一帧内所有属性变更都不做动画
 self.horizonLineLayer.path = p.CGPath;
 self.skyGradientLayer.frame = ...;
 // ... 其他更新 ...
 [CATransaction commit];

CATransaction 是 Core Animation 的事务机制,begin / commit 之间的属性修改会被批量提交。除了关闭动画,它还能控制动画时长——后面主题切换时会用到。

三层波纹叠加

水面下叠了三层 CAShapeLayer,各有不同参数:

参数 第 1 层 第 2 层 第 3 层
频率 1.5 2.3 3.1
振幅 4pt 3pt 2pt
相位 0 π/3 2π/3

三层错开 120° 相位,避免看起来整齐划一。路径画完后要闭合到屏幕底部,形成一个水面多边形:

[path addLineToPoint:CGPointMake(w, h)];  // 右下角
 [path addLineToPoint:CGPointMake(0, h)];  // 左下角
 [path closePath];                          // 连回起点,封闭路径

每层用不同的 opacity 控制透明度(0.25 / 0.18 / 0.12),叠加出水面的层次感。


2. 文字分色:遮罩技巧

这是整个动画里我最满意的部分。

问题:文字会从水面升入天空。浅色模式下天空很亮,白色文字看不清;但深色文字在暗色水面上也不行。

思路:把每个文字做两份,重叠放在一起。一份深色只在天空区域显示,一份浅色只在水面区域显示。

怎么让一个 UILabel 只在指定区域可见?这里要用到 CALayer 的 mask 属性。

layer.mask 接受另一个 CALayer 作为遮罩——遮罩中不透明的区域,原 layer 就可见;透明的区域,原 layer 就被裁掉。用一个填充了矩形的 CAShapeLayer 做遮罩,就能把 label 裁切到任意区域。

// 初始化时,给每个 label 附上遮罩
 CAShapeLayer *mask = [CAShapeLayer layer];
 mask.fillColor = [UIColor blackColor].CGColor;  // 黑色 = 不透明 = 可见区域
 label.layer.mask = mask;

每帧根据地平线位置更新遮罩区域:

- (void)applyMaskForLabel:(UILabel *)label screenRect:(CGRect)screenRect {
   CAShapeLayer *mask = (CAShapeLayer *)label.layer.mask;
   // 关键:遮罩的坐标系是 label 本地坐标系,不是父视图的
   // 必须用 convertRect:toView: 做一次坐标转换
   CGRect localRect = [self convertRect:screenRect toView:label];
   mask.path = [UIBezierPath bezierPathWithRect:localRect].CGPath;
 }

convertRect:toView: 是 UIView 的方法,把一个 rect 从当前视图的坐标系转换到目标视图的坐标系。这里有个坑——如果不转换,遮罩位置会错,因为遮罩的 path 是相对于 label 自身的。

副本 颜色 遮罩区域
mainLabelSky 深色 (0, 0, w, horizonY) — 天空
mainLabelWater 浅色 (0, horizonY, w, h-horizonY) — 水面

效果:文字穿过地平线时,颜色在分界处自然切换,没有突变也没有闪烁。

阶段切换时文字内容要变("Breathe in" → "Hold"),这里用 CABasicAnimation 做淡入淡出。

为什么不用 UIView animateWithDuration: ?因为需要在 fadeOut 结束后换文字内容再 fadeIn,要串接两段动画。CABasicAnimation 有 delegate,可以在 animationDidStop:finished: 里做这个串接:

CABasicAnimation *fadeOut = [CABasicAnimation animationWithKeyPath:@"opacity"];
 fadeOut.fromValue = @(1.0f);
 fadeOut.toValue   = @(0.0f);
 fadeOut.duration  = 0.4;
 // fillMode + removedOnCompletion 让动画停在终态(透明),不回弹
 fadeOut.fillMode  = kCAFillModeForwards;
 fadeOut.removedOnCompletion = NO;
 fadeOut.delegate = self;
 [label.layer addAnimation:fadeOut forKey:@"fadeOut"];

delegate 收到 animationDidStop: 后,换掉文字内容,再加一个 fadeIn 动画。这 1 秒的过渡窗口(0.4s 淡出 + 0.6s 淡入)刚好遮盖住内容和位置的跳变。


3. 呼吸状态机

三个阶段

4-7-8 呼吸法拆成三个阶段,状态机只需要回答三个问题:哪个阶段、过了多久、地平线在哪。

typedef NS_ENUM(NSInteger, XCBreathPhase) {
   XCBreathPhaseInhale = 0,  // 吸气 4s — 地平线升至 35%
   XCBreathPhaseHold   = 1,  // 屏息 7s — 地平线不动
   XCBreathPhaseExhale = 2   // 呼气 8s — 地平线降至 65%
 };

每帧调用 tickWithDelta: 推进进度,返回阶段是否结束:

- (BOOL)tickWithDelta:(CFTimeInterval)delta viewHeight:(CGFloat)viewHeight {
   self.phaseElapsed += delta;
   CGFloat t = MIN(self.phaseElapsed / self.phaseDuration, 1.0f);
   CGFloat easedT = (self.currentPhase == XCBreathPhaseHold)
       ? 1.0f
       : XCBezierEase(t, kXCEaseBreath);
 
   self.currentHorizonY =
       self.startHorizonY + (self.targetHorizonY - self.startHorizonY) * easedT;
   return (t >= 1.0f);
 }

一个设计细节:startHorizonY 始终取当前位置,呼气结束后下一轮吸气直接从当前位置继续,不需要先回到中点,衔接更自然。

缓动:为什么不用线性

线性运动看起来很机械,真实的呼吸是"慢启动 → 中段加速 → 缓停"。这里用三次贝塞尔曲线做缓动,核心是一个牛顿迭代:给定时间进度 t,反求贝塞尔参数 u,再算出位移。迭代 8 次就能收敛,性能没问题。

地平线和文字用了不同的曲线——文字用更柔和的 (0.25, 0, 0.75, 1),穿越水面时速度最快(穿越太慢会暴露遮罩拼接痕迹)。


4. 逐帧驱动:让一切动起来

画面搭好了,状态机也有了,现在需要一个"心跳"每帧推进所有东西。

最直觉的方案是 NSTimer,但它有个致命问题——和屏幕刷新不同步。NSTimer 基于 RunLoop 调度,可能一帧触发两次或跳帧,画面抖动。

CADisplayLink 绑定到屏幕的 VSync 信号,60Hz 设备上每 16.6ms 回调一次,保证每帧恰好一次。它有两个关键属性:duration(两帧之间的时间差,用来推进状态机)和 timestamp(当前帧时间戳,传给正弦波函数)。

self.displayLink = [CADisplayLink displayLinkWithTarget:proxy
                                                selector:@selector(displayLinkTick:)];
 // 用 NSRunLoopCommonModes,不然用户滑动 ScrollView 时动画会暂停
 [self.displayLink addToRunLoop:[NSRunLoop mainRunLoop]
                        forMode:NSRunLoopCommonModes];

注意 addToRunLoop:forMode: 的 mode 参数。如果用默认的 NSDefaultRunLoopMode,当 RunLoop 进入 UITrackingRunLoopMode(比如用户在滑 ScrollView)时,displayLink 的回调会暂停。用 NSRunLoopCommonModes 可以避免这个问题。

循环引用问题

但 CADisplayLink 有个坑——displayLinkWithTarget:selector: 会强引用 target。如果直接传 self(VC),就形成了 VC → displayLink → VC 的循环引用,VC 永远释放不掉。

解决方案是用 NSProxy。NSProxy 是 OC 里和 NSObject 并列的另一个根类,天生为消息转发设计。继承它,内部用 weak 持有真正的 target,所有消息通过 forwardInvocation: 转发过去:

@interface XCBreathWeakProxy : NSProxy
 @property (nonatomic, weak, nullable) id target;
 @end
 
 @implementation XCBreathWeakProxy
 - (NSMethodSignature *)methodSignatureForSelector:(SEL)sel {
   return [self.target methodSignatureForSelector:sel]
        ?: [NSMethodSignature signatureWithObjCTypes:"v@:"];
 }
 - (void)forwardInvocation:(NSInvocation *)invocation {
   if (self.target) {
     [invocation invokeWithTarget:self.target];
   }
 }
 @end

引用链变成 displayLink → proxy -(weak)→ VC,VC 释放后 proxy 的 target 变 nil,回调变空操作,不崩溃。

CADisplayLink 没有 block API(截至目前只能传 target + selector),所以 WeakProxy 是这个场景的标准解法。

主循环

有了 displayLink,每帧跑一遍流程:

- (void)displayLinkTick:(CADisplayLink *)link {
   self.elapsedTime += link.duration;
   CGFloat progress = MIN(self.elapsedTime / self.totalDuration, 1.0);
   // 振幅从 1.0 衰减到 0.1,冥想越久水面越平静
   CGFloat amplitudeScale = 1.0f - 0.9f * progress;
 
   if (progress >= 1.0f) { [self beginFinishingSequence]; return; }
 
   BOOL phaseEnded = [self.stateMachine tickWithDelta:link.duration viewHeight:h];
 
   // 屏息张力:地平线不动,但波纹振幅从 1.0 悄悄增到 2.5
   CGFloat waveIntensity = 1.0f;
   if (self.stateMachine.currentPhase == XCBreathPhaseHold) {
     CGFloat holdProgress = self.stateMachine.phaseElapsed / self.stateMachine.phaseDuration;
     waveIntensity = 1.0f + MIN(holdProgress, 1.0f) * 1.5f;
   }
 
   [self.animationView updateWithHorizonY:horizonY
                              textAnchorY:textY
                                 waveTime:link.timestamp
                           amplitudeScale:amplitudeScale
                            waveIntensity:waveIntensity];
 
   if (phaseEnded) {
     [self enterPhase:XCBreathPhaseNext(self.stateMachine.currentPhase)];
   }
 }

两个细节值得说:

振幅衰减:amplitudeScale 从 1.0 线性衰减到 0.1。但保留了 10%——完全静止反而让人以为程序卡了。

屏息张力:屏息 7 秒中地平线不动,但 waveIntensity 从 1.0 增到 2.5 只乘给波纹——"表面克制、水面翻涌",暗示用户再坚持一下。

阶段切换的触觉反馈

每次切换阶段时,触发一次轻触觉提示用户"该换气了"。这里用 UIImpactFeedbackGenerator:

self.feedbackGenerator =
     [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
 [self.feedbackGenerator prepare];  // 提前预热 Taptic Engine
 
 // 阶段切换时
 [self.feedbackGenerator impactOccurred];
 [self.feedbackGenerator prepare];  // 为下次准备

prepare 会提前唤醒 Taptic Engine,否则首次 impactOccurred 会有约 50ms 延迟。冥想场景对时序要求不算高,但养成好习惯。

深色/浅色模式切换

冥想过程中用户可能切换系统外观(比如到了日落时间自动切暗色模式)。iOS 17 废弃了 traitCollectionDidChange:,新 API 更精确:

[self registerForTraitChanges:@[UITraitUserInterfaceStyle.class]
                   withHandler:^(id<UITraitEnvironment> env,
                                 UITraitCollection *previous) {
   [weakSelf applyCurrentTheme:YES];
 }];

只监听 UITraitUserInterfaceStyle 这一个 trait,size class 之类的变化不会触发,避免不必要的重绘。

切主题时,渐变颜色需要平滑过渡而不是瞬间跳变。前面提到 CATransaction 可以关闭隐式动画,反过来,它也能控制动画时长:

[CATransaction begin];
 [CATransaction setAnimationDuration:0.4f];
 [CATransaction setAnimationTimingFunction:
     [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]];
 self.skyGradientLayer.colors = @[...];   // 这次会有 0.4s 平滑过渡
 self.waterGradientLayer.colors = @[...];
 [CATransaction commit];

收尾

到达总时长后:停 DisplayLink → 文字淡出 → 0.8 秒静息 → dismiss。画面定格在最后一帧(振幅已经快降到 0.1),是"呼吸归于平静"而不是戛然而止。


总结

核心思路:对于多元素联动、参数实时变化的动画场景,CADisplayLink 逐帧驱动比声明式动画更灵活——每帧只需问"现在该画什么"。

呼吸节奏抽成状态机之后,换成任何呼吸法(box breathing 4-4-4-4、Wim Hof 等)只需改参数,View 零改动。

CAShapeLayer 遮罩是一个被低估的技巧,它让同一个文字在不同区域呈现不同颜色,比 blend mode 轻量得多。