什么是 UIKit Dynamics

iOS 7 中推出的UIKit Dynamics,主要带来了模拟现实的二维动画效果,Apple 的高度封装让开发者不用知道太多物理知识也可以开发出逼真的物理动画。

  • Real word inspired interactions
  • Combining predefined and interactive animations
  • Designed for UI

Why

苹果鼓励模拟真实世界的交互而不只是简单的像素堆砌的拟物风格,所以苹果这些模拟现实的交互动画封装进了 UIKit,希望开发者能开发出更多模拟现实的交互。

关键类

  • UIDynamicAnimator,封装了底层 iOS 物理引擎,为动力项(UIDynamicItem)提供物理相关的功能和动画。
  • UIDynamicBehavior,动力行为,为动力项提供不同的物理行为
  • UIDynamicItem,动力项,相当于现实世界中的一个基本物体

image

这三个类的结构是:UIDynamicAnimator 需要一个 refrence view 作为物理引擎的坐标系统,再根据不同需求添加各种动力行为(UIDynamicBehavior),而每个动力行为都可以指定一个或多个动力项(UIDynamicItem),常用的动力项就是一个普通的 View。

UIDynamicAnimator

UIDynamicAnimator 封装了底层 iOS 物理引擎,为动力项(UIDynamicItem)提供物理相关的功能和动画,并为这些动画提供上下文。Animator 作为底层 iOS 物理引擎和动力项(UIDynamicItem)之间的中介,通过 - (void)addBehavior:(UIDynamicBehavior *)behavior; 方法添加不同的动力行为,让动力项拥有物理功能和动画。

现在来看看 UIDynamicAnimator 都有哪些方法:

  • 初始化和管理一个 Dynamic Animator
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 传入一个 Reference view 创建一个 Dynamic Animator
- (instancetype)initWithReferenceView:(UIView*)view;

// 获取在 CGRect 内所有的动力项,这个 CGRect 是基于 Reference view 的二维坐标系统的
- (NSArray*)itemsInRect:(CGRect)rect;

// 添加动力行为
- (void)addBehavior:(UIDynamicBehavior *)behavior;

// 删除指定的动力行为
- (void)removeBehavior:(UIDynamicBehavior *)behavior;

// 删除所有的动力行为
- (void)removeAllBehaviors;
  • 获取 Dynamic Animator’s 的状态
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 是否正在运行
@property (nonatomic, readonly, getter = isRunning) BOOL running;

// 获取所有的 Behaviors
@property (nonatomic, readonly, copy) NSArray* behaviors;

@property (nonatomic, readonly) UIView* referenceView;

// 这个 delegate 中有两个回调方法,一个是在 animator 暂停的时候调用,一个是在将要恢复的时候调用
@property (nonatomic, assign) id <UIDynamicAnimatorDelegate> delegate;

// 已经运行了多久的时间,是一个 NSTimeInterval
- (NSTimeInterval)elapsedTime;

// 如果动力项不是通过 animator 自动计算改变状态,比如,通过代码强制改变一个 item 的 transfrom 时,可以用这个方法通知 animator 这个 item 的改变。如果不用这个方法,animator 之后的动画会覆盖代码中对 item 做的改变,相当于代码改变 transform 变得没有意义。
- (void)updateItemUsingCurrentState:(id <UIDynamicItem>)item;
  • Collection View Additions
1
2
3
4
- (instancetype)initWithCollectionViewLayout:(UICollectionViewLayout*)layout // 传入一个 CollectionViewLayout 创建一个 Dynamic Animator
 layoutAttributesForCellAtIndexPath:
 layoutAttributesForDecorationViewOfKind:atIndexPath:
 layoutAttributesForSupplementaryViewOfKind:atIndexPath:

从这里开始,让我们先创建一个项目,取名 DynamicDemo,选择 single view project。

在 ViewController.m 文件修改成如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@interface ViewController ()

@property (strong, nonatomic) UIView *squareView;
@property (strong, nonatomic) UIDynamicAnimator *animator;

@end

@implementation ViewController

- (void)viewDidLoad
{
    [super viewDidLoad];
    self.squareView = [[UIView alloc] initWithFrame:CGRectMake(100, 200, 200, 200)];
    self.squareView.backgroundColor = [UIColor orangeColor];
    [self.view addSubview:self.squareView];

    self.animator = [[UIDynamicAnimator alloc] initWithReferenceView:self.view];
}

