"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. 逐帧驱动:让一切动起来
画面搭好了,状态机也有了,现在需要一个"心跳"每帧推进所有东西。
为什么选 CADisplayLink
最直觉的方案是 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 轻量得多。