利用贝塞尔曲线实现Q弹的下拉刷新

再次受 Kitten 大神的一篇妙文所启发,想要自己来实现原文中 Kitten 已实现的动画效果。


最终效果如下:
image


下面我们来一步一步地实现它。有一些基础的知识需要先事先了解:

  • CADisplayLink,可理解为跟屏幕刷新频率同步的定时器。可见 Kitten 的博文
  • UIKit Dynamics,iOS7 之后 UIKit 中包含的可应用于 View 对象的“物理引擎”。可见 Pandara 的这篇博文
首先,我们需要一个能够互相碰撞小球跟地面。小球为 SBJellyBall 类对象,实现它时有一个地方需要注意一下。在 iOS9 里面,协议 UIDynamicItem 新引入一个属性,可以让我们设置 item 的碰撞边缘类型:
1
@property(nonatomic, readonly) UIDynamicItemCollisionBoundsType collisionBoundsType;

注意它是 readonly 的,所以只能在 SBJellyBall.m 文件里这样实现它:

1
2
3
4
5
6
// SBJellyBall.m

- (UIDynamicItemCollisionBoundsType)collisionBoundsType
{
return UIDynamicItemCollisionBoundsTypeEllipse;
}

然后我们设置 UIDynamic 相关的东东:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// SBJellyRefreshView.m

//gravity
UIGravityBehavior *gravityBehavior = [[UIGravityBehavior alloc] initWithItems:@[self.ball]];
[self.animator addBehavior:gravityBehavior];

//collision
UICollisionBehavior *collisionBehavior = [[UICollisionBehavior alloc] initWithItems:@[self.ball]];
collisionBehavior.collisionMode = UICollisionBehaviorModeEverything;
collisionBehavior.translatesReferenceBoundsIntoBoundary = YES;
[self.animator addBehavior:collisionBehavior];

//ball property
UIDynamicItemBehavior *ballBehavior = [[UIDynamicItemBehavior alloc] initWithItems:@[self.ball]];
ballBehavior.elasticity = 0.4;
ballBehavior.allowsRotation = YES;
ballBehavior.friction = 1;
ballBehavior.resistance = 0.5;
[self.animator addBehavior:ballBehavior];

image


接着,尝试给 View 添加一个弧形的下边界。首先初始化我们的贝塞尔曲线路径:

1
2
3
4
5
6
7
8
9
10
11
- (UIBezierPath *)getPathFromDistance:(CGFloat)Distance
{
UIBezierPath *bezierPath = [UIBezierPath bezierPath];
[bezierPath moveToPoint:CGPointMake(0, 0)];
[bezierPath addLineToPoint:CGPointMake(self.frame.size.width, 0)];
[bezierPath addLineToPoint:CGPointMake(self.frame.size.width, self.frame.size.height)];
[bezierPath addQuadCurveToPoint:CGPointMake(0, self.frame.size.height) controlPoint:CGPointMake(self.frame.size.width / 2.0f, self.frame.size.height * 1.5)];
[bezierPath closePath];

return bezierPath;
}

然后把它添加到碰撞边界中,这一步是关键,将上面有关碰撞的设置代码修改成下面这段:

1
2
3
4
5
6
7
//collision
self.bezierPath = [self getPathFromDistance:0];

UICollisionBehavior *collisionBehavior = [[UICollisionBehavior alloc] initWithItems:@[self.ball]];
collisionBehavior.collisionMode = UICollisionBehaviorModeEverything;
[collisionBehavior addBoundaryWithIdentifier:COLLISION_BOUNDARY_BEZIER forPath:self.bezierPath];
[self.animator addBehavior:collisionBehavior];

iamge


到这里,实现这个Q弹效果的核心部分已经讲完了。接下来大致说说自定义下拉刷新相关的杂事。小球在下拉之前处于悬空状态,此时应该:

  • 将小球从 gravity behavior 中移除
  • 重置小球的位置,记得设置完成之后调用 UIDynamicAnimator 的 updateItemUsingCurrentState: 来更新小球的位置


执行刷新动画时,需要将小球所有 dynamic 相关的东东移除:

  • 从 gravity behavior 中移除
  • 将 ballBehavior 从 animator 中移除,免得对后面要加入的 animation 造成冲突
  • 将 collision 移除


有那么一个特殊情况,如果用户滑动得太过猛烈,小球会掉出来,这时候需要一个方法将小球拽回来:

1
2
3
4
5
6
7
- (void)dragBallBackIfNeeded
{
if (![self.bezierPath containsPoint:self.ball.center]) {
self.ball.center = CGPointMake(self.ball.center.x, self.ball.center.y - BALL_W);
[self.animator updateItemUsingCurrentState:self.ball];
}
}

最后说说我自定义下拉刷新的大致流程:
1.定义自己的 refreshView 类,并定义触发刷新的下拉距离

1
#define REFRESH_H 80

2.在 refreshView 的 drawRect: 中获取 parent scrollView:

1
2
3
4
5
6
7
8
9
10
11
12
13
- (void)drawRect:(CGRect)rect
{
[super drawRect:rect];
UIView *superView = self.superview;
while (superView) {
if ([superView isKindOfClass:[UIScrollView class]]) {
self.parentScrollView = (UIScrollView *)superView;
break;
} else {
superView = superView.superview;
}
}
}

3.实现三个方法,用处嘛,看方法名字就知道了:

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
- (void)scrollViewDidScroll
{
// 更新 refreshView 的 UI
// 注意区分此时是否处于 refreshing 的状态


// 为了能流畅地设置 contentInset
if (_toRefresh) {

if (self.parentScrollView.contentOffset.y >= -REFRESH_H && self.parentScrollView.contentInset.top == 0) {
self.parentScrollView.contentInset = UIEdgeInsetsMake(REFRESH_H, 0, 0, 0);
self.parentScrollView.contentOffset = CGPointMake(0, -REFRESH_H);
}
}
}

- (void)scrollViewDidEndDragging
{
// 判断是否需要执行刷新
}

- (void)endRefresh
{
// 重置所有
}

最终效果:
image