【iOS】QQ粘性动画果冻效果

QQ中的未读消息可以通过拖拽根据手指移动,在范围内拖拽会有一个粘性动画,当松开手指时还会有一个果冻效果。看到这个动画时,眨眼一看挺好看,仔细一想挺难,但深入分析后发现做起来不难。

一、整体思路

1.1. 构建UI

  1. 既然可以拖动,应该是一个view,使用UIButton或者UILabel都可以,我们选择使用UIButton
  2. 在按钮的原始位置创建一个尺寸小于按钮的view,作为‘吸盘’固定在该位置,吸盘和按钮是同一级,不是父子关系。
    1
    2
    3
    4
    self.placeView = [[UIView alloc] init];
    self.placeView.frame = self.frame;
    self.placeView.layer.cornerRadius = self.placeView.bounds.size.width * 0.5;
    [self.superview insertSubview:self.placeView belowSubview:self];

1.2. 添加拖动事件

  1. UIButton添加拖动事件,让按钮能够跟随手指在屏幕上移动;
  2. 拖动按钮时,让吸盘随着拖动距离按比例收缩;
  3. 拖动位移达到限定距离后让吸盘消失,按钮继续跟随手指位置;
  4. 取消拖动后,如果在限定距离内,让按钮回到原始位置,否则停留在当前触摸点。
    1
    2
    UIPanGestureRecognizer *panGesture = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(panGestureHandle:)];
    [self addGestureRecognizer:panGesture];

1.3. 计算路径(重点)

由于transform不会修改center,只会修改frame,为了方便计算两个圆心之间的距离,所以不使用transform让按钮发生位移动画。

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
- (void)panGestureHandle:(UIPanGestureRecognizer *)gesture {
CGPoint translatePoint = [gesture translationInView:gesture.view];
// 计算按钮中心点
CGPoint center = self.center;
center.x += translatePoint.x;
center.y += translatePoint.y;
self.center = center;

// 位移复位
[gesture setTranslation:CGPointZero inView:gesture.view];

// 获取两个view的圆心距离
CGFloat distance = [self getCircleCenterDistanceWithView:self otherView:self.placeView];
// 计算缩放比例
CGFloat place_radius = self.bounds.size.width * 0.5;
place_radius = place_radius - distance / 10.0;
self.placeView.bounds = CGRectMake(0.0, 0.0, place_radius * 2, place_radius * 2);
self.placeView.layer.cornerRadius = place_radius;
}

// 获取圆心距离
- (CGFloat)getCircleCenterDistanceWithView:(UIView *)aView otherView:(UIView *)bView {
// 1.斜边 = 临边两个数平方的和的平方根
CGFloat offset_x = fabs(aView.center.x - bView.center.x);
CGFloat offset_y = fabs(aView.center.y - bView.center.y);
CGFloat distance = sqrtf(offset_x * offset_x + offset_y * offset_y);

// 2.求直角三角形斜边长度的C函数:hypotf(a, b)
// distance = hypotf(offset_x, offset_y);

return distance;
}

圆心距离有了,只需要计算出两个圆的四个外切连线点坐标,然后把四个点连接起来并填充颜色就可以了。

四个点的坐标如何计算呢?

$已知 OA \perp AB { , } PB \perp AB { , } OA=PB= d \div 2 $

由上图可知,当动画改变时,两个圆距离和角度会发生变化,变换的值是距离d角度θ。根据已知条件,用三角定理可以求出距离d,使用三角函数和相似三角原理可以求出其他各点的坐标(计算坐标时要用数学思维,不要把屏幕坐标系考虑在内)。

求距离d:
$ d = \sqrt{ (x_{1}-x_{2}) ^{2}+ (y_{1}-y_{2}) ^{2} } $

优先求出MNx2轴的角θ正弦和余弦:

$ \sin \theta = \frac{ x_{2}-x_{1} }{d} $

$ \cos \theta = \frac{ y_{2}-y_{1} }{d} $

