Log Entry

UIDynamicAnimator + Core Motion 物理动画完全指南

这是我配的物理队,这个配队现在落后了吗,总伤还可以用吗

UIDynamicAnimator + Core Motion 物理动画完全指南

目录

UIKit Dynamics 是什么

UIKit Dynamics 是 Apple 在 iOS 7 引入的一套 2D 物理引擎,让开发者可以给 UIView 赋予真实世界的物理行为——重力、碰撞、弹性、摩擦、吸附等——而不需要手动计算任何物理公式

虽然不需要物理公式,但是对于一些物理概念,你仍然需要熟练掌握或了解

同时,使用物理引擎是为了更好的映射现实世界,所以使用的时候一定要把握好使用场景以及量和度

物理引擎与普通动画完全不同,二者的使用场景也不一样

与普通动画的对比

维度 UIView.animate / Spring UIKit Dynamics
驱动方式 时间曲线(duration + curve) 物理模拟(力 + 质量 + 碰撞)
终止条件 到达目标值 物理平衡(速度趋零)
交互性 有限 可实时施加外力、改变方向
多物体交互 不支持 自动处理碰撞、叠加
适用场景 属性过渡、弹簧回弹 自由落体、碰撞、真实物理效果

平台支持:

  • iOS 7.0+
  • iPadOS 7.0+
  • tvOS 9.0+

什么时候用

  • 需要物体之间相互碰撞、弹开
  • 需要重力方向随设备倾斜而变化
  • 需要用户「甩」「摇」等物理交互
  • 需要多个物体自然堆叠

如果只是一个简单的位移 + 弹簧回弹,UIView.animate 的 spring 参数已经足够,不需要动用物理引擎

核心概念:Animator 与 Behavior

UIKit Dynamics 的架构只有两个核心角色:

UIDynamicAnimator(物理世界)
 ├── UIGravityBehavior(重力)
 ├── UICollisionBehavior(碰撞)
 ├── UIDynamicItemBehavior(物理属性)
 ├── UIPushBehavior(推力)
 ├── UISnapBehavior(吸附)
 └── UIAttachmentBehavior(连接)

UIDynamicAnimator

物理世界的容器,负责驱动所有 behavior 的模拟计算。

// 创建 animator,referenceView 决定坐标系
 UIDynamicAnimator *animator = [[UIDynamicAnimator alloc] initWithReferenceView:self.view];

关键点: referenceView 决定了整个物理世界的坐标系。所有 behavior 中的坐标(碰撞边界、重力方向等)都相对于这个视图。选错 referenceView 是初学者最常见的坑(不是我,真的不是我)

UIDynamicBehavior

各种物理行为的基类。每个 behavior 管理一组 id<UIDynamicItem> 对象(UIView 默认就遵循该协议,谢天谢地)

Behavior 作用 典型参数
UIGravityBehavior 给物体施加持续的重力加速度 gravityDirection、magnitude
UICollisionBehavior 物体间碰撞 + 边界碰撞 boundary、translatesReferenceBoundsIntoBoundary
UIDynamicItemBehavior 物理属性(弹性、摩擦、密度等) elasticity、resistance、density
UIPushBehavior 施加推力(持续 / 瞬时) pushDirection、magnitude、mode
UISnapBehavior 吸附到某个点 snapPoint、damping
UIAttachmentBehavior 弹性连接两个物体 length、damping、frequency

UIDynamicItem 协议

UIView 已经默认遵循该协议,所以任何 UIView 都可以直接作为物理对象参与模拟。协议要求提供:

@protocol UIDynamicItem <NSObject>
 @property (nonatomic, readwrite) CGPoint center;
 @property (nonatomic, readonly) CGRect bounds; // 重点!!!要考!!!
 @property (nonatomic, readwrite) CGAffineTransform transform;
 @end

让视图自由落体

最简单的物理效果——给一个视图加重力,让它往下掉。

// 创建 animator
 self.animator = [[UIDynamicAnimator alloc] initWithReferenceView:self.view]; //  注意参考系
 
 // 创建重力行为,把 box 加进去
 UIGravityBehavior *gravity = [[UIGravityBehavior alloc] initWithItems:@[self.box]];
 [self.animator addBehavior:gravity];

