久违了,经典的iOS动画组合拳

其实本人开发iOS已经有些年头了,之前一直在论坛里面潜水,今年也开始抽些时间写一些blog,整理一些零碎的东西,朝花夕拾,当作对过去的一些总结与分享。
今天要总结的是一个经典的iOS动画组合拳,是一个基于coreAnimation、Timer、@dynamic这三者形成的一个动画组合拳。在开始讲解之前,让我们先来看一下今天要实现的一个动画效果,代码例子已经放到github上了,请自取

从效果图中,我们可以看出有5个扇形(slice)片,从顶部一个很小的地方慢慢旋转到各自的指定位置,而且他们的动画是连贯的,而且旋转的动画是开始慢,再加速,再慢下来的(EaseInEaseOut)。
那么我们如何做到这一点呢?


接下来我们一步一步来讲解整个实现的过程:

1、我们需要五个扇形图层(SliceLayer),这里我们可以写一个继承自CAShapeLayer的类

#import <QuartzCore/QuartzCore.h>
@interface MYSliceLayer : CAShapeLayer
@property (nonatomic, assign) CGFloat sliceAngle;
+ (id)layerWithColor:(CGColorRef)fillColor;
@end

其中sliceAngle是每个扇形的弧度。

2、让我们来看看这些扇形的.m文件是如何实现的

#import "MYSliceLayer.h"
@implementation MYSliceLayer
@dynamic sliceAngle;
+ (id)layerWithColor:(CGColorRef)fillColor
{
    MYSliceLayer *sliceLayer = [MYSliceLayer layer];
    [sliceLayer setFillColor:fillColor];
    [sliceLayer setStrokeColor:[UIColor colorWithWhite:0.8 alpha:1].CGColor];
    [sliceLayer setLineWidth:0.5f];
    [sliceLayer setContentsScale:[[UIScreen mainScreen] scale]];    
    return sliceLayer;
}
@end

我们注意到sliceAngle的实现并不是我们常用的@synthesize,而是@dynamic,这一点很重要,待会我会讲会为什么这会很重要。

3、好的,有了扇形图片,我们要做的就是把这些扇形放到视图上面,并把它们都绘制出来。

CGPathRef CGPathCreateArc(CGPoint center, CGFloat radius, CGFloat startAngle, CGFloat endAngle) {
    CGMutablePathRef path = CGPathCreateMutable();
    CGPathMoveToPoint(path, NULL, center.x, center.y);
    CGPathAddArc(path, NULL, center.x, center.y, radius, startAngle, endAngle, false);
    CGPathCloseSubpath(path);
    return path;
}

我们可以这样来生成一个扇形路径(path),并把它赋值给我们的sliceLayer就可以了,代码是这样的

CGPathRef path = CGPathCreateArc(center, radius, startAngle, endAngle);
[sliceLayer setPath:path];
CFRelease(path);

完成这些以后,我们今天要讲的重点来了,如何实现这样的动画呢?让我们再来思考一下这个动画的整个过程,一开始,这些扇形他们的弧度为0,都上视图的上方,然后弧度慢慢变大,并且他们的位置也在跟着变化,然后一点一点地移动到了其应该存在的位置,并具有正确的弧度。
似乎这一切都是那么的一气呵成。接下来就让我们走进那个经典的动画组合拳:
coreAnimation、Timer、@dynamic
首先,我们要计算出每一个扇形应该有的弧度(这个根据各自的业务灵活处理),[sliceLayer setSliceAngle:endAngle];调用这一句。值得注意的是SliceAngle这个属性要设置为@dynamic。同时,把扇形的delegate设置为viewController(或者是自定义的view)自己。代码如下

endAngle += [[values.anglesArray objectAtIndex:currentIndex] doubleValue];
MYSliceLayer *sliceLayer = [self insertSliceAtIndex:currentIndex values:values startAngle:startAngle endAngle:endAngle];
[sliceLayer setSliceAngle:endAngle];
[sliceLayer setDelegate:self];

同时ViewController要实现Layer的动画delegate

#pragma mark - Layer Animation Delegate