求A的坐标:

1
2
3
4
5
AE = r1 * cosθ
EM = r1 * sinθ
A的x坐标 = x1 - AE
A的y坐标 = y1 + EM
A(x1 - AE, y1 + EM)

同理可得其他点坐标:

1
2
3
4
5
B(x1 + r1 * cosθ, y1 - r1 * sinθ)
C(x2 + r2 * cosθ, y2 - r2 * sinθ)
D(x2 - r2 * cosθ, y2 + r2 * sinθ)
O(A坐标x + 2/d * sinθ, A坐标y + 2/d * cosθ)
P(B坐标x + 2/d * sinθ, B坐标y + 2/d * cosθ)

最后只需要把所有点用贝塞尔曲线连接起来就行了。

1.4. 绘制形状(重点)

直接使用UIBezierPath不能填充路径,而且超出路径范围后不显示,所以需要使用CAShapeLayer根据路径生成一个形状,并形状添加到父视图上。

1
2
3
4
5
6
7
8
9
10
11
12
13
UIBezierPath *path = [self getBezierPathWithView:self.placeView otherView:self];
// 根据路径填充形状
self.shapeLayer.path = path.CGPath;

- (CAShapeLayer *)shapeLayer {
if (!_shapeLayer) {
CAShapeLayer *shapeLayer = [CAShapeLayer layer];
shapeLayer.fillColor = self.backgroundColor.CGColor;
[self.superview.layer insertSublayer:shapeLayer atIndex:0];
self.shapeLayer = shapeLayer;
}
return _shapeLayer;
}

二、实现细节

DBBadgeButton

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
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
@interface DBBadgeButton ()

@property (nonatomic, strong) UIView *placeView; // 占位
@property (nonatomic, weak) CAShapeLayer *shapeLayer; // 路径形状

@end

@implementation DBBadgeButton

- (void)awakeFromNib {
[super awakeFromNib];
[self initConfig];
}

- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
[self initConfig];
}
return self;
}

// 取消高亮状态(不调用super即可)
- (void)setHighlighted:(BOOL)highlighted {

}

- (void)willMoveToSuperview:(UIView *)newSuperview {
[newSuperview insertSubview:self.placeView belowSubview:self];
[newSuperview.layer insertSublayer:self.shapeLayer atIndex:0];
[super willMoveToSuperview:newSuperview];
}

// 初始化配置
- (void)initConfig {
self.backgroundColor = [UIColor redColor];

[self setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
UIPanGestureRecognizer *panGesture = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(panGestureHandle:)];
[self addGestureRecognizer:panGesture];
}

- (void)layoutSubviews {
[super layoutSubviews];
self.layer.cornerRadius = self.bounds.size.width * 0.5;
self.placeView.frame = self.frame;
self.placeView.layer.cornerRadius = self.placeView.bounds.size.width * 0.5;
}

#pragma mark - Private Method
// 拖动手势
- (void)panGestureHandle:(UIPanGestureRecognizer *)gesture {
CGPoint translatePoint = [gesture translationInView:gesture.view];
// 跟随手指移动
CGPoint center = self.center;
center.x += translatePoint.x;
center.y += translatePoint.y;
self.center = center;

// 位移复位
[gesture setTranslation:CGPointZero inView:gesture.view];

// 获取两个view的圆心距离
CGFloat distance = [self getCircleCenterDistanceWithView:self.placeView otherView:self];
// 计算缩放比例
CGFloat place_radius = self.bounds.size.width * 0.5;
place_radius = place_radius - distance / 10.0;
self.placeView.bounds = CGRectMake(0.0, 0.0, place_radius * 2, place_radius * 2);
self.placeView.layer.cornerRadius = place_radius;

// 范围内显示路径
if (self.placeView.hidden == NO) {
UIBezierPath *path = [self getBezierPathWithView:self.placeView otherView:self];
// 根据路径填充形状
self.shapeLayer.path = path.CGPath;
}

// 超出范围隐藏路径
if (distance > 60) {
self.placeView.hidden = YES;
[self.shapeLayer removeFromSuperlayer];
}

// 手势结束
if (gesture.state == UIGestureRecognizerStateEnded) {
if (distance <= 60) { // 复位
[UIView animateWithDuration:0.25 delay:0 usingSpringWithDamping:0.6 initialSpringVelocity:0 options:UIViewAnimationOptionCurveLinear animations:^{
self.center = self.placeView.center;
} completion:nil];
self.placeView.hidden = NO;
[self.shapeLayer removeFromSuperlayer];
} else { // 做动画

}
}
}