运行后 box 会以 1000 pt/s² 的默认加速度向下坠落,直到飞出屏幕。

调整重力参数

// 改变重力大小(默认 1.0 ≈ 1000 pt/s²)
 gravity.magnitude = 2.0;  // 加倍
 
 // 改变重力方向(默认向下)
 gravity.gravityDirection = CGVectorMake(1.0, 0);  // 向右
 gravity.gravityDirection = CGVectorMake(0, -1.0);  // 向上(反重力)
 gravity.gravityDirection = CGVectorMake(0.5, 1.0);  // 斜向右下
 
 // 用角度设置方向
 gravity.angle = M_PI / 4;  // 45°

注意: 重力是持续力,会一直加速物体。如果没有碰撞边界,物体会加速飞出屏幕。


加入碰撞边界

光有重力不够,物体需要碰到东西停下来

不然你的UI控件就会在这个没有空气阻力的地方为你展示第一牛顿定律

使用 referenceView 边界

最简单的做法——把 referenceView 的 bounds 当作碰撞墙壁:

UICollisionBehavior *collision = [[UICollisionBehavior alloc] initWithItems:@[self.box]];
 collision.translatesReferenceBoundsIntoBoundary = YES;
 [self.animator addBehavior:collision];

这样 box 会掉到 referenceView 底部并弹几下停住

关于_initWithItems_方法:

UICollisionBehavior 的 initWithItems: 方法接收一个数组,数组里放的是你想让物理碰撞引擎"管理"的动态元素。每个元素必须遵循 UIDynamicItem 协议,而 UIView 默认就实现了这个协议,所以直接传 view 就行

方法可以传多个,比如 @[self.box, self.ball, self.block]__,它们之间以及与边界之间都会发生碰撞

自定义碰撞边界

实际项目中很少用 referenceView 的完整边界。比如我们只想要底部和左右两面墙:

UICollisionBehavior *collision = [[UICollisionBehavior alloc] initWithItems:@[self.box]];
 
 // 底部边界
 [collision addBoundaryWithIdentifier:@"bottom"
                            fromPoint:CGPointMake(0, 300)
                              toPoint:CGPointMake(280, 300)];
 
 // 左边界
 [collision addBoundaryWithIdentifier:@"left"
                            fromPoint:CGPointMake(0, 0)
                              toPoint:CGPointMake(0, 300)];
 
 // 右边界
 [collision addBoundaryWithIdentifier:@"right"
                            fromPoint:CGPointMake(280, 0)
                              toPoint:CGPointMake(280, 300)];
 
 [self.animator addBehavior:collision];

用 UIBezierPath 做不规则边界

在一些情况下,比如你为了美化,给你的边界视图加了一个圆角,你可能会发现视图并不会按照圆角符合物理定律

这个时候就需要定义一个不规则边界了

UIBezierPath *curvePath = [UIBezierPath bezierPath]; // 创建空路径
 [curvePath moveToPoint:CGPointMake(0, 250)]; // 把笔,移动到起点
 [curvePath addQuadCurveToPoint:CGPointMake(300, 250)
                   controlPoint:CGPointMake(150, 350)]; // 从起点画一条二次贝塞尔曲线到终点 (300, 250),控制点是 (150, 350)
 [collision addBoundaryWithIdentifier:@"curve" forPath:curvePath]; // 刚才画的贝塞尔路径注册为一条具名碰撞边界。@"curve" 是这条边界的唯一标识符

UIBezierPath 是 iOS 中用来描述矢量路径的类,可以画直线、曲线、圆弧等。这里用的是二次贝塞尔曲线(Quad Curve)(没错,想不到吧,除了物理,数学你也得学!!!)

这种方式比 translatesReferenceBoundsIntoBoundary 灵活得多,你可以在屏幕任意位置、以任意形状定义碰撞边界,实现斜坡、弧面、迷宫墙壁等复杂效果

碰撞回调

实现 UICollisionBehaviorDelegate 可以监听碰撞事件:

// 设置代理
 collision.collisionDelegate = self;
 
 // 物体碰到边界
 - (void)collisionBehavior:(UICollisionBehavior *)behavior
       beganContactForItem:(id<UIDynamicItem>)item
    withBoundaryIdentifier:(id<NSCopying>)identifier
                   atPoint:(CGPoint)p {
   NSLog(@"碰到了边界: %@", identifier);
 }
 
 // 物体碰到物体
 - (void)collisionBehavior:(UICollisionBehavior *)behavior
       beganContactForItem:(id<UIDynamicItem>)item1
                  withItem:(id<UIDynamicItem>)item2
                   atPoint:(CGPoint)p {
   NSLog(@"两个物体碰撞了");
 }

在这个例子中,这两个方法并没有加入一些其他的东西。你可以在这里做一些比如伤害操作,或者播放一些声音等

控制物理属性

UIDynamicItemBehavior 控制物体自身的物理特性。

UIDynamicItemBehavior *itemBehavior = [[UIDynamicItemBehavior alloc]
                                         initWithItems:@[self.box]];
 itemBehavior.elasticity = 0.3;       // 弹性 (0 = 不弹, 1 = 完美弹性碰撞)
 itemBehavior.resistance = 0.3;       // 线性阻力 (0 = 无阻力, 越大越快停下)
 itemBehavior.density = 1.0;          // 密度 (影响碰撞时的动量)
 itemBehavior.allowsRotation = YES;   // 是否允许旋转
 itemBehavior.angularResistance = 0;  // 旋转阻力
 itemBehavior.friction = 0.1;         // 摩擦力 (0 = 无摩擦, 1 = 高摩擦)
 [self.animator addBehavior:itemBehavior];

你可以注意到,这里涉及到了一些关于高中物理的知识,即完全弹性碰撞和非完全弹性碰撞等内容(byd 高中物理还在追我)

事实上,想用这个框架一些更加真实的效果的话,基本的物理知识是必备的,

包括下面会提到的一些参数效果实际上是什么效果,都需要开发者根据自己的ui材质来选择

材质的外表决定了他在用户心中的心智模型,而你的物理效果一定要根据用户的心智模型来调整

一个看起来像橡皮的球弹力却几乎完全没有,这理论上是可以的,但在你的 app中,我强烈不建议这样使用

具体可以查看我之前的博客:基本的设计原则 或者更好的最佳实践,苹果人机交互指南

各参数效果对比

参数 范围 低值效果 高值效果
elasticity 0.0 ~ 1.0 落地不弹(沙袋) 弹得很高(橡皮球)
resistance 0.0 ~ ∞ 永远不停(太空) 快速减速(水中)
density 0.0 ~ ∞ 轻如羽毛 重如铅球
friction 0.0 ~ 1.0 冰面滑行 粗糙地面
angularResistance 0.0 ~ ∞ 自由旋转 快速停转

手动读取 / 设置速度

// 读取线速度
 CGPoint velocity = [itemBehavior linearVelocityForItem:self.box];
 
 // 附加速度(不是设置,是叠加)
 [itemBehavior addLinearVelocity:CGPointMake(100, 0) forItem:self.box];
 
 // 附加角速度
 [itemBehavior addAngularVelocity:M_PI forItem:self.box];

施加推力

牛顿第二定律!

UIPushBehavior 可以给物体施加一个推力,分两种模式:

持续推力(Continuous)

UIPushBehavior *push = [[UIPushBehavior alloc]
                          initWithItems:@[self.box]
                                   mode:UIPushBehaviorModeContinuous];
 push.pushDirection = CGVectorMake(0, -1.0);  // 向上推
 push.magnitude = 0.5;
 [self.animator addBehavior:push];

持续推力类似火箭推进器,只要 behavior 存在就一直施力。可以用来模拟风、水流等环境力。

瞬时推力(Instantaneous)

UIPushBehavior *push = [[UIPushBehavior alloc]
                          initWithItems:@[self.box]
                                   mode:UIPushBehaviorModeInstantaneous];
 push.pushDirection = CGVectorMake(2.0, -1.0);  // 右上方向
 push.active = YES;  // 必须设为 YES 才会触发
 [self.animator addBehavior:push];

瞬时推力类似被打了一拳,只在那一瞬间施加一个冲量,然后物体靠惯性运动

可以依靠一些基本的物理学,比如说动量定理等用来辅助你的属性设置

关键区别:

  • Continuous:力 → 加速度 → 速度持续增加
  • Instantaneous:冲量 → 速度瞬间改变 → 之后靠惯性

