UIDynamicAnimator + Core Motion 物理动画完全指南
目录
- UIKit Dynamics 是什么
- 核心概念:Animator 与 Behavior
- 让视图自由落体
- 加入碰撞边界
- 控制物理属性
- 施加推力
- 自定义碰撞路径
- 进阶:Core Motion 驱动重力方向
- 进阶:摇晃手机产生推力
- 实战架构:冥想主题选择器
- 踩坑记录与最佳实践
- 完整 API 速查表
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 开发期间。物理引擎不难,难的是让物理效果恰到好处——太少了无聊,太多了廉价。