@end

上面的代码创建了一个方形 View,橘黄色背景色。还创建了一个UIDynamicAnimator。

UIDynamicBehavior 是具体的物理行为。

UIDynamicBehavior 赋予动态行为给一个或多个动态项(Dynamic Item)。

  • UIDynamicBehavior 的主要方法和属性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 在将要进行动画时的 block 回调
@property(nonatomic, copy) void (^action)(void)

// 添加到该动态行为中的子动态行为
@property(nonatomic, readonly, copy) NSArray *childBehaviors

//  该动态行为相关联的dynamicAnimator
@property(nonatomic, readonly) UIDynamicAnimator *dynamicAnimator

//添加一个子动态行为
- (void)addChildBehavior:(UIDynamicBehavior *)behavior

// 移除一个子动态行为
- (void)removeChildBehavior:(UIDynamicBehavior *)behavior

// 当该动态行为将要被添加到一个UIDynamicAnimator中时,这个方法会被调用。
- (void)willMoveToAnimator:(UIDynamicAnimator *)dynamicAnimator

在开发中,大部分情况下使用 UIDynamicBehavior 的子类就足够了,因为UIKit 中已经有几个现成的模拟现实的 UIDynamicBehavior 类。

UIDynamicBehavior的子类有:

UIGravityBehavior

重力行为,可以指定重力的方向和大小。用gravityDirection指定一个向量,或者设置 angle 和 magnitude。

打开刚才的项目,DynamicDemo,在 ViewController.m 中添加如下代码:

1
2
3
4
5
6
7
- (void)viewDidAppear:(BOOL)animated {
    [super viewDidAppear:animated];

    UIGravityBehavior *gravity = [[UIGravityBehavior alloc] initWithItems:@[self.squareView]]; // 创建一个重力行为
    gravity.gravityDirection = CGVectorMake(0, 1); // 在垂直向下方向 1000 点/平方秒 的速度
    [self.animator addBehavior:gravity];
}

运行项目可以看到效果:

image

UICollisionBehavior

碰撞行为,指定一个边界,物体在到达这个边界的时候会发生碰撞行为。通过实现 UICollisionBehaviorDelegate 可以跟踪物体什么时候开始碰撞和结束碰撞。

现在将下面代码添加到[self.animator addBehavior:gravity];之后

1
2
3
4
5
6
7
8
9
10
// 创建碰撞行为
UICollisionBehavior *collision = [[UICollisionBehavior alloc] initWithItems:balls];

// 指定 Reference view 的边界为可碰撞边界
collision.translatesReferenceBoundsIntoBoundary = YES;

// UICollisionBehaviorModeItems:item 只会和别的 item 发生碰撞;UICollisionBehaviorModeBoundaries:item 只和碰撞边界进行碰撞;UICollisionBehaviorModeEverything:item 和 item 之间会发生碰撞,也会和指定的边界发生碰撞。
collision.collisionMode = UICollisionBehaviorModeEverything;

[self.animator addBehavior:collision];

现在运行项目:

image

UICollisionBehavior通过下面两个方法来添加碰撞边界,可以根据贝塞尔曲线或者一条直线生成碰撞边界。

1
2
- (void)addBoundaryWithIdentifier:(id <NSCopying>)identifier forPath:(UIBezierPath*)bezierPath;
- (void)addBoundaryWithIdentifier:(id <NSCopying>)identifier fromPoint:(CGPoint)p1 toPoint:(CGPoint)p2;

UICollisionBehavior 里的 item 每次发生碰撞都可以通过 delegate 来监听事件。

1
2
3
4
5
6
7
8
9
10
11
12
// item 与 item 之间开始碰撞。
- (void)collisionBehavior:(UICollisionBehavior*)behavior beganContactForItem:(id <UIDynamicItem>)item1 withItem:(id <UIDynamicItem>)item2 atPoint:(CGPoint)p;

// item 与 item 之间结束碰撞。
- (void)collisionBehavior:(UICollisionBehavior*)behavior endedContactForItem:(id <UIDynamicItem>)item1 withItem:(id <UIDynamicItem>)item2;


// item 和边界开始碰撞
- (void)collisionBehavior:(UICollisionBehavior*)behavior beganContactForItem:(id <UIDynamicItem>)item withBoundaryIdentifier:(id <NSCopying>)identifier atPoint:(CGPoint)p;

