logo头像

学如逆水行舟

谈谈iOS中的原生物理引擎——UIDynamic的应用

谈谈iOS中的原生物理引擎——UIDynamic的应用

UIDynamic是iOS中UIKit框架提供的接口,其用来为UI元素增加符合物理世界运动规则的动画行为。简单来说,UIDynamic提供的实际上是一个物理引擎,由于它是iOS原生系统支持的(iOS 7以上),因此兼容性和易用性非常好,使用它开发者可以非常方便的创建出物理动画。本篇文章,我们将讨论UIDynamic的设计架构、使用方法以及做一些简单的物理动画示例,希望可以在应用开发中为你带来一些启发。

从一个碰撞动画示例开始

在开始具体完整的介绍UIDynamic相关功能和接口前,我们可以先通过一个示例来体验下UIDynamic强大的功能以及开发流程。

假如我们要实现这样一个动画效果:

模拟一个台球游戏,首先在窗口中显示一个矩形区域作为球桌,其中放置一个台球元素,给其一个初始的速度和方向来模拟发球动作,之后台球将按照预设的物理规律在桌面上进行碰撞运动。

先来看一下效果实例:

实现上面效果的核心代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var animator: UIDynamicAnimator!
// 执行动画
func addCollision() {
animator = UIDynamicAnimator(referenceView: box)
// 添加碰撞行为
let collision = UICollisionBehavior()
collision.translatesReferenceBoundsIntoBoundary = true
collision.addItem(ball)
// 添加推动行为
let push = UIPushBehavior(items: [ball], mode: .instantaneous)
push.addItem(ball)
push.pushDirection = .init(dx: 0.5, dy: 0.5)
push.magnitude = 5
// 定义元素行为
let item = UIDynamicItemBehavior(items: [ball])
item.resistance = 1
item.elasticity = 0.8
// 将定义的行为添加到引擎动画中
animator.addBehavior(push)
animator.addBehavior(collision)
animator.addBehavior(item)
}

可以看到,实现此物理动画的主要流程包括3个要素:

动画元素View

物理仿真器Animator

物理行为Behavior

上面示例代码中,添加了3种物理行为,UICollisionBehavior用来定义碰撞行为,可以定义要产生碰撞的元素。UIPushBehavior用来定义推动行为,可以给物理元素一个推力。UIDynamicItemBehavior用来定义物理元素本身的性质,例如摩擦力,质量等。下面我们会逐一讨论这些要素。

关于动画元素的定义

定义可动画元素:UIDynamicItem

任何物理行为都需要作用在某一个具体的UI元素上,要支持物理引擎的元素需要实现UIDynamicItem协议,此协议的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@MainActor public protocol UIDynamicItem : NSObjectProtocol {
// 物理上元素的中心
var center: CGPoint { get set }
// 物理上元素的bounds
var bounds: CGRect { get }
// transform
var transform: CGAffineTransform { get set }
// 边界类型
@available(iOS 9.0, *)
optional var collisionBoundsType: UIDynamicItemCollisionBoundsType { get }
// 边界Path
@available(iOS 9.0, *)
optional var collisionBoundingPath: UIBezierPath { get }
}

UIKit框架中的UIView类默认实现了UIDynamicItem协议,因此所有UIView的子类都可以直接无缝使用UIDynamic提供的物理能力。通过子类对collisionBoundsType属性,可以对物理元素的边界进行细化的定制:

1
2
3
4
5
6
7
8
9
@available(iOS 9.0, *)
public enum UIDynamicItemCollisionBoundsType : UInt, @unchecked Sendable {
// 矩形边界(bounds来控制)
case rectangle = 0
// 椭圆边界(有width和height来控制椭圆的两个轴)
case ellipse = 1
// 路径边界(自定义边界)
case path = 2
}

定义动画元素的属性:UIDynamicItemBehavior

