QQ里面有一个非常炫酷的动画,就是它的消息提示的小红点是可以拖拽的。这个动画综合运用了绘图、手势拖拽和CAShapeLayer的基础知识,下面我们就通过一个小项目来模仿一下:
![](http://upload-images.jianshu.io/upload_images/1012450-cb19040d21a97134.gif?imageMogr2/auto-orient/strip)
一、添加消息按钮
首先,你需要知道这个小红点其实是一个UIButton,因为这个button的形状和系统默认的不太一样,所以肯定会想到要自定义一个UIButton。先在Main.storyboard文件中的控制器里布局一个UIButton控件,然后新建一个继承自UIButton的ESTipsButton类来描述它:
![](https://i-blog.csdnimg.cn/blog_migrate/3a88c197a8e562c103394439c83197be.webp?x-image-process=image/format,png)
将按钮的背景颜色设置成红色,并给它的Label随便设置一个白色的数字,为了便于后面的操作,我将这个按钮的尺寸设置的非常的大。先运行程序看一下:
![](https://i-blog.csdnimg.cn/blog_migrate/d5a9f24ca74c44ffec9099752a439878.webp?x-image-process=image/format,png)
从GIF图上可知,这个按钮应该是圆的,为此,来到ESTipsButton.m文件,对按钮进行初始化:
// MARK:- 控件的初始化(Main.storyboard或者xib)
- (void)awakeFromNib {
[super awakeFromNib];
/************************** 初始化按钮 **************************/
// 初始化按钮
[self setup];
}
// MARK:- 初始化控件(纯代码)
- (instancetype)initWithFrame:(CGRect)frame {
if (self = [super initWithFrame:frame]) {
// 初始化按钮
[self setup];
}
return self;
}
// MARK:- 初始化按钮(保证通过代码创建的tips按钮背景颜色、文字等和通过Main.storyboard创建的一样)
- (void)setup {
// 设置按钮的圆角
self.layer.cornerRadius = self.bounds.size.width * 0.5;
// 设置按钮背景颜色
[self setBackgroundColor:[UIColor redColor]];
// 设置按钮文字颜色
[self setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
// 设置按钮文字大小
self.titleLabel.font = [UIFont systemFontOfSize:40];
}
我们这个按钮是通过Main.storyboard加载的,所以应该在- awakeFromNib方法中对其进行初始化。有一个问题,就是我们在Main.storyboard文件中已经设置好了按钮的背景颜色、label文字的颜色和字体大小,为什么在初始化的时候还要重复操作一遍呢?这个主要是从封装的角度考虑的。因为以后按钮可能不只是从Main.storyboard或者xib文件中加载,也有可能是通过纯代码的方式加载,如果不在初始化的过程中再将它们设置一遍,那么通过纯代码的方式加载的按钮,其背景颜色、label的文字颜色和字体大小可能就不是预期结果了。运行程序看一下:
![](https://i-blog.csdnimg.cn/blog_migrate/c311e9faca2685790e77eeafa2ee6c30.webp?x-image-process=image/format,png)
二、让按钮可拖动
我们已经办到让按钮呈现出圆形,接下来就是要让按钮可拖动。为此,来到- awakeFromNib方法和- initWithFrame:方法中,给按钮添加拖动手势(由于我们这个按钮是通过Main.storyboard文件加载的,只在- awakeFromNib方法中添加手势也是可以的):
// MARK:- 控件的初始化
- (void)awakeFromNib {
[super awakeFromNib];
/************************** 初始化按钮 **************************/
// 初始化按钮
[self setup];
/************************** 添加手势 **************************/
// 添加拖动手势
UIPanGestureRecognizer *pan = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(panGesture:)];
[self addGestureRecognizer:pan];
}
// MARK:- 初始化控件
- (instancetype)initWithFrame:(CGRect)frame {
if (self = [super initWithFrame:frame]) {
// 初始化按钮
[self setup];
/************************** 添加手势 **************************/
// 添加拖动手势
UIPanGestureRecognizer *pan = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(panGesture:)];
[self addGestureRecognizer:pan];
}
return self;
}
实现拖动手势时的方法- panGesture:。回顾一下我们在前面学习的手势拖动的方法,要实现手势拖动,首先必须获取当前手势所在的点;然后,再通过这个点获取相应的x轴和y轴的偏移量。有了偏移量之后,就可以通过CGAffineTransformTranslate ( )函数来设置按钮的transform属性了;最后,为了保证手势拖动的流畅,必须做复位操作:
// MARK:- 实现手势拖动的方法
- (void)panGesture:(UIPanGestureRecognizer *)pan {
// 获取当前手势所在的点
CGPoint currentPoint = [pan locationInView:self];
// 让按钮根据手势移动做出平移(transform不会修改center的值,它修改的是frame的值)
self.transform = CGAffineTransformTranslate(self.transform, currentPoint.x, currentPoint.y);
// 复位操作
[pan setTranslation:CGPointZero inView:self];
}
运行程序,看看能不能拖动屏幕上的按钮:
![](http://upload-images.jianshu.io/upload_images/1012450-de3a3cb97491910d.gif?imageMogr2/auto-orient/strip)
现在,我们可以办到让按钮随着手指的移动而移动了。不过,在手指移动过程中,这个按钮是有高亮状态的。实际上,QQ消息的高亮状态是没有的,所以,我们还需要去掉这个按钮的高亮状态:
// MARK:- 取消按钮的高亮状态
- (void)setHighlighted:(BOOL)highlighted {
highlighted = NO;
}
再次运行程序,看一下高亮状态还有没有:
![](https://i-blog.csdnimg.cn/blog_migrate/31de0eb90bbeb278c77ea04e0f2abdc4.webp?x-image-process=image/format,png)
三、给按钮底部添加一个小圆
看一下原始程序,当我们拖动按钮时,它底部应该是有一个小圆的。为此,我们先来添加这个小圆。这个小圆应该是程序一运行时就有的,它处在按钮的底部,并且尺寸和背景颜色与按钮一样。还有,底部这个小圆在其它方法中也会用到,应该在ESTipsButton的类扩展中声明一个属性来保存它:
// 创建小圆
UIView *smallCircle = [[UIView alloc] initWithFrame:self.frame]; // 小圆的位置和尺寸与按钮一致
// 设置小圆的背景颜色
smallCircle.backgroundColor = [UIColor redColor];
// 设置小圆的圆角
smallCircle.layer.cornerRadius = self.layer.cornerRadius;
// 将smallCircle保存起来
self.smallCircle = smallCircle;
// 将小圆添加到控制器的view上(也就是按钮的父控件上)
[self.superview addSubview:smallCircle];
// 将小圆插入到按钮与控制器的view中间
[self.superview insertSubview:smallCircle belowSubview:self];
这个小圆比按钮后创建,将它添加上去的时候肯定是盖在按钮上面的,但是,我们的需求是,按钮必须在小圆上面。不过,好在- insertSubview: belowSubview:这个方法可以帮助我们调整按钮和它底部小圆之间的顺序。来运行程序看一下:
![](https://i-blog.csdnimg.cn/blog_migrate/c8d0bc489590257db359dfb34f496f9b.webp?x-image-process=image/format,png)
四、计算两个圆之间的距离
从原始的GIF图中我们可以看到,当我们拖动消息提示的小按钮时,底部的小圆会随着距离的增大而逐渐缩小。为此,我们首先要做的是,计算出按钮和它底部小圆之间的圆心距离。来到手势拖动的实现方法中,计算出按钮和小圆之间的圆心距离。为了保证代码的可读性,最好是做到分模块设计,将计算圆心距的代码抽出来单独作为一个方法:
// MARK:- 计算按钮和它底部小圆之间的距离
- (CGFloat)distanceBetweenSmallCircle:(UIView *)smallCircle andTipsButton:(UIButton *)tipsButton {
// x轴方向上的偏移量
CGFloat offsetX = tipsButton.center.x - smallCircle.center.x;
// y轴方向上的偏移量
CGFloat offsetY = tipsButton.center.y - smallCircle.center.y;
// 两个圆形之间的距离
return sqrt(offsetX * offsetX + offsetY * offsetY);
}
来到- panGesture:方法的底部,调用一下计算按钮和它底部小圆之间圆心距的方法:
/************************** 计算两个圆之间的距离 **************************/
CGFloat distance = [self distanceBetweenSmallCircle:self.smallCircle andTipsButton:self];
为了验证计算结果,我们先打印一下distance,看看它有没有值:
![](http://upload-images.jianshu.io/upload_images/1012450-de32d18ed9d6faf6.gif?imageMogr2/auto-orient/strip)
从控制台打印出来的消息看,在手指拖动的过程中,这个distance的值一直是0!难道是我们的计算方法有误吗?其实并不是,只不过,通过transform属性所做的平移,它并不会修改控件center的值,它修改的是控件的frame。而为了计算出按钮和它底部小圆之间的圆心距,又必须要借助它们的center。为此,我们要使用新的方式来让按钮做出平移。先将self.transform = CGAffineTransformTranslate(self.transform, currentPoint.x, currentPoint.y);注释掉,然后获取按钮center的值,接着修改center的x和y的值,最后将修改过后的center赋值回去:
CGPoint center = self.center; // 取出按钮的center的值
center.x += currentPoint.x; // 修改center的x值
center.y += currentPoint.y; // 修改center的y值
self.center = center; // 将修改过后的center的值赋值回去
运行程序,看看通过这种方式有没有让按钮随着手指的拖动做出平移,同时查看distance的值有没有变化:
![](http://upload-images.jianshu.io/upload_images/1012450-8f3e4141535eb679.gif?imageMogr2/auto-orient/strip)
五、让按钮底部小圆的半径随着圆心距的增大而缩小
圆心距已经求出来了,接下来就是要让按钮底部的小圆随着圆心距的增大而缩小。这项功能还是和手指拖动有关,因此还是在- panGesture:方法中实现它:
// 取出小圆的半径
CGFloat smallR = self.smallCircle.bounds.size.width * 0.5;
// 让小圆的半径随着距离的增大而缩小
smallR -= distance / 10.0;
// 重新设置smallCircle的bounds
self.smallCircle.bounds = CGRectMake(0, 0, smallR * 2, smallR * 2);
// 重新设置圆角半径
self.smallCircle.layer.cornerRadius = smallR;
在上述代码中,数字10并没有什么特殊的含义,只不过是为了让小圆的半径随着圆心距的增大而缩小所除的一个数字,你完全可以改成其它的。这个数字你可以自己去控制。运行程序看一下:
![](http://upload-images.jianshu.io/upload_images/1012450-8e95daa27d7dc8a6.gif?imageMogr2/auto-orient/strip)
从图上可以看到,随着圆心距的增大,这个小圆缩小得非常的快。而且,当圆心距增大到一定的距离以后,这个小圆完全变成了一个正方形(小圆的半径为负值以后就变成了正方形)!这是为什么呢?回忆一下在《手势识别》中所学到的知识,这主要是因为没有执行"复位操作"的原因。因为- panGesture:这个方法调用得非常的频繁(按钮每移动一丁点,它就会被调用一次),而我们小圆的半径都是在上一次已经缩小过半径的基础上再次不断缩小的,所以小圆的半径最后会缩小得非常疯狂!我们需要的效果是,小圆的半径是在原始半径的基础上逐渐缩小(而不是在上一次已经缩小过的基础上再次不断缩小)。那么,有什么办法可以执行类似的"复位操作"呢?其实,小圆的半径最开始是和按钮的半径相等的,而按钮的半径在整个移动过程中是固定不变的。为此,只需要将取出小圆半径的代码从CGFloat smallR = self.smallCircle.bounds.size.width * 0.5;换成CGFloat smallR = self.bounds.size.width * 0.5;,问题就可以迎刃而解了。运行程序看一下:
![](http://upload-images.jianshu.io/upload_images/1012450-f114821df818367c.gif?imageMogr2/auto-orient/strip)
六、描述拖动按钮时两个圆之间的不规则路径
接下来要完成的操作是,当按钮与它底部小圆之间圆心距逐渐增大时,绘制它们之间像鼻涕一样不规则的图形。要完成绘图,首先要描述好路径,为此,我们先来分析一下。先看下面的图:
![](https://i-blog.csdnimg.cn/blog_migrate/6ac7bbea0936faa9a4f8428f0448d50f.webp?x-image-process=image/format,png)
只需要计算出上图中6个绿色点所在的点坐标,然后描述相应的路径就可以了。其中,A点到B点是一条直线,B点到C点是一条曲线,而P点是这条曲线的控制点;C点到D点又是一条直线,D点到A点同样是一条曲线,而O点是这条曲线的控制点。在上面的图中,小圆和大圆的圆心点坐标、两个圆的半径,以及两个圆心之间的距离都是已知的,只需要简单的三角函数知识就可以计算出A、B、C、D、O和P点的坐标。有了这些坐标,再描述相应的路径,那就是再简单不过的事情了:
// MARK:- 描述两个圆之间的不规则路径
- (UIBezierPath *)pathWithSmallCircle:(UIView *)smallCircle andTipsButton:(UIButton *)tipsButton {
// 求点
CGFloat x1 = smallCircle.center.x;
CGFloat y1 = smallCircle.center.y;
CGFloat x2 = tipsButton.center.x;
CGFloat y2 = tipsButton.center.y;
CGFloat d = [self distanceBetweenSmallCircle:smallCircle andTipsButton:tipsButton];
if (d <= 0) {
return nil;
}
CGFloat cosΘ = (y2 - y1) / d;
CGFloat sinΘ = (x2 - x1) / d;
CGFloat r1 = smallCircle.bounds.size.width * 0.5;
CGFloat r2 = tipsButton.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 + d * 0.5 * sinΘ, pointA.y + d * 0.5 * cosΘ);
CGPoint pointP = CGPointMake(pointB.x + d * 0.5 * sinΘ, pointB.y + d * 0.5 * cosΘ);
// 描述路径
UIBezierPath *path = [UIBezierPath bezierPath];
// AB
[path moveToPoint:pointA]; // 设置A为起点
[path addLineToPoint:pointB]; // 添加一根线到B点
// BC(曲线)
[path addQuadCurveToPoint:pointC controlPoint:pointP]; // 添加一根曲线到C点,其中P点为控制点
// CD
[path addLineToPoint:pointD]; // 添加一根直线到D点
// DA(曲线)
[path addQuadCurveToPoint:pointA controlPoint:pointO]; // 添加一根曲线到A点,其中O点为控制点
// 返回路径
return path;
}
七、根据已描述的路径绘图
路径已经描述好了,接下来就是在手指拖动的过程中,绘制不规则路径。所以,应该在- panGesture:方法中实现这个功能。
要完成这个功能,先来学习一个新的知识点——CAShapeLayer。它是CALayer的一个子类,使用起来非常灵活,只要你描述好了路径,通过它可以画出各种形状的图形。照例还是进入它的头文件中看一下:
![](https://i-blog.csdnimg.cn/blog_migrate/224f191826684ff793603624b6b73433.webp?x-image-process=image/format,png)
与CAGradientLayer和CAReplicatorLayer比起来,它的内容稍微多那么一点点,不过也都很好理解。我们今天要用的是它的path属性。先调用我们在上面写的- pathWithSmallCircle: andTipsButton:方法,获取已经描述好的路径,然后再创建CAShapeLayer图层对象,并且将已获取到的路径传给CAShapeLayer对象的path属性,最后将CAShapeLayer图层对象插入到小圆的下面就可以了:
// 获取path
UIBezierPath *path = [self pathWithSmallCircle:self.smallCircle andTipsButton:self];
// 创建形状图层
CAShapeLayer *shpeLayer = [CAShapeLayer layer];
// 将已经描述好的路径传给CAShapeLayer对象的path属性
shpeLayer.path = path.CGPath;
// 设置路径填充颜色
shpeLayer.fillColor = [UIColor redColor].CGColor;
// 将形状图层插入到最底下
[self.superview.layer insertSublayer:shpeLayer above:0];
来运行程序看一下效果:
![](https://i-blog.csdnimg.cn/blog_migrate/dee08e41932a99b6367c9ec39509791a.webp?x-image-process=image/format,png)
说好的鼻涕效果,为什么看起来特别像大姨妈?!还记得在上面说过的,- panGesture:这个方法调用非常频繁吗?我们将创建CAShapeLayer图层的代码放在这个方法里,导致了在手势拖动的过程中创建了无数个形状图层(从GIF图上可以看出创建了N个),我实际上,自始至终我们只需要一个。为此,我们需要对创建形状图层的代码进行懒加载。先在ESTipsButton的类扩展中声明一个shpeLayer属性,然后对它进行懒加载:
// MARK:- 形状图层的懒加载
- (CAShapeLayer *)shpeLayer {
if (!_shpeLayer) {
// 创建形状图层
CAShapeLayer *shpeLayer = [CAShapeLayer layer];
// 将形状图层插入到最底下
[self.superview.layer insertSublayer:shpeLayer above:0];
// 设置路径填充颜色
shpeLayer.fillColor = [UIColor redColor].CGColor;
_shpeLayer = shpeLayer;
}
return _shpeLayer;
}
来到- panGesture:方法,将之前创建形状图层的代码修改为下面这样的:
// 获取path
UIBezierPath *path = [self pathWithSmallCircle:self.smallCircle andTipsButton:self];
// 将已经描述好的路径传给CAShapeLayer对象的path属性
self.shpeLayer.path = path.CGPath;
运行程序看一下:
![](http://upload-images.jianshu.io/upload_images/1012450-e2637a6ed8307eba.gif?imageMogr2/auto-orient/strip)
八、手势拖动结束以后的处理
从上面的GIF图可以看出,现在不管手指怎么拖,CAShapeLayer图层对象永远都只有一份了。可以去玩一下QQ的消息提示按钮,当你用手指进行拖动时,如果拖动距离没有超过一定距离,松开手指以后,提示按钮会回弹复位的。只有当你的手指拖动距离超过一定的范围之后,消息提示才会消失。下面我们来实现一下这些功能。
先来实现当手指拖动的距离超过一定范围时,按钮底部的小圆完全消失。这个距离可以自己根据实际情况来调试,不过,其大小最好是要在让小圆的半径变成负数之前:
// 当按钮与小圆之间的距离大于某个值时
if (distance > 200) {
// 隐藏小圆
self.smallCircle.hidden = YES;
// 删除形状图层
[self.shpeLayer removeFromSuperlayer];
}
运行程序看一下:
![](https://i-blog.csdnimg.cn/blog_migrate/361e5df727a3190ff54431f526755091.webp?x-image-process=image/format,png)
现在,当圆心距超过一定范围以后,底部的小圆是可以消失了,但是又出现两个问题。第一个问题是,当我们把按钮重新拖到靠近小圆原来所在的位置时,已经消失的图层对象又出现了。解决的办法就是,在给图层对象path属性传递已描述好的路径的地方加上一个判断:
// 如果小圆没有被隐藏
if (self.smallCircle.hidden == NO) {
// 将已经描述好的路径传给CAShapeLayer对象的path属性
self.shpeLayer.path = path.CGPath;
}
也就是说,当小圆处于非隐藏状态下时,才需要给shpeLayer的path属性传递已描述好的路径。第二个问题是,当手指拖动结束以后,按钮没有回弹或者消失。解决这个问题,需要对手势拖动的状态进行判断。当手势拖动结束以后,如果没有超过一定距离,就让按钮回弹复位;如果拖动距离超过一定的距离,就让按钮消失。来到- panGesture:方法中,对手势拖动的状态进行判断:
// 当手势拖动结束时
if (pan.state == UIGestureRecognizerStateEnded) {
// 如果拖动的距离大于200
if (distance < 200) {
// 将形状图层从父控件中移除
[self.shpeLayer removeFromSuperlayer];
// 直接让按钮复位
self.center = self.smallCircle.center;
// 重新显示小圆,以便下次拖动按钮时可以重新绘制不规则图形
self.smallCircle.hidden = NO;
} else {
// 创建UIImageView对象
UIImageView *imageView = [[UIImageView alloc] initWithFrame:self.bounds];
// 用于存储播放动画的图片
NSMutableArray *imageArr = [NSMutableArray array];
for (int i = 0; i < 8; i++) {
// 加载图片
UIImage *image = [UIImage imageNamed:[NSString stringWithFormat:@"%d", i + 1]];
// 将图片添加到数组中
[imageArr addObject:image];
}
// 设置图片动画
imageView.animationImages = imageArr;
// 设置动画执行时间
imageView.animationDuration = 1;
// 开始播放动画
[imageView startAnimating];
// 将图片添加到按钮上
[self addSubview:imageView];
// 动画播放的时长是1秒,动画完毕以后直接从父控件中删除按钮
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
// 移除按钮
[self removeFromSuperview];
});
}
}
上面对手势状态进行判断的代码非常简单,不做详细解释。其中,播放图片动画的基础知识在《UIImageView的帧动画》中已经演示过,而GCD定时器的使用在之前的篇幅中也多次用过。现在运行程序看一下效果:
![](http://upload-images.jianshu.io/upload_images/1012450-91e8bbaf80717e20.gif?imageMogr2/auto-orient/strip)
从图中可以看出,当拖动距离没有超过一定范围就松手时,按钮是会回弹的。当拖动距离超过一定范围以后,按钮底部小圆消失,但是再把按钮拖回小圆之前所在的位置附近时,也没有出现重新绘制不规则图形的情形。最后,拖动距离超过一定范围之后,松开手,提示按钮完全消失。详细代码参见ESQQTips。