实用技巧:推完记得清理

瞬时推力触发后不会自动从 animator 中移除。如果你不清理,后续的 push.active = YES 赋值会再次触发。建议用完就移除:

// 移除旧推力
 if (self.motionPush) {
   [self.animator removeBehavior:self.motionPush];
 }
 
 // 创建新推力
 UIPushBehavior *push = [[UIPushBehavior alloc]
                          initWithItems:items
                                   mode:UIPushBehaviorModeInstantaneous];
 push.pushDirection = CGVectorMake(ax, ay);
 push.active = YES;
 [self.animator addBehavior:push];
 self.motionPush = push;  // 保存引用以便下次移除

自定义碰撞路径

默认情况下,UIKit Dynamics 用 UIView 的 bounds(矩形)做碰撞检测

但如果你的视图是圆角胶囊形,矩形碰撞会看起来很假——两个胶囊还没视觉上接触就弹开了

不光如此,你的胶囊按钮还会立在地上,以一个非常离谱的角度稳定站立

碰撞边界类型

UIDynamicItem 协议允许自定义碰撞形状:

typedef NS_ENUM(NSUInteger, UIDynamicItemCollisionBoundsType) {
   UIDynamicItemCollisionBoundsTypeRectangle,  // 默认矩形
   UIDynamicItemCollisionBoundsTypeEllipse,    // 椭圆(内切于 bounds)
   UIDynamicItemCollisionBoundsTypePath,       // 自定义路径
 };

为胶囊形视图自定义碰撞路径

在自定义 UIView 子类中重写两个方法:

@implementation XCThemeTagButton
 
 /// 告诉物理引擎使用自定义路径
 - (UIDynamicItemCollisionBoundsType)collisionBoundsType {
   return UIDynamicItemCollisionBoundsTypePath; // 设置枚举
 }
 
 /// 返回碰撞路径(坐标系以 center 为原点)
 - (UIBezierPath *)collisionBoundingPath {
   CGRect bounds = self.bounds;
   CGFloat radius = bounds.size.height / 2.0;
 
   // 注意:坐标系以视图 center 为原点,不是左上角
   CGRect centeredRect = CGRectMake(-bounds.size.width / 2.0,
                                    -bounds.size.height / 2.0,
                                    bounds.size.width,
                                    bounds.size.height);
   return [UIBezierPath bezierPathWithRoundedRect:centeredRect
                                     cornerRadius:radius];
 }
 
 @end

重要约束:

  • 路径必须是凸多边形(convex),不能有凹陷
  • 路径的坐标系以视图的 center 为原点 (0, 0)
  • 路径必须逆时针绘制(UIBezierPath 的标准方法默认就是逆时针)
  • 如果路径不是凸的,运行时会直接崩溃

进阶:Core Motion 驱动重力方向

到目前为止,重力方向是固定的。真正有趣的效果是——让用户倾斜手机,标签就像水一样流向低处

目前来说,我在这部分做出的东西也并不够完美

主要是由于如何把两个框架融入进一起,并按照物理计算法则改变物理属性(真得好好学学高中物理了)

CMMotionManager 基础

Core Motion 框架提供设备运动数据,其中 CMDeviceMotion 是最常用的高级接口,它融合了加速度计、陀螺仪和磁力计的数据

#import <CoreMotion/CoreMotion.h>
 
 @property (nonatomic, strong) CMMotionManager *motionManager;

获取设备重力方向

CMDeviceMotion.gravity 提供设备相对于地球重力的方向分量:

self.motionManager = [[CMMotionManager alloc] init];
// 设置采样频率,保证与屏幕刷新率相同(当然你可以使用120)
 self.motionManager.deviceMotionUpdateInterval = 1.0 / 60.0;  // 60Hz
 
 [self.motionManager startDeviceMotionUpdatesToQueue:[NSOperationQueue mainQueue]
                                        withHandler:^(CMDeviceMotion *motion, NSError *error) {
   if (!motion || error) return;
 
   // CMAcceleration.gravity 坐标系:
   //   x: 正值 = 设备向右倾斜
   //   y: 正值 = 设备顶部朝上
   //
   // UIKit 坐标系:
   //   x: 正值 = 向右
   //   y: 正值 = 向下
   //
   // 所以 y 要取反
   CGFloat gx = motion.gravity.x;
   CGFloat gy = -motion.gravity.y;
 
   CGFloat magnitude = 2.0;
   self.sharedGravity.gravityDirection = CGVectorMake(gx * magnitude,
                                                      gy * magnitude);
 }];