UIDynamicItemBehavior本身也是Behavior的那种,和其他物理行为不同的是,UIDynamicItemBehavior侧重于定义动画元素本身的属性。UIDynamicItemBehavior的定义会影响元素本身运动过程中的阻力、弹力、惯性等行为。解析如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@available(iOS 7.0, *)
@MainActor open class UIDynamicItemBehavior : UIDynamicBehavior {
// 初始化方法,添加一组作用于的元素
public init(items: [any UIDynamicItem])
// 添加作用于的元素
open func addItem(_ item: any UIDynamicItem)
// 移除某个作用的元素
open func removeItem(_ item: any UIDynamicItem)
open var items: [any UIDynamicItem] { get }
// 弹性设置,0到1之间,值越大元素的弹性越大
open var elasticity: CGFloat
// 摩擦力设置,0表示无摩擦力,当两个物理元素接触滑动时,此值会有影响
open var friction: CGFloat // 0 being no friction between objects slide along each other
// 密度设置 默认为1,密度大的元素加速和减速都能加困难
open var density: CGFloat
// 阻尼设置,控制物体运动的阻力
open var resistance: CGFloat
// 旋转阻尼设置,控制物理旋转的阻力
open var angularResistance: CGFloat // 0: no angular velocity damping
// 电荷设置,决定电磁感应的程度
@available(iOS 9.0, *)
open var charge: CGFloat
// 设置当前元素是否作为锚定元素,锚定的元素会作用碰撞,但不会被碰撞影响,通常用来做碰撞的边界
@available(iOS 9.0, *)
open var isAnchored: Bool
// 是否允许元素旋转
open var allowsRotation: Bool

// 为一个元素添加线性的加速度
open func addLinearVelocity(_ velocity: CGPoint, for item: any UIDynamicItem)
open func linearVelocity(for item: any UIDynamicItem) -> CGPoint

// 为一个元素添加一个角加速度
open func addAngularVelocity(_ velocity: CGFloat, for item: any UIDynamicItem)
open func angularVelocity(for item: any UIDynamicItem) -> CGFloat
}

物理仿真器

物理仿真器由UIDynamicAnimator类来描述。UIDynamicAnimator的主要作用是将动画元素和物力行为进行结合,驱动出仿真的物理动画。此类定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@available(iOS 7.0, *)
open class UIDynamicAnimator : NSObject {
// 初始化,并关联一个视图,关联的视图将作为参照坐标系
public init(referenceView view: UIView)
// 添加一个物理行为
open func addBehavior(_ behavior: UIDynamicBehavior)
// 移除物理行为
open func removeBehavior(_ behavior: UIDynamicBehavior)
// 移除所有物理行为
open func removeAllBehaviors()
// 参照视图
open var referenceView: UIView? { get }
// 所有行为对象
open var behaviors: [UIDynamicBehavior] { get }
// 左右作用的物理元素
open func items(in rect: CGRect) -> [any UIDynamicItem]
// 更新一个元素的状态(当此物理引擎系统外部造成的改变时调用此方法更新)
open func updateItem(usingCurrentState item: any UIDynamicItem)
// 是否正在执行
open var isRunning: Bool { get }
// 目前执行的时长
open var elapsedTime: TimeInterval { get }
// 设置代理
weak open var delegate: (any UIDynamicAnimatorDelegate)?
}

UIDynamicAnimatorDelegate协议可以监听物理动画的状态:

1
2
3
4
5
6
7
8
@MainActor public protocol UIDynamicAnimatorDelegate : NSObjectProtocol {
// 动画即将恢复
@available(iOS 7.0, *)
optional func dynamicAnimatorWillResume(_ animator: UIDynamicAnimator)
// 动画暂停
@available(iOS 7.0, *)
optional func dynamicAnimatorDidPause(_ animator: UIDynamicAnimator)
}

物理行为定义

物理行为可以实现复杂的2D物理动画,我们可以单独使用这些物理行为,也可以将物理行为进行组合使用。

UIDynamicBehavior基类

UIDynamicBehavior是所有物理行为的基类,其中定义了一些公共的方法和属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@available(iOS 7.0, *)
@MainActor open class UIDynamicBehavior : NSObject {
// 添加子行为
open func addChildBehavior(_ behavior: UIDynamicBehavior)
// 移除子行为
open func removeChildBehavior(_ behavior: UIDynamicBehavior)
open var childBehaviors: [UIDynamicBehavior] { get }
// 物理仿真器在执行动画时会调用此方法
open var action: (() -> Void)?
// 添加和移除仿真器
open func willMove(to dynamicAnimator: UIDynamicAnimator?) // nil when being removed from an animator
// 关联的仿真器
open var dynamicAnimator: UIDynamicAnimator? { get }
}

依附行为:UIAttachmentBehavior

简单理解,依附行为就像将元素与锚点间连接上了一条线,效果如下:

