一款Loading动画的实现思路(一):复杂任务的拆分

2015年12月14日 12:27 0 点赞 0 评论 更新于 2025-11-21 17:51

前几天,朋友推荐了一款颇具趣味的Loading动画。正好这段时间我在学习动画相关知识,便尝试实现了一版。为降低实现难度,我对该动画做了一些简化。

考虑到抛砖引玉是一种很好的学习方式,我打算分几篇文章详细阐述自己的实现思路,也希望大家能分享更好的想法。

复杂动画的拆分思路

这个动画乍看之下较为复杂,但我们坚信一个原则:一个复杂任务可以拆分成一组简单任务。因此,我将这段复杂动画按时间拆分成了几个阶段,并且把每个阶段进一步拆分成了几个并行的简单动画。

若我们有动画的GIF文件,可以使用系统自带的Preview工具逐帧查看,从中获取拆分思路。不过,每个人的拆分方式可能不同,因为答案并非唯一。接下来,我们先聚焦于第一阶段的实现。为方便大家观察,我放慢了第一阶段动画的速度。

从观察可知,第一阶段的动画呈现为一段起点和终点不停变化的弧。于是,我决定采用重绘弧的方式来实现该动画。在绘制方面,我选择使用UIBezierPath,这是因为初次实现时,我更倾向于使用自己熟悉的方式。

要绘制弧,我们会用到UIBezierPath的特定方法。为了便于后文叙述,我在此引用UIBezierPath官方文档中的一张图,大家在后续参考我手绘的示意图时,可能需要借助此图。

假设弧的起点为O(origin),终点为D(dest),由于动画中弧是逆时针转动的,所以我们绘制弧时也采用逆时针方向,即从O点逆时针画到D点。

首先,我们来看动画开始和结束时弧的形态(注意:结束时的弧实际上是一个圆,为方便说明,我故意留了个缺口)。通过观察动画可以发现,结束时O点和D点在0(或2π)处重合,所以结束时可以认为弧是从2π逆时针画到0。这里需要注意,虽然0和2π在同一个点,但从0到0、从0到2π、从2π到0画弧的效果是不同的,建议大家亲自尝试绘制以加深理解。

确定了结束时O、D点的位置后,我们再来探究开始时的情况。通过观察动画中O、D点的运行轨迹,我们可以发现:O点逆时针(逆时针可理解为角度在减小,可参考上文提到的UIBezierPath官方文档中的图)绕了3/4圈到2π,D点逆时针绕了1.5圈到0。由此,我们可以得出O、D点的角度变化情况,即O点从7/2的π减小到2π,D点从3π减小到0。

现在我们已经知道了O、D点的起点位置,便可以将前面图上的相关信息补全。通过这些信息,我们可以得出O、D点在动画阶段中的角度(图中的progress取值范围为0 - 1)。只要让progress的值从0逐渐变化到1,O、D点就会逐渐从起点运动到终点,每次变化时绘制从O到D逆时针的弧,动画即可实现。

代码实现

我们的实现思路可以概括为:属性变化触发重绘。通过自定义CALayer的子类,并重写它的两个方法,能够实现这一思路。

可以参考needsDisplayForKey:的官方文档,其中提到:子类可以重写此方法,当指定属性的值发生变化时,如果图层需要重新显示,则返回YES。改变属性值的动画也会触发重新显示。

我们定义progress属性,并重写CALayer的相关方法(下面代码中的@dynamic progress;我们将在本文最后解释)。这样,当progress的值改变时,CALayer会标记自己为需要重绘。如果我们重写了drawInContext:方法,系统会在适当的时候调用该方法来重绘图层。

根据文档中的描述“Animations changing the value of the attribute also trigger redisplay.”,我们可以使用CA动画来修改progress的值,示例代码如下:

CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:@"progress"];
animation.fromValue = @(0);
animation.toValue = @(1);
animation.duration = 2.0;
animation.fillMode = kCAFillModeForwards;
animation.removedOnCompletion = NO;
[self.layer addAnimation:animation forKey:@"progressAnimation"];

第一句代码的作用是让动画结束时停留在动画结束时的状态。简单来说,动画执行时改变的是presentation Layer的值,model Layer的值不会变化,动画结束后会显示model Layer的值,由于model Layer的值没有变化,看上去就像是直接跳回了动画开始时的值,上述第一句代码就是将model Layer的值修改为动画结束时的值。这部分内容可以参考Core Animation Programming Guided的相关章节。

看到CABasicAnimation,大家可能会发现有很多属性可以设置。例如,将代码修改为如下形式:

CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:@"progress"];
animation.fromValue = @(0);
animation.toValue = @(1);
animation.duration = 2.0;
animation.repeatCount = HUGE_VALF;
[self.layer addAnimation:animation forKey:@"progressAnimation"];

修改后,动画会呈现出不同的效果,我们可以充分利用CA动画系统的特性。

动画流程明确后,接下来只需重写drawInContext:方法的绘制代码即可。前文已经明确了绘制思路和各节点的值,以下是具体代码,为了表达清晰,我声明了多个局部变量,大家可以对照前面的图进行理解。

- (void)drawInContext:(CGContextRef)ctx {
CGFloat radius = MIN(self.bounds.size.width, self.bounds.size.height) / 2.0;
CGPoint center = CGPointMake(CGRectGetMidX(self.bounds), CGRectGetMidY(self.bounds));
CGFloat startAngle = (7.0 / 2.0 * M_PI) - (3.0 / 2.0 * M_PI * self.progress);
CGFloat endAngle = 3.0 * M_PI - (3.0 * M_PI * self.progress);

UIBezierPath *path = [UIBezierPath bezierPathWithArcCenter:center radius:radius startAngle:startAngle endAngle:endAngle clockwise:NO];
CGContextAddPath(ctx, path.CGPath);
CGContextSetStrokeColorWithColor(ctx, [UIColor redColor].CGColor);
CGContextSetLineWidth(ctx, 2.0);
CGContextStrokePath(ctx);
}

至此,第一阶段代码的主要部分已完成。第一阶段的完整代码大家可以参考GitHub上这个项目的OneLoadingAnimationStep1目录。

@dynamic progress;的解释

由于我对property的了解还不够深入,关于@dynamic progress;的详细解释后续会补上。目前大家可以参考CALayer.h的相关注释。

作者信息

洞悉

洞悉

共发布了 3994 篇文章