// 获取路径
- (UIBezierPath *)getBezierPathWithView:(UIView *)aView otherView:(UIView *)bView {
// 获取圆心距离
CGFloat d = [self getCircleCenterDistanceWithView:aView otherView:bView];
// 距离小于0不绘制曲线
if (d <= 0) {
return nil;
}
// 计算坐标点
CGFloat x1 = aView.center.x;
CGFloat y1 = aView.center.y;

CGFloat x2 = bView.center.x;
CGFloat y2 = bView.center.y;

CGFloat sinθ = (x2 - x1) / d;
CGFloat cosθ = (y2 - y1) / d;

CGFloat r1 = aView.bounds.size.width * 0.5;
CGFloat r2 = bView.bounds.size.width * 0.5;

CGPoint pointA = CGPointMake(x1 - r1 * cosθ, y1 + r1 * sinθ);
CGPoint pointB = CGPointMake(x1 + r1 * cosθ, y1 - r1 * sinθ);
CGPoint pointC = CGPointMake(x2 + r2 * cosθ, y2 - r2 * sinθ);
CGPoint pointD = CGPointMake(x2 - r2 * cosθ, y2 + r2 * sinθ);
CGPoint pointO = CGPointMake(pointA.x + 2/d * sinθ, pointA.y + 2/d * cosθ);
CGPoint pointP = CGPointMake(pointB.x + 2/d * sinθ, pointB.y + 2/d * cosθ);

// 绘制路径
UIBezierPath *path = [UIBezierPath bezierPath];
// AB直线
[path moveToPoint:pointA];
[path addLineToPoint:pointB];
// BC曲线
[path addQuadCurveToPoint:pointC controlPoint:pointP];
// CD直线
[path addLineToPoint:pointD];
// DA曲线
[path addQuadCurveToPoint:pointA controlPoint:pointO];

return path;
}

// 获取圆心距离
- (CGFloat)getCircleCenterDistanceWithView:(UIView *)aView otherView:(UIView *)bView {
// 1.斜边 = 临边两个数平方的和的平方根
CGFloat offset_x = fabs(aView.center.x - bView.center.x);
CGFloat offset_y = fabs(aView.center.y - bView.center.y);
CGFloat distance = sqrtf(offset_x * offset_x + offset_y * offset_y);

// 2.求直角三角形斜边长度的C函数:hypotf(a, b)
distance = hypotf(offset_x, offset_y);

return distance;
}

#pragma mark - Setter and Getter
- (void)setBackgroundColor:(UIColor *)backgroundColor {
[super setBackgroundColor:backgroundColor];
self.placeView.backgroundColor = backgroundColor;
self.shapeLayer.fillColor = backgroundColor.CGColor;
}

- (UIView *)placeView {
if (!_placeView) {
_placeView = [[UIView alloc] init];
}
return _placeView;
}

- (CAShapeLayer *)shapeLayer {
if (!_shapeLayer) {
CAShapeLayer *shapeLayer = [CAShapeLayer layer];
shapeLayer.fillColor = self.backgroundColor.CGColor;
[self.superview.layer insertSublayer:shapeLayer atIndex:0];
self.shapeLayer = shapeLayer;
}
return _shapeLayer;
}

@end