示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func addAttachment () {
animator = UIDynamicAnimator(referenceView: box)

// 添加重力行为
let gravity = UIGravityBehavior(items: [ball])
gravity.gravityDirection = .init(dx: 0, dy: 1)

// 添加附着力行为
let attachment = UIAttachmentBehavior(item: ball, attachedToAnchor: CGPoint(x: box.frame.width / 2 + 35 , y: box.frame.height / 2))
attachment.length = 50

animator.addBehavior(gravity)
animator.addBehavior(attachment)

}

UIAttachmentBehavior提供了多种初始化的方式,可以使用一个点作为锚点,也可以将另一个视图作为锚点:

1
2
3
4
5
6
7
8
9
// 以一个点作为锚点进行依附
public convenience init(item: any UIDynamicItem, attachedToAnchor point: CGPoint)
// 设置物理元素的中心偏移,可以理解为将绳子连接在物理元素的哪个位置
public init(item: any UIDynamicItem, offsetFromCenter offset: UIOffset, attachedToAnchor point: CGPoint)

// 以另一个物理元素作为锚点元素
public convenience init(item item1: any UIDynamicItem, attachedTo item2: any UIDynamicItem)
// 设置两个物理元素的中心偏移,即绳子的两端位置
public init(item item1: any UIDynamicItem, offsetFromCenter offset1: UIOffset, attachedTo item2: any UIDynamicItem, offsetFromCenter offset2: UIOffset)

UIAttachmentBehavior还有很多可配置的属性,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@available(iOS 7.0, *)
@MainActor open class UIAttachmentBehavior : UIDynamicBehavior {
// 物理元素
open var items: [any UIDynamicItem] { get }
// 依附行为的类型
open var attachedBehaviorType: UIAttachmentBehavior.AttachmentType { get }
// 锚点
open var anchorPoint: CGPoint
// 依附距离(绳子的长度)
open var length: CGFloat
// 依附行为的阻尼量
open var damping: CGFloat // 1: critical damping
// 依附行为的震荡频率(绳子的弹性)
open var frequency: CGFloat // in Hertz
// 旋转阻力
@available(iOS 9.0, *)
open var frictionTorque: CGFloat // default is 0.0
// 运动范围
@available(iOS 9.0, *)
open var attachmentRange: UIFloatRange // default is UIFloatRangeInfinite
}

AttachmentType枚举定义了是以点为锚点还是元素为锚点进行依附:

1
2
3
4
5
@available(iOS 7.0, *)
public enum AttachmentType : Int, @unchecked Sendable {
case items = 0
case anchor = 1
}

碰撞行为:UICollisionBehavior

碰撞行为比较好理解,在本文也是以一个台球碰撞示例开始。UICollisionBehavior解析如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@available(iOS 7.0, *)
@MainActor open class UICollisionBehavior : UIDynamicBehavior {
// 初始化
public init(items: [any UIDynamicItem])
// 元素添加与移除
open func addItem(_ item: any UIDynamicItem)
open func removeItem(_ item: any UIDynamicItem)
open var items: [any UIDynamicItem] { get }
// 碰撞模式
open var collisionMode: UICollisionBehavior.Mode

// 设置是否激活参照物的边界(创建仿真器时会指定一个参照元素,此属性控制是否将参照元素的边界作为碰撞边界进行激活)
open var translatesReferenceBoundsIntoBoundary: Bool
// 设置参照元素边界的偏移
open func setTranslatesReferenceBoundsIntoBoundary(with insets: UIEdgeInsets)

// 将指令的边界添加到碰撞行为中
open func addBoundary(withIdentifier identifier: any NSCopying, for bezierPath: UIBezierPath)
open func addBoundary(withIdentifier identifier: any NSCopying, from p1: CGPoint, to p2: CGPoint)
open func boundary(withIdentifier identifier: any NSCopying) -> UIBezierPath?
// 移除指定的碰撞边界
open func removeBoundary(withIdentifier identifier: any NSCopying)
open var boundaryIdentifiers: [any NSCopying]? { get }
open func removeAllBoundaries()
// 代理
weak open var collisionDelegate: (any UICollisionBehaviorDelegate)?
}

collisionMode可以指定碰撞的模式:

1
2
3
4
5
6
7
8
9
10
11
12
@available(iOS 7.0, *)
public struct Mode : OptionSet, @unchecked Sendable {

public init(rawValue: UInt)

// 仅仅物理元素间进行碰撞
public static var items: UICollisionBehavior.Mode { get }
// 仅仅和边界进行碰撞
public static var boundaries: UICollisionBehavior.Mode { get }
// 物理元素间和边界都进行碰撞
public static var everything: UICollisionBehavior.Mode { get }
}

