使用 UIKit Dynamics 来模拟物理效果

UIKit Dynamics 是 iOS7 中新增加的 lib,可能对于没听过没见过的人来说,它有点难用。不过事实上,它并不难。通过这篇教程,你可以知道使用它来为你的 app 增加物理效果是多么的简单,并且你不需要直到深层的物理跟数学知识,也不需要深入了解像 Core Animation 这些框架。用过的人都说好,谁用谁知道。

概述


在开始我们的教程之前,先来了解一些 UIKit Dynamics 相关的必要知识,还有一些经常使用的比较重要的类。要使用 UIKit Dynamics,我们没有必要引入其他的 framework,因为它是 UIKit 的一部分。只要你引入了 UIKit,Dynamics 也就可被使用了。它可以被用在任何 UIView 或者其子类的对象中。UIKit Dynamics 的核心是类 UIDynamicAnimator,它在 UIKit 中被实现,支撑这个类的是一个隐藏于世人前的物理引擎。它负责提供需要使用的物理效果。不过,就算 UIDynamicAnimator 是 UIKit Dynamics 的核心,但它自己一个人干不成什么事。我们需要将一些对象添加给它,这些对象称为行为(behaviors),或者从代码层面说,称为 UIDynamicBehaviors。一个 UIKit Dynamics behavior 实际上在代码世界里扮演一个真实世界中的物理行为,同时它代表 UIKit Dynamics 提供给开发者的物理效果。跟 behavior 相关的类,概括如下:

  • UIGravityBehavior:它的名字已经道破了一切。它代表使用了重力属性的 UIView 对象。
  • UICollisionBehavior:它的存在,让两个或者多个 View 能够产生碰撞的效果,或者是在一个 View 的边界之中产生碰撞。
  • UIPushBehavior:它可以将一个任意方向的力(或者说是加速度)施加在一个或者多个对象上,同时它支持两种不同的模式:一种是持续的作用力,让对象能够持续地增加速度;另一种是瞬间的作用力,让对象瞬时拥有一个最大速度。
  • UIAttachmentBehavior:它被用于将两个对象连接在一起,或者将一个对象跟某个点连接在一起。
  • UISnapBehavior:这个就牛逼了。插汉纸知道吗?X-man知道吗?万磁王知道吗?这个类就是万磁王。有了它,你可以在某个点吸引某个 View 对象。更屌的是,它包含一个 damping 属性,让该对象在最后被吸引到那个点时,在该点周围做弹性运动。


除了上面提到的这些之外,还有一个更加重要的类 —— UIDynamicItemBehavior。这个类并不会将特定的行为添加到某个对象中,但是它可以用来根据其他 behavior 来调整一些特定的属性。这些属性是:

  • elasticity-弹性:它的值范围是从0.0到1.0,它用来指定两个对象碰撞的时候的弹性程度。
  • density-密度:这个属性代表着对象的质量。这个值越大,对象的质量就越大。
  • resistance-阻力:用于修改对象的速度的衰减(damping)。
  • friction-摩擦力:可以设置两个对象之间的摩擦力。
  • angularResistance-角速度阻力:用于修改对象的角速度的衰减(damping)。
  • allowsRotation-允许旋转:这是一个布尔型属性,用来指定某个对象是否可以在物理运动中旋转。


UIkit Dynamic 真心容易使用,它简单到只需要寥寥几行代码。不管怎么说,我们对于 UIKit Dynamics 的简介到此结束。我们下一步要深入到我们真正关心的部分了。


现在,擦亮你的双眼,我们要进入一个崭新的世界了。

构建一个Demo

重力效果

这里我用一个圆滚滚的 view 来模拟一个具有重力的球:


image


我们需要一个 Animator 以及一个球:

1
2
@property (strong, nonatomic) UIDynamicAnimator *animator;
@property (strong, nonatomic) UIView *buleBall;

将小球初始化之后添加到 self.view 上,然后调整它的位置,之后便是我们的核心代码了:

1
2
3
self.animator = [[UIDynamicAnimator alloc] initWithReferenceView:self.view];
UIGravityBehavior *gravityBehavior = [[UIGravityBehavior alloc] initWithItems:@[self.buleBall]];
[self.animator addBehavior:gravityBehavior];

初始化 animator 时,我们将 self.view 传入,它作为 animator 起作用的地方。UIGravityBehavior 接受一个数组,来表明所有需要使用重力的 view 对象。