startDeviceMotionUpdatesToQueue:withHandler: 这个方法启动设备运动更新,每次传感器有新数据时就在指定队列上回调 handler。这里用的是 mainQueue,意味着回调直接在主线程执行,可以安全地更新 UI 相关的物理属性而不需要额外的线程同步

此外,在返回的结构体CMAcceleration 中有一个巨大的需要后期处理的问题,即与UI坐标系差别

坐标系对照

由于没办法在简单的文字里来讲明白物理和传感器数据以及UI 的对应关系,这里让ai 用文字的方式尽可能的展现清楚

CMMotion              UIKit 屏幕
           +y                    +y
           ↑                      ↓
     -x ←  ●  → +x         -x ←  ●  → +x
           ↓                      ↑
           -y                    -y
 
    (设备朝上为正 y)       (屏幕向下为正 y)

对于常见物理系上来讲,Y 轴正方向朝上,UIKit 朝向则是Y正方向朝下

所以映射规则是:

  • x 方向一致:设备向右倾斜 → 标签向右滚
  • y 方向相反:取反 gravity.y

更新频率的选择

频率 适用场景 CPU 开销
10 Hz 粗略方向检测 极低
30 Hz 普通交互 低
60 Hz 流畅物理模拟 中等
100 Hz 运动类游戏 较高

对于 UIKit Dynamics,推荐使用 60 Hz,与屏幕刷新同步,确保物理模拟不会出现视觉上的延迟

毕竟不是一个游戏,对一个需要使用物理属性来展示的应用上,简单的60Hz 完全足够

进阶:摇晃手机产生推力

CMDeviceMotion 除了 .gravity(地球引力分量),还有 .userAcceleration(用户主动施加的加速度)。两者之和等于加速度计的原始读数,所以拿到数据的第一步应该是分开这两个数据,因为引力使用来获取设备姿态的,而人为加速度是我们需要直接对UI 元素操作的

区分 gravity 与 userAcceleration

总加速度 = gravity(地球引力)+ userAcceleration(人为加速度)
  • gravity:设备静止时也有,反映设备姿态
  • userAcceleration:只在用户移动 / 摇晃设备时产生

实现摇晃推力

// 在 deviceMotion handler 中
 
 CGFloat ax = motion.userAcceleration.x;
 CGFloat ay = -motion.userAcceleration.y;  // 同样取反
 
 CGFloat accelMagnitude = sqrt(ax * ax + ay * ay);
 
 // 过滤微小抖动,只响应明显的摇晃
 if (accelMagnitude > 0.3) {
   // 移除旧推力
   if (self.motionPush) {
     [self.animator removeBehavior:self.motionPush];
   }
 
   // 收集需要施力的物体
   NSMutableArray *items = [NSMutableArray array];
   for (XCThemeTagButton *tag in self.tagButtons) {
     if (tag.isSelected) [items addObject:tag];
   }
 
   if (items.count > 0) {
     UIPushBehavior *push = [[UIPushBehavior alloc]
                              initWithItems:items
                                       mode:UIPushBehaviorModeInstantaneous];
     CGFloat pushScale = 1.5;  // 放大系数
     push.pushDirection = CGVectorMake(ax * pushScale, ay * pushScale);
     push.active = YES;
     [self.animator addBehavior:push];
     self.motionPush = push;
 
     // 加速度较大时给触觉反馈
     if (accelMagnitude > 1.0) {
       UIImpactFeedbackGenerator *feedback = [[UIImpactFeedbackGenerator alloc]
                                               initWithStyle:UIImpactFeedbackStyleMedium];
       [feedback impactOccurred];
     }
   }
 }

阈值调优

阈值 效果
0.1 极度灵敏,走路就会触发
0.3 推荐值,需要有意识地晃动
0.5 需要用力摇
1.0 只有剧烈摇晃才触发