- (id <CAAction>)actionForLayer:(CALayer *)layer forKey:(NSString *)event
{
    if ([@"sliceAngle" isEqualToString:event]) {
        CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:@"sliceAngle"];
        [animation setFromValue:[NSNumber numberWithDouble:initAngle]];
        [animation setDelegate:self];
        animation.duration = 1;
        [animation setTimingFunction:[CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]];
        return animation;
    } else {
        return nil;
    }
}

这里有几个关键点要注意,首先是动画的duration,另外一个是TimingFunction模式的选定,例如我们这里选择了的EaseInEaseOut,最后一个是记得为这里的animation设置delegate

接下来,我们要实现coreanimation的delegate

- (void)animationDidStart:(CAAnimation *)animation
{
    [_displayLink setPaused:NO];
    [_animations addObject:animation];
}

- (void)animationDidStop:(CAAnimation *)animation finished:(BOOL)animationCompleted
{
    [_animations removeObject:animation];
    if ([_animations count] == 0) {
        [_displayLink setPaused:YES];
    }
}

在animationDidStart里面我们开启了一个Timer,这个Timer是在viewController(或者是View)的初始化的时候创建的,代码如下

    _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(updateTimerFired:)];
    [_displayLink setPaused:YES];
    [_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];

这里使用的是CADisplayLink这个Timer,他与NSTimer之间有一些不同之处,大家可以参阅google,另外一个要注意的是runloop的选定,建议使用NSRunLoopCommonModes这个模式,这样在手指接触屏幕时,仍然能够进行绘制。而NSDefaultRunLoopMode却不行。

最后一个,也是最重要的一点,Timer的执行函数
让我们来看一下代码:

- (void)updateTimerFired:(CADisplayLink *)displayLink
{
    MYPieLayer *parentLayer = (MYPieLayer *) [self layer];
    NSArray *pieLayers = [[parentLayer sliceLayers] sublayers];
    NSArray *labelLayers = [[parentLayer labelLayers] sublayers];

    CGPoint center = _center;
    CGFloat radius = _radius;

    [CATransaction setDisableActions:YES];

    NSUInteger index = 0;
    for (MYPieLayer *currentPieLayer in pieLayers) {
        CGFloat interpolatedStartAngle = MYLookupPreviousLayerAngle(pieLayers, index, initAngle);
        MYSliceLayer *presentationLayer = (MYSliceLayer *) [currentPieLayer presentationLayer];
        CGFloat interpolatedEndAngle = [presentationLayer sliceAngle];

        MYUpdateLayers(pieLayers, labelLayers, index, center, radius, interpolatedStartAngle, interpolatedEndAngle);
        ++index;
    }
    [CATransaction setDisableActions:NO];
}

这里呢,我们每隔1/60秒就去获取每个扇形(Slice)的弧度变化情况,注意这里要使用扇形的presentationLayer属性才能获取,因为执行动画的时候会为每个layer生成一个presentationLayer,动画是在presentationLayer上执行的。我们根据这个弧度,来重新绘制每个扇形的位置与弧度(其实就是重新设置一下每个扇形的path)。

这里要注意的一点是要先禁用掉隐式动画,这一句[CATransaction setDisableActions:YES];不然一会在执行整个动画的时候会出现阴影或者抖动。


好的,到这里整个动画的过程就讲完了,让我们来总结一下,到底发生了什么。

首先为每个扇形的动态属性sliceAngle(这个名称可以随便取)设置一个值,这样他就要求你提供一个coreAnimation(即函数actionForLayer:forKey:),来供其执行一个动画。
于是我们就提供一个自定义的动画,这个动画其实什么动画效果也没有,唯一的事情是他会把sliceAngle这个属性从FromValue动态地变化为ToValue(即我们自己设定的值)。
然后我们通过一个Timer来实时地捕获这个sliceAngle属性值的变化情况,拿到sliceAngle变化的值,来实时地刷新他的扇形界面,即每次赋值一个新的path.这样我们就可以用自定义的coreAnimation中的TimingFunction属性来实现动画的EaseIn等特性,这比简单地使用Timer来绘制要生动得多,也要高明得多。


更多拓展

这套经典的动画组合拳,其实应用场景相当广泛,还记得那些语音波浪线吗,还记得QQ中消息Tab刷新时的水银流动效果吗,这些都可以用这套组合拳来实现。

2015-07-12 08:56311