// item 和边界结束碰撞
- (void)collisionBehavior:(UICollisionBehavior*)behavior endedContactForItem:(id <UIDynamicItem>)item withBoundaryIdentifier:(id <NSCopying>)identifier;

让我们为项目添加碰撞行为的 delegate ,修改 ViewController.m 为下面样子:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@interface ViewController () <UICollisionBehaviorDelegate>

@property (strong, nonatomic) UIView *squareView;
@property (strong, nonatomic) UIDynamicAnimator *animator;

@end

@implementation BeginnerViewController

- (void)viewDidLoad
{
    [super viewDidLoad];
    self.squareView = [[UIView alloc] initWithFrame:CGRectMake(100, 100, 100, 100)];
    self.squareView.backgroundColor = [UIColor orangeColor];
    [self.view addSubview:self.squareView];

    self.animator = [[UIDynamicAnimator alloc] initWithReferenceView:self.view];
}

- (void)viewDidAppear:(BOOL)animated {
    [super viewDidAppear:animated];
    UIGravityBehavior *gravity = [[UIGravityBehavior alloc] initWithItems:@[self.squareView]];
    [self.animator addBehavior:gravity];

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

#pragma mark - UICollisionBehaviorDelegate

- (void)collisionBehavior:(UICollisionBehavior*)behavior beganContactForItem:(id <UIDynamicItem>)item withBoundaryIdentifier:(id <NSCopying>)identifier atPoint:(CGPoint)p
{
    // 结束碰撞为 squareView 设置一个随机背景
    self.squareView.backgroundColor = [UIColor colorWithRed:(float)rand() / RAND_MAX
                                                      green:(float)rand() / RAND_MAX
                                                       blue:(float)rand() / RAND_MAX
                                                      alpha:1];
}

- (void)collisionBehavior:(UICollisionBehavior*)behavior endedContactForItem:(id <UIDynamicItem>)item withBoundaryIdentifier:(id <NSCopying>)identifier
{
    // 结束碰撞为 squareView 设置一个随机背景
    self.squareView.backgroundColor = [UIColor colorWithRed:(float)rand() / RAND_MAX
                                                      green:(float)rand() / RAND_MAX
                                                       blue:(float)rand() / RAND_MAX
                                                      alpha:1];
}

@end

现在运行项目将会看到如下效果:

collision delegate

UIAttachmentBehavior

附着行为,让物体附着在某个点或另外一个物体上。可以设置附着点的到物体的距离,阻尼系数和振动频率等。

在 ViewController.m 的 - (void)viewDidAppear:(BOOL)animated 末尾添加如下代码:

1
2
3
4
5
UIAttachmentBehavior *attachment = [[UIAttachmentBehavior alloc] initWithItem:self.squareView attachedToAnchor:self.squareView.center];
attachment.length = 50;
attachment.damping = 0.5;
attachment.frequency = 1;
[self.animator addBehavior:attachment];

运行项目看到效果:

attachment

属性详细说明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// UIAttachmentBehaviorTypeAnchor类型的依赖行为的锚点,锚点与行为相关的动力动画的坐标系统有关。
@property(readwrite, nonatomic) CGPoint anchorPoint

// 吸附行为的类型
@property(readonly, nonatomic) UIAttachmentBehaviorType attachedBehaviorType

// 描述吸附行为减弱的阻力大小
@property(readwrite, nonatomic) CGFloat damping

// 吸附行为震荡的频率
@property(readwrite, nonatomic) CGFloat frequency

// 与吸附行为相连的动态项目,当吸附行为类型是UIAttachmentBehaviorTypeItems时有2个元素,当吸附行为类型是UIAttachmentBehaviorTypeAnchor时只有一个元素。
@property(nonatomic, readonly, copy) NSArray *items

// 吸附行为中的两个吸附点之间的距离,通常用这个属性来调整吸附的长度,可以创建吸附行为之后调用。系统基于你创建吸附行为的方法来自动初始化这个长度
@property(readwrite, nonatomic) CGFloat length

UIDynamicItemBehavior

物体属性,如密度、弹性系数、摩擦系数、阻力、转动阻力等。

接下来我们修改物体的物理属性,为了能看到这个效果,我们先删除 UIAttachmentBehavior 相关的代码,并在- (void)viewDidAppear:(BOOL)animated 末尾添加如下代码:

1
2
3
4
5
6
UIDynamicItemBehavior *itemBehavior = [[UIDynamicItemBehavior alloc] initWithItems:@[self.squareView]];
itemBehavior.elasticity = 0.8; // 改变弹性
itemBehavior.allowsRotation = YES; // 允许旋转
[itemBehavior addAngularVelocity:1 forItem:self.squareView]; // 让物体旋转

[self.animator addBehavior:itemBehavior];

现在我们看到,方块的弹性变大了,并且伴随着旋转:

item behavior

属性详细说明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 弹力,通常设置 0~1 之间
@property (readwrite, nonatomic) CGFloat elasticity;

// 摩擦力,0表示完全光滑无摩擦
@property (readwrite, nonatomic) CGFloat friction;

// 密度,一个 100x100 points(1 point 在 retina 屏幕上等于2像素,在普通屏幕上为1像素。)大小的物体,密度1.0,在上面施加 1.0 的力,会产生 100 point/平方秒 的加速度。
@property (readwrite, nonatomic) CGFloat density;

// 线性阻力,物体在移动过程中受到的阻力大小
@property (readwrite, nonatomic) CGFloat resistance;

// 旋转阻力,物体旋转过程中的阻力大小
@property (readwrite, nonatomic) CGFloat angularResistance;

// 是否允许旋转
@property (readwrite, nonatomic) BOOL allowsRotation;

UIPushBehavior

对物体施加力,可以是持续性的力也可以是一次性的力。用一个向量(CGVector)来表示力的方向和大小。

这次我们通过手势来动态的为物体添加推力,首先注释重力行为的相关代码,然后在- (void)viewDidAppear:(BOOL)animated 末尾添加如下代码:

1
2
UITapGestureRecognizer *viewTapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tapViewHandler:)];
    [self.view addGestureRecognizer:viewTapGesture];