UICollisionBehaviorDelegate代理中定义了碰撞过程的回调:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@MainActor public protocol UICollisionBehaviorDelegate : NSObjectProtocol {
// 两个物理元素间开始产生碰撞时调用
@available(iOS 7.0, *)
optional func collisionBehavior(_ behavior: UICollisionBehavior, beganContactFor item1: any UIDynamicItem, with item2: any UIDynamicItem, at p: CGPoint)
// 两个物理元素间碰撞结束时调用
@available(iOS 7.0, *)
optional func collisionBehavior(_ behavior: UICollisionBehavior, endedContactFor item1: any UIDynamicItem, with item2: any UIDynamicItem)
// 物理元素与边界开始产生碰撞时调用
@available(iOS 7.0, *)
optional func collisionBehavior(_ behavior: UICollisionBehavior, beganContactFor item: any UIDynamicItem, withBoundaryIdentifier identifier: (any NSCopying)?, at p: CGPoint)
// 物理元素与边界碰撞结束时调用
@available(iOS 7.0, *)
optional func collisionBehavior(_ behavior: UICollisionBehavior, endedContactFor item: any UIDynamicItem, withBoundaryIdentifier identifier: (any NSCopying)?)
}

场行为:UIFieldBehavior

场也是物理学中物理运动重要模型,生活中电场、磁场、重力场等场无处不在,iOS 9之后引入了UIFieldBehavior来仿真场行为。场行为本身运动规律复杂,UIFieldBehavior中提供了一些静态方法能方便的创建不同的场模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 创建一个拉力场行为(进入场后减速物理的运动)
open class func dragField() -> Self
// 创建一个弹力场行为(弹簧震荡的效果)
open class func springField() -> Self
// 加速度场 (场中的物理元素会被叠加上指定方向的加速度)
open class func velocityField(direction: CGVector) -> Self
// 创建电场行为 (与物体所携带的电荷量有关)
open class func electricField() -> Self
// 创建磁场行为 (与物体所携带的电荷量有关)
open class func magneticField() -> Self
// 在指定的点创建重力场行为 (有质量的物体会被吸引,设置负值则排斥)
open class func radialGravityField(position: CGPoint) -> Self
// 创建一个方向上的引力场行为 (与radialGravityField不同的是此场的力会均匀作用在指定方向)
open class func linearGravityField(direction: CGVector) -> Self
// 创建涡流场行为(场中附加的力是沿速度方向的切线)
open class func vortexField() -> Self
// 噪声场,此场通常与其他场结合使用,用来在纯粹的物理场中增加一些噪声,更好的模拟现实
open class func noiseField(smoothness: CGFloat, animationSpeed speed: CGFloat) -> Self
// 湍流场,也用于增加噪声,此场中的噪声与速度成正比
open class func turbulenceField(smoothness: CGFloat, animationSpeed speed: CGFloat) -> Self
// 完全自定义场行为
open class func field(evaluationBlock block: @escaping (UIFieldBehavior, CGPoint, CGVector, CGFloat, CGFloat, TimeInterval) -> CGVector) -> Self

通过上面的静态方法可以直接创建出复杂的场效果,并且场可以叠加进行使用。下面分别演示了拉力场,弹力场的行为运动效果,这些行为本身都是符合具体的物理公式,可以通过参数调整来仿真所需要的场景。

拉力场示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 拉力场
func addDragField() {
animator = UIDynamicAnimator(referenceView: box)

// 添加重力行为
let gravity = UIGravityBehavior(items: [ball])
gravity.gravityDirection = .init(dx: 0, dy: 1)

// 添加拉力场行为
let field = UIFieldBehavior.dragField()
field.addItem(ball)
field.position = CGPoint(x: ball.frame.origin.x, y: ball.frame.origin.y + 100)
// 力度
field.strength = 3
// 场影响的范围
field.region = .init(size: .init(width: 100, height: 100))
animator.addBehavior(field)
animator.addBehavior(gravity)
}

可以看到,当物理元素位于拉力场范围内时,物体下落速度非常慢,脱离场影响范围后,在重力作用下,快速下落。