建议设置两级阈值:低阈值触发推力但不给反馈,高阈值同时触发触觉反馈,分层的体验更自然。

如果反馈给的过于多,即碰撞一次反馈一次,由于在这里进行触觉反馈自定义的花销有亿点点大,所以你只能退而求其次使用一样的反馈

多次一样的反馈叠在一起,一个影响另一个,就会导致用户根本感知不到反馈

即反馈越多,反馈越少(雾)

实战架构:冥想主题选择器

上面讲了各个零件,现在看看在凪中它们是怎么组装在一起的。

整体效果

冥想主题选择器是一个弹窗卡片,里面有 8 个胶囊形标签(放松、专注、平静…)

用户点击标签时:

  • 选中 → 标签变半透明,受重力坠落到卡片底部,可以互相碰撞
  • 取消 → 标签从当前物理位置弹簧回弹到原来的网格位置
  • 倾斜手机 → 所有已坠落的标签随倾斜方向滚动
  • 摇晃手机 → 标签被甩向摇晃方向

类图

XCMeditationThemePickerView(弹窗主视图)
 ├── overlayView(半透明遮罩)
 ├── cardView(白色卡片)
 │   ├── titleLabel(选择冥想主题)
 │   ├── XCTagContainerView(标签容器,自定义 hitTest)
 │   │   └── XCThemeTagButton × 8(胶囊标签,自定义碰撞路径)
 │   └── confirmButton(开始冥想)
 ├── UIDynamicAnimator(物理世界,referenceView = tagContainer)
 │   ├── UIGravityBehavior(重力,方向由 Core Motion 控制)
 │   ├── UICollisionBehavior(底部 + 左 + 右边界)
 │   ├── UIDynamicItemBehavior(弹性 0.3 / 阻力 0.3)
 │   └── UIPushBehavior(摇晃推力,动态创建 / 销毁)
 └── CMMotionManager(60Hz 设备运动数据)

核心设计决策

referenceView 选择 tagContainer 而非 cardView

self.animator = [[UIDynamicAnimator alloc] initWithReferenceView:self.tagContainer];

为什么? 标签的坐标是相对于 tagContainer 的。如果用 cardView 做 referenceView,所有碰撞边界坐标都要加上 tagContainer 在 cardView 中的偏移量,容易出错。选择 tagContainer 让坐标系保持一致。

每次选中 / 取消都完全重建物理系统

- (void)rebuildPhysics {
   // 保存重力方向(Core Motion 可能已修改)
   CGVector currentGravityDir = self.sharedGravity
     ? self.sharedGravity.gravityDirection
     : CGVectorMake(0, 2.0);
 
   // 彻底清空
   [self.animator removeAllBehaviors];
   self.motionPush = nil;
 
   // 收集选中标签
   NSMutableArray *selectedTags = [NSMutableArray array];
   for (XCThemeTagButton *tag in self.tagButtons) {
     if (tag.isSelected) [selectedTags addObject:tag];
   }
 
   // 用全新实例重建
   self.sharedGravity = [[UIGravityBehavior alloc] initWithItems:selectedTags];
   self.sharedGravity.gravityDirection = currentGravityDir;
   [self.animator addBehavior:self.sharedGravity];
 
   // ... collision、itemBehavior 同样全新创建
 }

为什么不增量更新?

UIDynamicAnimator 内部维护了缓存状态。如果你只是 removeItem: 再 addItem:,有概率出现:

  • 物体位置突变(animator 使用了过时的缓存坐标)
  • 碰撞检测失效(内部碰撞对未正确更新)
  • 已移除的物体仍然受到残留力的影响

完全重建虽然粗暴,但保证了状态的干净。对于标签数量在 10 个以内的场景,性能开销可以忽略

取消选中用弹簧动画而非物理弹簧

- (void)deselectTag:(XCThemeTagButton *)tag {
   tag.isSelected = NO;
 
   // 捕获当前物理位置
   CGPoint currentCenter = tag.center;
   CGAffineTransform currentTransform = tag.transform;
 
   // 重建物理(此 tag 不再参与)
   [self rebuildPhysics];
 
   // 还原到捕获位置
   tag.center = currentCenter;
   tag.transform = currentTransform;
 
   // 弹簧动画回原位
   [UIView animateWithDuration:0.5
                         delay:0
        usingSpringWithDamping:0.6
         initialSpringVelocity:0.8
                       options:0
                    animations:^{
     tag.center = tag.originalCenter;
     tag.transform = CGAffineTransformIdentity;
     [tag applyUnselectedStyle];
   } completion:nil];
 }