在ViewController.m中添加方法:

1
2
3
4
5
6
7
8
- (void)tapViewHandler:(UITapGestureRecognizer *)gestureRecognizer
{
    UIPushBehavior *push = [[UIPushBehavior alloc] initWithItems:@[self.squareView] mode:UIPushBehaviorModeInstantaneous];
    CGPoint location = [gestureRecognizer locationInView:self.view];
    CGPoint itemCenter = self.squareView.center;
    push.pushDirection = CGVectorMake((location.x - itemCenter.x) / 100, (location.y - itemCenter.y) / 100);
    [self.animator addBehavior:push];
}

上面代码会根据手指点击,生成一个由物体中心点指向点击位置的点的向量,通过设置UIPushBehavior的pushDirection让物体产生一个推向点击点的力。说得有点抽象,看看现实效果

push behavior

主要的属性和方法

1
2
3
4
5
6
7
8
// 推力模式,UIPushBehaviorModeContinuous:持续型。UIPushBehaviorModeInstantaneous:一次性推力。
@property (nonatomic, readonly) UIPushBehaviorMode mode;

// 推力是否被激活,在激活状态下,物体才会受到推力效果
@property(nonatomic, readwrite) BOOL active

// 推力的大小和方向
@property (readwrite, nonatomic) CGVector pushDirection;

UISnapBehavior

将一个物体钉在某一点。它只有一个初始化方法和一个属性。

1
2
3
4
5
// 根据 item 和 point 来确定一个 item 要被定到哪个点上。
- (instancetype)initWithItem:(id <UIDynamicItem>)item snapToPoint:(CGPoint)point;

// 减震系数,范围在0.0~1.0
@property (nonatomic, assign) CGFloat damping;

这个就留给大家自己实验了。XD

Demo

整个 Demo 的代码:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
@interface ViewController () <UICollisionBehaviorDelegate>

@property (strong, nonatomic) UIView *squareView;
@property (strong, nonatomic) UIDynamicAnimator *animator;

@end

@implementation ViewController

- (void)viewDidLoad
{
    [super viewDidLoad];
    self.squareView = [[UIView alloc] initWithFrame:CGRectMake(100, 100, 100, 100)];
    self.squareView.backgroundColor = [UIColor orangeColor];
    [self.view addSubview:self.squareView];

    self.animator = [[UIDynamicAnimator alloc] initWithReferenceView:self.view];
}