你可以看到,小球一直在坠落,就算我们已经看不到它,就算它已经跌落到了屏幕千里之外。想验证这点很容易:

1
2
3
gravityBehavior.action = ^{
NSLog(@"%f", self.buleBall.center.y);
};

增加碰撞

好了,虽然我们已经成功让小球下落,但总不能让它漫无目的的运动下去吧。上帝说要有地板,于是我们需要给它造一块地板。我们需要使用 UICollisionBehavior 类来搞块地板。我们可以自行设置地板的边界,然后将它添加到 animator 上:

1
2
3
4
5
6
7
8
9
10
- (void)showGravity
{
UIGravityBehavior *gravityBehavior = [[UIGravityBehavior alloc] initWithItems:@[self.buleBall]];
[self.animator addBehavior:gravityBehavior];
UICollisionBehavior *collisionBehavior = [[UICollisionBehavior alloc] initWithItems:@[self.buleBall]];
[collisionBehavior addBoundaryWithIdentifier:@"floor"
fromPoint:CGPointMake(0, SCREEN_SIZE.height) toPoint:CGPointMake(SCREEN_SIZE.width, SCREEN_SIZE.height)];
[self.animator addBehavior:collisionBehavior];
}

在 collisionBehavior 初始化时,我们将 buleBall 作为需要应用碰撞的对象传入。addBoundaryWithIdentifier:fromPoint:toPoint: 方法则让我们声明一条看不见的边界。运行之后:


image


好了,现在我们能够将我们的 ball 挽留住了。但是 ball 的触底反弹好像不是太Q弹。我们可以通过修改 collisionBehavior 中的 elasticity 属性来达到我们的目的。


如果你还记得的话,我们在前面介绍过一个类,叫做 UIDynamicItemBehavior。我们可以通过它来替换所有 behavior 的属性。下面我们利用它来修改碰撞的反弹程度:

1
2
3
UIDynamicItemBehavior *ballBehavior = [[UIDynamicItemBehavior alloc] initWithItems:@[self.buleBall]];
ballBehavior.elasticity = 0.75;
[self.animator addBehavior:ballBehavior];

elasticity 属性取值范围为0.0到1.0,0.0代表完全没有弹性,1.0表示完全弹(反弹速度大小前后一样)。下面看看我们Q弹的 ball:


image


也可以尝试修改其他的属性,比如 resistance 或者 friction 来看看有什么影响。他们取值同样为0.0到1.0。


有时候我们可能需要在碰撞发生的时候执行一些其他操作,因此,UIKit 给我们提供了一套协议 UICollisionBehaviourDelegate。我们可以监听到两个对象,或者对象与边界之间的碰撞发生或者结束:

1
@interface ViewController () <UICollisionBehaviorDelegate>

1
collisionBehavior.collisionDelegate = self;

下面我们实现这样的功能:在 ball 发生碰撞的时候改变它的颜色:

1
2
3
4
- (void)collisionBehavior:(UICollisionBehavior *)behavior beganContactForItem:(id<UIDynamicItem>)item withBoundaryIdentifier:(id<NSCopying>)identifier atPoint:(CGPoint)p
{
self.buleBall.backgroundColor = color(arc4random() % 255, arc4random() % 255, arc4random() % 255, 1);
}

image

更复杂的情况

到目前为止还好。我们了解了重力跟碰撞,还有碰撞的回调。但是仅仅看着一个 ball 做自由落体运动然后反弹最后静止,还是很 boring 的。下面我们添加一些更 nb 的东西。


我们先来添加三个 view,作为 ball 的障碍。其中两个是固定的,一左一右,一上一下。第三个在最下面,并且当 ball 碰到它之后就开始旋转:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
- (void)playWithBall
{
UIView *obstacle1 = [[UIView alloc] initWithFrame:CGRectMake(50, 80, 150, 20)];
obstacle1.layer.cornerRadius = 10.0f;
obstacle1.clipsToBounds = YES;
obstacle1.backgroundColor = color(46, 204, 113, 1);
UIView *obstacle2 = [[UIView alloc] initWithFrame:CGRectMake(SCREEN_SIZE.width - 50 - 150, 250, 150, 20)];
obstacle2.layer.cornerRadius = 10.0f;
obstacle2.clipsToBounds = YES;
obstacle2.backgroundColor = color(46, 204, 113, 1);
UIView *obstacle3 = [[UIView alloc] initWithFrame:CGRectMake((SCREEN_SIZE.width - 150) / 2.0f, 450, 150, 20)];
obstacle3.layer.cornerRadius = 10.0f;
obstacle3.clipsToBounds = YES;
obstacle3.backgroundColor = color(52, 73, 94, 1);
[self.view addSubview:obstacle1];
[self.view addSubview:obstacle2];
[self.view addSubview:obstacle3];
}

