老规矩还是先上效果图视频效果点这里
老规矩还是先吐槽
简书限制gif不超过10M我就忍了,我的图为什么还跑的这么快,又没人追你跑那么快干啥
然后外面怎么还在下雨,这都啥时候了还不晴天
然后怎么又下班了,时间怎么过得这么快
然后.......
前言
最近一直有朋友问我关于动画的相关问题,购物车加商品动画啊,水波纹啊,贝塞尔曲线啊种种,刚好前段时间项目中有相关功能,于是想着写篇博客,总结一下动画相关知识,也以免自己遗忘再去翻代码,毕竟作为高级工程师,封装的太高深,时间一长自己都看不懂了。
正文
iOS中常用的动画实现方式无非就是那几种,UIView动画,核心动画,帧动画,spring动画,json(Lottie)动画,自我总结了一下画了个动画合集思维导图如下所示。本来准备一项项的写,但是想了想网上随便哪个动画,一搜都是一大堆资料,再写也写不出来个花,于是另辟蹊径,直接分解效果图上的每一个动画,讲解相应的实现方式。
先介绍个属性
- 在创建UIView对象时,UIView内部会自动创建一个层(即CALayer对象),UIView本身不具备显示的功能,是它内部的层才有显示功能。UIView是CALayer的管理器,CALayer的主要工作是为屏幕的绘制渲染提供所需的数据源,也就是说,你在屏幕上看到的内容,都是来源于CALayer。
- 每一个UIView都有一个backing layer,UIView的UI属性跟CALayer的属性是一一对应的,设置UIView的UI属性实际上是设置CALayer对应的属性,即UIView的绘制渲染工作是由CALayer完成。
- UIView对象之间存在着一定的层级关系,那么所以UIView的Backing Layer也相应的存在着一定的层级关系,这个层级关系叫做图层树(模型树)。
很重要的一点,layer不参与view的事件处理、不参与响应链,只负责显示,所以动画的操作,很大一部分程度上是对layer相关属性的操作。
1, 圆球扩大后再缩小 / 点击标签按钮抖动
CAKeyframeAnimation *animation = [CAKeyframeAnimation animationWithKeyPath:@"transform"];
NSMutableArray *values = [[NSMutableArray alloc]initWithCapacity:3];
[values addObject:[NSValue valueWithCATransform3D:CATransform3DMakeScale(0.1, 0.1, 1.0)]];
[values addObject:[NSValue valueWithCATransform3D:CATransform3DMakeScale(1.3, 1.3, 1.0)]];
[values addObject:[NSValue valueWithCATransform3D:CATransform3DMakeScale(0.9, 0.9, 1.0)]];
[values addObject:[NSValue valueWithCATransform3D:CATransform3DMakeScale(1.1, 1.1, 1.0)]];
[values addObject:[NSValue valueWithCATransform3D:CATransform3DMakeScale(1.0, 1.0, 1.0)]];
animation.values = values;
animation.duration = 0.5;
animation.removedOnCompletion = YES;
[self.layer addAnimation:animation forKey:nil];
2, 圆球中❤️❤️图标漂浮
介绍水球中❤️的浮动前,先来了解一个动画库Lottie,Lottie是一个为Android和iOS设备提供的一个开源框架,它能够解析通过Adobe After Effects 软件做出来的动画,动画文件通过Bodymovin导出json文件,然后由Lottie库解析json渲染动画,可以看官方示例网站,参考一下Lottie大概都能干些什么,比如说下图的这个开屏动画。
基本的用法如下
//从本地的json加载(json文件一般由设计师提供)
LOTAnimationView *hertView = [LOTAnimationView animationNamed:@"heart_inProgress"];
//从服务器的json加载
//LOTAnimationView *animationView = [[LOTAnimationView alloc] initWithContentsOfURL:[NSURL URLWithString:@""]];
hertView.cacheEnable = NO;
hertView.frame = frame;
hertView.contentMode = UIViewContentModeScaleToFill;
//设置播放速度
hertView.animationSpeed = 1.0;
//设置循环播放
hertView.loopAnimation = YES;
[self addSubview:hertView];
//播放 pause 暂停
[hertView play];
控制进度播放
//直接播放到指定进度
[animationView playToProgress:0.8 withCompletion:^(BOOL animationFinished) {
// do something
}];
//从进度A播放到进度B
[animationView playFromProgress:0 toProgress:0.8 withCompletion:^(BOOL animationFinished) {
// do something
}];
//直接设置当前进度
animationView.animationProgress = currentProgress;
Lottie同时还支持以下功能
- 控制帧播放
- 编辑某帧的动画对象的属性
- 添加自定义的视图到指定的 Layer
总体来说功能还是蛮强大的,能够满足大部分动画需求,同时运行效率和也比数十张图片轮播实现某个系统函数实现不了的动画效率高的多。由于篇幅有限,对Lottie其余没介绍到的功能感兴趣的同学可以在评论区留言,共同探讨一二。
3, +1,-1图标飘出
产品经理给的需求是,取下方点击按钮中心区域为飘出标签的起点,做一个路径为S型的动画,如果点击按钮位于屏幕左侧,往右侧做S动画,同理按钮位于右侧,往左侧做S动画,标签透明度渐变,到达屏幕顶部中心时,完全透明消失。下图黑色虚线为大概路径。
分析一下需求实现方式
- 确定动画的路径,可以画一条三次贝塞尔曲线,在每次点击事件中对贝塞尔曲线端点重新赋值以达到左右飘出的需求,然后将此路径赋值给layer的path。
- 透明度渐变的话,假设动画时间为1秒,维护一个定时器,每隔1/10秒改变一次标签的透明度,当到动画完成到达顶部时标签刚好透明
- 动画结束之后,在animationDidStop方法中可以直接销毁标签,也可以回收起来存到数组里,将属性全部清空,下一次点击取出再次利用,减少cpu频繁的alloc操作
问题
1次点击,屏幕中出现一个标签(UIImageView对象),如果点击迅速,屏幕中会出现数十个标签同时做动画,如何捕捉到每一个标签操作其透明度,难道要同时维护数十个定时器?而且animationDidStop方法也会被不停的调用,怎么做到准确的销毁每一个该被销毁的标签。
解决办法
维护一个全局数组与一个全局字典,每初始化一个标签(UIImageView对象),将此标签跟透明度存入一个局部字典dic,同时取当前的时间戳为key,局部字典dic对象为value。全局数组的作用相当于一个队列,后初始化的标签总是排在前一个标签的后面,动画结束代理方法调用时,每次默认删除第0个元素,以达到每次准确取出需要操作的标签的目的。
NSString *nowtime = [self getCurrentTime];
NSMutableDictionary *dic = [[NSMutableDictionary alloc] initWithCapacity:0];
[dic setObject:coverImgView forKey:@"btn"];
[dic setObject:@"1" forKey:@"alph"];
[self.btnDic setObject:dic forKey:nowtime];
[self.btnArry addObject:nowtime];
页面初始化时,init方法里只维护一个定时器,每个时间间隔内遍历字典,将按钮取出,改变其透明度。
// 设置回调
dispatch_source_set_event_handler(self.timer, ^{
for (NSString *keyStr in [weakself.btnDic allKeys]) {
NSDictionary *fistdic = [weakself.btnDic objectForKey:keyStr];
UIButton *fistbtn = [fistdic objectForKey:@"btn"];
if (fistbtn.alpha > 0) {
istbtn.alpha = fistbtn.alpha - 0.15;
[fistdic setValue:[NSString stringWithFormat:@"%f",fistbtn.alpha] forKey:@"alph"];
}
}
动画结束的代理方法中,将数组中第0个元素取出来,去字典中将此按钮取出删除,最后删除数据中第0个元素。记得删除动画,不然会多次调用addAnimation会引起内存泄漏。
- (void)animationDidStop:(CAAnimation *)anim finished:(BOOL)flag {
UIButton *resultbtn = [[self.btnDic objectForKey:self.btnArry[0]] objectForKey:@"btn"];;
[resultbtn removeFromSuperview];
[self.btnDic removeObjectForKey:self.btnArry[0]];
//每次删除动画 不然coverImgView多次调用addAnimation 会导致内存泄漏
[_coverImgView.layer removeAnimationForKey:self.btnArry[0]];
[self.btnArry removeObjectAtIndex:0];
}
关键问题解决后,就是简单的动画实现了,方式如下
1,初始化标签动画的路径
- (void)drawRect:(CGRect)rect {
UIBezierPath bepath = [UIBezierPath bezierPath];
bepath.lineWidth = 1.0;
bepath.lineCapStyle = kCGLineCapRound;
bepath.lineJoinStyle = kCGLineJoinRound;
[bepath stroke];
}
2,初始化一个关于位移的关键帧动画
//初始化一个关于位移的关键帧动画
CAKeyframeAnimation *anima = [CAKeyframeAnimation animationWithKeyPath:@"position"];
anima.delegate = self;
anima.path = bepath.CGPath;
//默认为YES,代表动画执行完毕后就从图层上移除,图形会恢复到动画执行前的状态。如果想让图层保持显示动画执行后的状态,那就设置为NO,不过还要设置fillMode为kCAFillModeForwards
anima.removedOnCompletion = NO;
anima.fillMode = kCAFillModeForwards;
anima.duration = 1.0;
3,按钮的点击事件里,动态的画每一条路径线
- (void)addTagAnimationWithBtn:(UIButton *)button {
/ /初始化一个tagImgView标签步骤省略
NSString *nowtime = [self getCurrentTime];
NSMutableDictionary *dic = [[NSMutableDictionary alloc] initWithCapacity:0];
[dic setObject:tagImgView forKey:@"btn"];
[dic setObject:@"1" forKey:@"alph"];
[self.btnDic setObject:dic forKey:nowtime];
[self.btnArry addObject:nowtime];
[bepath removeAllPoints];
//起点
[bepath moveToPoint:CGPointMake(CGRectGetMidX(button.frame),CGRectGetMidY(button.frame) + self.baseScrollview.frame.origin.y - self.scrollviewDeviationY)];
//终点
CGFloat endPointX = SCREEN_WIDTH / 2.0 - exceedWidth / 2.0 + arc4random() % exceedWidth;
[bepath addCurveToPoint:CGPointMake(endPointX , 10) controlPoint1:CGPointMake(pointx, 200) controlPoint2:CGPointMake(pointx2, 200)];
anima.path = bepath.CGPath;
//+1图标是一个UIImageView对象 以
[tagImgView.layer addAnimation:anima forKey:nowtime];
[bepath closePath];
}
4, 圆球中水波纹
水波纹实现方式大同小异,基本都要用到正弦曲线或者余弦曲线,y=Asin(ωx+φ)+h,先画一条静态的三角函数。
- (void)setCurrentWaveLayerPath {
// 通过正弦曲线来绘制波浪形状
CGMutablePathRef path = CGPathCreateMutable();
CGFloat y = self.currentWavePointY;
CGPathMoveToPoint(path, nil, 0, y);
CGFloat width = CGRectGetWidth(self.frame);
for (float x = 0.0f; x <= width; x++){
// 正弦波浪公式
y = 2 * self.waveAmplitude * sin(1.5 * self.waveCycle * x + self.offsetX) + self.currentWavePointY;
CGPathAddLineToPoint(path, nil, x, y);
}
CGPathAddLineToPoint(path, nil, width, CGRectGetHeight(self.frame));
CGPathAddLineToPoint(path, nil, 0, CGRectGetHeight(self.frame));
CGPathCloseSubpath(path);
self.waveLayer.path = path;
CGPathRelease(path);
}
要让曲线动起来,当不断改变x的值,曲线就会发生偏移,以达到动画效果,所以需要一个定时器。
//是用于同步屏幕刷新频率的计时器,调用屏幕会根据不同iphone设备自动调整,比普通定时器用于动画效果更自然
@property (nonatomic, strong) CADisplayLink *displayLink;
@property (nonatomic, strong) CAShapeLayer *waveLayer; // 绘制第一条波形
@property (nonatomic, strong) CAShapeLayer *waveTwoLayer; // 绘制第二条波形
@property (nonatomic, strong) CAShapeLayer *waveThreeLayer; // 绘制第三条波形
// 绘制波形的变量定义,使用波形曲线y=Asin(ωx+φ)+k进行绘制
@property (nonatomic, assign) CGFloat waveAmplitude; // 波纹振幅,A
@property (nonatomic, assign) CGFloat waveCycle; // 波纹周期,T = 2π/ω
@property (nonatomic, assign) CGFloat offsetX; // 波浪x位移,φ
@property (nonatomic, assign) CGFloat waveSpeed; // 波纹速度,用来累加到相位φ上,达到波纹水平移动的效果
@property (nonatomic, assign) CGFloat waveGrowth; // 波纹上升速度,累加到k上,达到波浪高度上升的效果
//用来计算波峰一定范围内的波动值
@property (nonatomic, assign) BOOL increase;
@property (nonatomic, assign) CGFloat variable; //波峰
初始化渲染定时器
//启动同步渲染绘制波纹
self.displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(setCurrentWave:)];
[self.displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
在定时器的每次回调方法中,做水波纹上升动画跟水平方向上波动动画
- (void)setCurrentWave:(CADisplayLink *)displayLink {
if ([self waveFinished]) {
// 波峰在一定范围之内进行轻微波动
// 波峰该继续增大或减小
if (self.increase) {
self.variable += 0.01;
}else{
self.variable -= 0.01;
}
// 变化的范围
if (self.variable <= 1){
self.increase = YES;
}
if (self.variable >= 1.6) {
self.increase = NO;
}
// 根据variable值来决定波峰
self.waveAmplitude = self.variable * 5;
}
else
{
// 波浪高度未到指定高度 继续上涨
[self amplitudeChanged];
if (self.percent > 0.5) {
self.currentWavePointY -= self.waveGrowth;
}else{
self.currentWavePointY += self.waveGrowth;
}
//圆球中分数增加
CGFloat resultcount = self.percent > 0.5 ? self.lableScore + self.perScore : self.lableScore - self.perScore;
self.lableScore = resultcount;
self.scoreLable.text = [NSString stringWithFormat:@"%d",(int)resultcount];
}
self.offsetX += self.waveSpeed;
[self setCurrentWaveLayerPath];
}
效果gif中有三条水波纹,是设计需求,为了减少篇幅此处只贴一条水波纹代码,三条的话错开相位φ即可达到效果。
结语
好久没更新博客了,工作中总能遇到各种各样的问题,各种突发奇想,不知所措。以及某一个问题的全部解决思路。