弹力场示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 弹力场
func addSpringField() {
animator = UIDynamicAnimator(referenceView: box)

// 添加重力行为
let gravity = UIGravityBehavior(items: [ball])
gravity.gravityDirection = .init(dx: 0, dy: 1)

// 添加弹力场行为
let field = UIFieldBehavior.springField()
field.addItem(ball)
field.position = CGPoint(x: ball.frame.origin.x, y: ball.frame.origin.y - 100)

animator.addBehavior(field)
animator.addBehavior(gravity)
}

UIFieldBehavior创建的场可以通过设置参数来控制场中的力、方向等属性,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@available(iOS 9.0, *)
@MainActor open class UIFieldBehavior : UIDynamicBehavior {
// 场的位置
open var position: CGPoint
// 场的作用范围
open var region: UIRegion
// 场的力大小,默认为1
open var strength: CGFloat
// 定义随着离场中心距离的增加,场强度减弱的速度,默认0 表示场是均匀的,不会减弱
open var falloff: CGFloat
// 设置场中心的半径最小值,小于此值时,场作用不会进行衰减
open var minimumRadius: CGFloat
// 指定速度场和线性方向的重力场的速度方向
open var direction: CGVector
// 设置噪声场合湍流场的噪声强度 0为最强 1为最弱
open var smoothness: CGFloat
// 噪声场合湍流场的动画速度
open var animationSpeed: CGFloat
}

重力行为:UIGravityBehavior

UIGravityBehavior与UIFieldBehavior中的重力场功能有重复,这是由于UIGravityBehavior是iOS7之后就已经存在的行为,UIFieldBehavior是iOS9后为了增强对物理场模型的支持新增的,对应也覆盖了重力场的场景。UIGravityBehavior比较简单,解析如下:

1
2
3
4
5
6
7
8
9
10
11
@available(iOS 7.0, *)
@MainActor open class UIGravityBehavior : UIDynamicBehavior {
// 重力方向
open var gravityDirection: CGVector
// 设置角度
open var angle: CGFloat
// 重力大小
open var magnitude: CGFloat
// 设置角度和重力大小
open func setAngle(_ angle: CGFloat, magnitude: CGFloat)
}

推动行为:UIPushBehavior

UIPushBehavior用来仿真推动行为,其可以为物理元素提供一个推力。UIPushBehavior的模式有两种,分别可以添加瞬时推力与持续推力。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
extension UIPushBehavior {
// 模式
@available(iOS 7.0, *)
public enum Mode : Int, @unchecked Sendable {
// 持续的力
case continuous = 0
// 瞬时力
case instantaneous = 1
}
}

@available(iOS 7.0, *)
@MainActor open class UIPushBehavior : UIDynamicBehavior {
// 初始化方法
public init(items: [any UIDynamicItem], mode: UIPushBehavior.Mode)
// 添加和移除物理元素
open func addItem(_ item: any UIDynamicItem)
open func removeItem(_ item: any UIDynamicItem)
open var items: [any UIDynamicItem] { get }

// 推力作用于的点的偏移
open func targetOffsetFromCenter(for item: any UIDynamicItem) -> UIOffset
open func setTargetOffsetFromCenter(_ o: UIOffset, for item: any UIDynamicItem)

// 模式
open var mode: UIPushBehavior.Mode { get }
// 推力是否激活中
open var active: Bool
// 角度
open var angle: CGFloat
open func setAngle(_ angle: CGFloat, magnitude: CGFloat)
// 设置推力大小
open var magnitude: CGFloat
// 设置推力的方向
open var pushDirection: CGVector
}

捕获行为:UISnapBehavior

捕获行为与弹簧行为类似,其描述运动随时间而衰减,逐渐将物理元素固定到某个点。

1
2
3
4
5
6
7
8
9
@available(iOS 7.0, *)
@MainActor open class UISnapBehavior : UIDynamicBehavior {
// 初始化方法 设置最终物理元素固定在的位置
public init(item: any UIDynamicItem, snapTo point: CGPoint)
@available(iOS 9.0, *)
open var snapPoint: CGPoint
// 设置震荡幅度 0-1之间
open var damping: CGFloat
}

写在最后

物理引擎是许多游戏开发中的必备,使用物理引擎也可以为应用增加许多有趣的交互。另外,UIKit提供的物理引擎有着很好的性能,且可以和UIView无缝使用。最后,对本篇文章的任何讨论,都欢迎留言交流。