image


接着我们需要重新设置 ball 的初始位置,让它可以落在最上方障碍物上面:

1
self.buleBall.center = CGPointMake(75, 25);

在碰撞行为初始化的地方,不要忘了将新增加的障碍物一并添加碰撞:

1
2
3
4
5
6
7
8
UICollisionBehavior *collisionBehavior = [[UICollisionBehavior alloc] initWithItems:@[self.buleBall, obstacle1, obstacle2, obstacle3]];
collisionBehavior.translatesReferenceBoundsIntoBoundary = YES;
[collisionBehavior addBoundaryWithIdentifier:@"floor"
fromPoint:CGPointMake(0, SCREEN_SIZE.height)
toPoint:CGPointMake(SCREEN_SIZE.width, SCREEN_SIZE.height)];
collisionBehavior.collisionMode = UICollisionBehaviorModeEverything;
collisionBehavior.collisionDelegate = self;
[self.animator addBehavior:collisionBehavior];

大概你会发现我们增加了两行新的代码,第一行是:

1
collisionBehavior.translatesReferenceBoundsIntoBoundary = YES;

表示是否允许 self.view(animator 起作用的 view)的边界作为可被碰撞的边界。这按很重要,如果你不想你的 ball 或者你的其他 view 消失在视野之外的话。第二行新代码:

1
collisionBehavior.collisionMode = UICollisionBehaviorModeEverything;

表示所有被添加到 collisionBehavior 中的对象都能发生碰撞。


下面设置 ball 的其他属性:

1
2
3
4
5
6
UIDynamicItemBehavior *ballBehavior = [[UIDynamicItemBehavior alloc] initWithItems:@[self.buleBall]];
ballBehavior.elasticity = 0.75;
ballBehavior.resistance = 0;
ballBehavior.friction = 0;
ballBehavior.allowsRotation = NO;
[self.animator addBehavior:ballBehavior];

下面我们来修改一下前两个障碍物的 property。我们前面提到过,我们需要它们维持在原地不动,所以,我们得将它们俩的 desity 设置得大一些,让它们“重到不行”,好让 ball 的碰撞难以对他们造成影响:

1
2
3
4
UIDynamicItemBehavior *obstacles1AndObstacles2 = [[UIDynamicItemBehavior alloc] initWithItems:@[obstacle1, obstacle2]];
obstacles1AndObstacles2.allowsRotation = NO;
obstacles1AndObstacles2.density = 1000000;
[self.animator addBehavior:obstacles1AndObstacles2];

对于第三个障碍物,我们让它转起来,所以,我们不必增大他的 desity:

1
2
3
UIDynamicItemBehavior *obstacle3Behavior = [[UIDynamicItemBehavior alloc] initWithItems:@[obstacle3]];
obstacle3Behavior.allowsRotation = YES;
[self.animator addBehavior:obstacle3Behavior];

最后一步,我们需要在点击屏幕的时候,给 ball 一个推力。我们声明一个私有的属性来表示 ball 目前是否正在滚动:

1
2
3
4
5
@interface ViewController () <UICollisionBehaviorDelegate>
@property (nonatomic) BOOL isBallRolling;
@end

现在我们来实现方法 touchesBegan:withEvent: method:

1
2
3
4
5
6
7
8
9
10
11
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
if (!self.isBallRolling) {
UIPushBehavior *pushBehavior = [[UIPushBehavior alloc] initWithItems:@[self.buleBall] mode:UIPushBehaviorModeInstantaneous];
pushBehavior.magnitude = 1.5;
[self.animator addBehavior:pushBehavior];
self.isBallRolling = YES;
}
}

如果 ball 没有在滚,我们就让它滚!mode 参数我们传入 UIPushBehaviorModeInstantaneous,表明我们会让 ball 在最开始得到它的最大速度,而不是持续性增加;magnitude 表示力的大小,但实际上定义了 ball 的速度。同时还有一个 angle 属性,用于表示力的方向。


image

想玩个球?

我们前面所做的,可以进化成一种碰撞游戏。我们可以在屏幕底部添加一个球拍,让它可以跟随我们的手指移动。我们需要添加的代码不多,let‘s go。