自定义 hitTest 让超出 bounds 的标签可点击

标签坠落后可能超出 tagContainer 的 bounds(比如弹跳过程中)。UIKit 默认不会把超出 bounds 的子视图纳入 hit test。所以需要自定义:

@implementation XCTagContainerView
 
 - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
   UIView *result = [super hitTest:point withEvent:event];
   if (result) return result;
 
   // 遍历所有子视图,包括超出 bounds 的
   for (UIView *subview in self.subviews.reverseObjectEnumerator) {
     CGPoint subPoint = [subview convertPoint:point fromView:self];
     UIView *hit = [subview hitTest:subPoint withEvent:event];
     if (hit) return hit;
   }
   return nil;
 }
 
 @end

同时在外层 XCMeditationThemePickerView 也重写了 hitTest,确保 cardView 范围内超出 tagContainer 的标签也能响应触摸。

无障碍降级

由于一些可能不需要动画效果的人,或者说为了可以让apple 上架的原因

当用户开启了减弱动态效果时,应降级为简单动画:

BOOL reduceMotion = UIAccessibilityIsReduceMotionEnabled();
 
 if (reduceMotion) {
   // 不使用物理引擎,直接用普通动画移动到底部
   [UIView animateWithDuration:0.3 animations:^{
     tag.center = CGPointMake(tag.center.x,
                               self.bottomBoundaryY - tag.bounds.size.height / 2.0);
     [tag applySelectedStyle];
   }];
   return;
 }

这不仅是无障碍最佳实践,也是 App Store 审核的隐性要求——物理动画可能让部分用户不适

在所有的动画部分都应该注意这一点,即有下位替代动画

触觉反馈节流

碰撞事件在物理模拟中触发频率非常高——一次落地弹跳可能在几十毫秒内触发多次碰撞回调。如果每次都震动,马达会糊成连续噪音:

- (void)fireCollisionFeedbackIfNeeded {
   NSTimeInterval now = CACurrentMediaTime();
   if (now - self.lastCollisionFeedbackTime < 0.15) return;
   self.lastCollisionFeedbackTime = now;
 
   UIImpactFeedbackGenerator *feedback = [[UIImpactFeedbackGenerator alloc]
                                           initWithStyle:UIImpactFeedbackStyleSoft];
   [feedback impactOccurred];
 }

0.15 秒的节流间隔让每次弹跳能感受到 2~3 次清晰的振动,而不是一坨模糊的嗡嗡声。


踩坑记录与最佳实践

物体加入 animator 后 frame 由物理引擎控制

一旦把 UIView 添加到任何 behavior 中,它的 center 和 transform 就由 animator 驱动了。此时你手动设置 view.center = ... 不会生效,或者会导致物理状态不一致。

正确做法: 要修改物体位置,先从所有 behavior 中移除它,修改位置,再重新添加。或者像我们一样直接 rebuildPhysics。

removeAllBehaviors 不会重置物体位置

[self.animator removeAllBehaviors];
 // tag.center 仍然停留在最后一帧的物理位置
 // tag.transform 仍然是旋转后的角度

移除 behavior 只是停止了物理模拟,不会把物体传送回原位。你需要手动恢复。

collisionBoundingPath 的坐标系

自定义碰撞路径的坐标系以视图的 center 为原点,不是 bounds 的左上角。忘记偏移会导致碰撞检测偏移半个视图大小。

// 错误:以左上角为原点
 CGRect wrong = self.bounds;
 
 // 正确:以 center 为原点
 CGRect correct = CGRectMake(-self.bounds.size.width / 2.0,
                              -self.bounds.size.height / 2.0,
                              self.bounds.size.width,
                              self.bounds.size.height);

Core Motion 需要真机

CMMotionManager 在模拟器上的 isDeviceMotionAvailable 返回 NO。请务必做可用性检查,不要让功能在模拟器上崩溃。

记得停止 Core Motion