- (void)viewDidAppear:(BOOL)animated {
    [super viewDidAppear:animated];
//    UIGravityBehavior *gravity = [[UIGravityBehavior alloc] initWithItems:@[self.squareView]];
//    [self.animator addBehavior:gravity];

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

    // 吸附于某一点,也可以吸附与某个实现了 UIDynamicItem 的对象
//    UIAttachmentBehavior *attachment = [[UIAttachmentBehavior alloc] initWithItem:self.squareView attachedToAnchor:self.squareView.center];
//    attachment.length = 50;
//    attachment.damping = 0.5;
//    attachment.frequency = 1;
//    [self.animator addBehavior:attachment];
//    [self addViewAtPoint:self.squareView.center];

    // 物体属性:质量、摩擦、阻力等
    UIDynamicItemBehavior *itemBehavior = [[UIDynamicItemBehavior alloc] initWithItems:@[self.squareView]];
    itemBehavior.elasticity = 0.8;
    itemBehavior.allowsRotation = YES;
    itemBehavior.resistance = 0.5;
    [itemBehavior addAngularVelocity:1 forItem:self.squareView];
    [self.animator addBehavior:itemBehavior];

    UITapGestureRecognizer *viewTapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tapViewHandler:)];
    [self.view addGestureRecognizer:viewTapGesture];
}

- (void)tapViewHandler:(UITapGestureRecognizer *)gestureRecognizer
{
    UIPushBehavior *push = [[UIPushBehavior alloc] initWithItems:@[self.squareView] mode:UIPushBehaviorModeInstantaneous];
    CGPoint location = [gestureRecognizer locationInView:self.view];
    CGPoint itemCenter = self.squareView.center;
    push.pushDirection = CGVectorMake((location.x - itemCenter.x) / 100, (location.y - itemCenter.y) / 100);
    [self.animator addBehavior:push];
}

#pragma mark - Helper

- (void)addViewAtPoint:(CGPoint)center
{
    UIView *view = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 10, 10)];
    view.backgroundColor = [UIColor grayColor];
    view.center = center;
    [self.view addSubview:view];
}

#pragma mark - UICollisionBehaviorDelegate

- (void)collisionBehavior:(UICollisionBehavior*)behavior beganContactForItem:(id <UIDynamicItem>)item withBoundaryIdentifier:(id <NSCopying>)identifier atPoint:(CGPoint)p
{
    self.squareView.backgroundColor = [UIColor colorWithRed:(float)rand() / RAND_MAX
                                                      green:(float)rand() / RAND_MAX
                                                       blue:(float)rand() / RAND_MAX
                                                      alpha:1];
}

- (void)collisionBehavior:(UICollisionBehavior*)behavior endedContactForItem:(id <UIDynamicItem>)item withBoundaryIdentifier:(id <NSCopying>)identifier
{
    self.squareView.backgroundColor = [UIColor colorWithRed:(float)rand() / RAND_MAX
                                                      green:(float)rand() / RAND_MAX
                                                       blue:(float)rand() / RAND_MAX
                                                      alpha:1];
}

@end

现实中的使用场景

  • AlertView 弹出和隐藏

alertview

图片来自teehan+lax

  • 类似于系统通知的弹性效果,侧边栏菜单弹性效果

slide menu

图片来自teehan+lax

  • 类似于系统 Message 信息拉动时的弹簧效果

spring collection view

图片来自obj.io

  • 还有很多使用场景期待大家共同挖掘补充

总结

UIKit Dynamic 为开发者提供了模拟现实的交互动画。

从例子中来看,使用 UIKit Dynamic 实际上真的很简单,只需要几行或者十几行代码就能写出很棒的模拟真实世界的交互效果。

UIKit Dynamic 是 UIKit 的一部分,这意味着使用它不需要添加其它额外的framework,所以如果应用只支持 iOS 7 以上,可以在项目中多多使用,让应用中的动画效果瞬间提升好几个档次。

参考

2013 WWDC

Blog

iOS UITextView 输入内容实时更新 cell 的高度

这篇文章介绍了在一个动态数据的 table view 中,cell 根据 text view 内容的输入实时改变 cell 和 table view 的高度。自动计算 cell 高度的功能使用 iOS 8 才支持的自适应 cell,如果你还不知道 iOS 8 自适应 cell, …… Continue reading

iOS 8 自适应 Cell

Published on November 13, 2014