首先,创建一个新的 View 对象,代表我们的球拍。同时声明一个CGPoint 属性来保存球拍的初始中点位置。你会知道这有多么方便的:

1
2
3
4
5
6
@interface ViewController () <UICollisionBehaviorDelegate>
@property (strong, nonatomic) UIView *paddle;
@property (nonatomic) CGPoint paddleCenterPoint;
@end

现在,回到 playWithBall 方法去设置我们的球拍,新代码应该添加在下面代码的后面:

1
2
3
[self.view addSubview:obstacle1];
[self.view addSubview:obstacle2];
[self.view addSubview:obstacle3];

设置球拍:

1
2
3
4
5
6
self.paddle = [[UIView alloc] initWithFrame:CGRectMake((SCREEN_SIZE.width - 10) / 2.0f, SCREEN_SIZE.height - 20.0f, 100, 30)];
self.paddle.backgroundColor = color(231, 76, 60, 1);
self.paddle.layer.cornerRadius = 15.0f;
self.paddle.clipsToBounds = YES;
self.paddleCenterPoint = self.paddle.center;
[self.view addSubview:self.paddle];

这里需要注意的是,我们将球拍的初始中心点保存了起来。剩下的代码将变得简单:


下面,在 collision behavior 的初始化方法中做一些小修改:

1
UICollisionBehavior *collisionBehavior = [[UICollisionBehavior alloc] initWithItems:@[self.buleBall, self.paddle, obstacle1, obstacle2, obstacle3]];

最后,我们设置一些球拍的属性。跟前两个障碍物一样,我们不希望我们的球拍旋转或者被碰离原位。在 playWithBall 方法的最后,增加下面代码:

1
2
3
4
UIDynamicItemBehavior *paddleBehavior = [[UIDynamicItemBehavior alloc] initWithItems:@[self.paddle]];
paddleBehavior.allowsRotation = NO;
paddleBehavior.density = 1000000;
[self.animator addBehavior:paddleBehavior];

球拍有了,但是它现在还不能移动。我们之前说过,我们需要球拍随着手指而移动,所以,我们接下来实现 touchesMoved:withEvent: 方法。另外,我们只允许球拍水平移动:

1
2
3
4
5
6
7
8
9
10
11
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
UITouch *touch = [touches anyObject];
CGPoint touchLocation = [touch locationInView:self.view];
CGFloat yPoint = self.paddleCenterPoint.y;
CGPoint paddleCenter = CGPointMake(touchLocation.x, yPoint);
self.paddle.center = paddleCenter;
[self.animator updateItemUsingCurrentState:self.paddle];
}

这里你应该可以发觉前面使用 paddleCenterPoint 属性的意图了。如我所说,我们希望在 y 轴方向上的坐标保持不变。最后,重中之重,如果我们不调用 updateItemUsingCurrentState 的话,我们将看不到任何改变发生。


好了,目前我们有了一个能随我们手指运动的球拍。但是如果你现在马上运行 Demo,返现球拍不能让球运动起来。当球拍碰撞到球的时候给球添加一个额外的作用力会怎么样呢?前面我们实现了两个碰撞的 delegate,现在我们实现多一个 delegate。下面的这个 delegate 会在两个 item 碰撞的时候才会被调用(ball 跟球拍之间的碰撞)。每当新的碰撞在两个 View 之间产生的时候,一个新的推力将会添加到 ball 上。

1
2
3
4
5
6
7
8
9
- (void)collisionBehavior:(UICollisionBehavior *)behavior beganContactForItem:(id<UIDynamicItem>)item1 withItem:(id<UIDynamicItem>)item2 atPoint:(CGPoint)p
{
if (item1 == self.buleBall && item2 == self.paddle) {
UIPushBehavior *pushBehavior = [[UIPushBehavior alloc] initWithItems:@[self.buleBall] mode:UIPushBehaviorModeInstantaneous];
pushBehavior.angle = 0;
pushBehavior.magnitude = 0.75;
[self.animator addBehavior:pushBehavior];
}
}

现在运行 Demo,就可以看到我们的球拍是多么的溜到不行:


image

总结

有了这个用于 UIView 的“物理引擎”,我们可以搞出很多很屌的效果。需要记住,我们可以将很多效果组合起来。但是可能难免会有冲突。还有一些其他的效果没有在这片文章里描述,但是其实也大同小异,试试便知。



以上


参考链接:Adding Animated Effects to iOS App Using UIKit Dynamics