视图消失时必须停止更新,否则加速度计会持续运行,浪费电量:

- (void)dismiss {
   [self stopMotionUpdates];  // 先停 Core Motion
   // ... 然后做关闭动画
 }
 
 - (void)stopMotionUpdates {
   if (self.motionManager.isDeviceMotionActive) {
     [self.motionManager stopDeviceMotionUpdates];
   }
   self.motionManager = nil;
 }

最佳实践清单

实践 原因
referenceView 选择坐标系最方便的视图 减少坐标转换错误
物理系统状态复杂时选择完全重建 避免缓存污染
碰撞触觉反馈做节流(≥ 0.1s) 避免马达连续噪音
检查 UIAccessibilityIsReduceMotionEnabled() 无障碍降级
检查 isDeviceMotionAvailable 模拟器兼容
视图消失时清理 animator + 停止 Core Motion 防止内存泄漏和电量浪费
瞬时推力用完即移除 防止意外重复触发
collisionBoundingPath 用 center 原点 碰撞检测位置正确
物理效果标签数量控制在 20 个以内 性能保障

完整 API 速查表

对于一些勇于尝试动画的开发者,这里让ai专门整理了一份可以对应查找的API表,需要提醒的是,在ai横行的时代

API这个东西本身已经不重要了,所以这里的速查表只是起一个参考查找的作用,而不是让各位对照表像背公式一样全部背诵下来

UIDynamicAnimator

方法 / 属性 说明
initWithReferenceView: 创建 animator,绑定坐标系
addBehavior: 添加物理行为
removeBehavior: 移除物理行为
removeAllBehaviors 清空所有行为
running 是否正在模拟
elapsedTime 已模拟的时间
itemsInRect: 查询矩形区域内的物体
delegate UIDynamicAnimatorDelegate,提供 dynamicAnimatorDidPause: / willResume:

UIGravityBehavior

方法 / 属性 说明
initWithItems: 创建并绑定物体
addItem: / removeItem: 动态增减物体
gravityDirection 重力方向向量(CGVector)
angle 重力角度(弧度)
magnitude 重力大小(默认 1.0 ≈ 1000 pt/s²)

UICollisionBehavior

方法 / 属性 说明
initWithItems: 创建并绑定物体
translatesReferenceBoundsIntoBoundary 用 referenceView 边界做碰撞墙
addBoundaryWithIdentifier:fromPoint:toPoint: 线段边界
addBoundaryWithIdentifier:forPath: 路径边界
removeBoundaryWithIdentifier: 移除指定边界
removeAllBoundaries 清除所有边界
collisionMode .items / .boundaries / .everything
collisionDelegate 碰撞事件回调

UIDynamicItemBehavior

方法 / 属性 说明
elasticity 弹性系数 (0.0 ~ 1.0)
friction 摩擦力 (0.0 ~ 1.0)
density 密度(影响碰撞动量)
resistance 线性阻力 (0 = 无阻力)
angularResistance 旋转阻力
allowsRotation 是否允许旋转
charge 电荷(配合 UIFieldBehavior 使用)
isAnchored 是否固定不动
addLinearVelocity:forItem: 附加线速度
linearVelocityForItem: 读取当前线速度
addAngularVelocity:forItem: 附加角速度
angularVelocityForItem: 读取当前角速度

UIPushBehavior

方法 / 属性 说明
initWithItems:mode: 创建(Continuous / Instantaneous)
pushDirection 推力方向(CGVector)
magnitude 推力大小
angle 推力角度
active 是否激活(Instantaneous 模式需手动设 YES)

CMMotionManager

方法 / 属性 说明
deviceMotionUpdateInterval 更新间隔(秒)
isDeviceMotionAvailable 设备是否支持
isDeviceMotionActive 是否正在更新
startDeviceMotionUpdatesToQueue:withHandler: 开始更新
stopDeviceMotionUpdates 停止更新

CMDeviceMotion

属性 说明
gravity 重力加速度分量(CMAcceleration)
userAcceleration 用户加速度分量(CMAcceleration)
attitude 设备姿态(roll / pitch / yaw)
rotationRate 旋转速率

写于「凪」v1.0 开发期间。物理引擎不难,难的是让物理效果恰到好处——太少了无聊,太多了廉价。