Swift每日一练:重写UICountingLabel

今天Swift练习的是准备尝试把UICountingLabel这个Github Star数超过500的库用Swift重写一遍。

UICountingLabel源码解析

整个uICountingLabel的本质就是基于一个NSTimer来计算当前时间内应该显示什么值,

- (CGFloat)currentValue {

    if (self.progress >= self.totalTime) {
        return self.destinationValue;
    }

    CGFloat percent = self.progress / self.totalTime;
    CGFloat updateVal = [self.counter update:percent];
    return self.startingValue + (updateVal * (self.destinationValue - self.startingValue));
}

这个值通过不同的插值方式得到,这个插值方式的具体实现时通过self.counter的不同子类进行实现。

@implementation UILabelCounterLinear

-(CGFloat)update:(CGFloat)t
{
    return t;
}

@end

@implementation UILabelCounterEaseIn

-(CGFloat)update:(CGFloat)t
{
    return powf(t, kUILabelCounterRate);
}

@end

@implementation UILabelCounterEaseOut

-(CGFloat)update:(CGFloat)t{
    return 1.0-powf((1.0-t), kUILabelCounterRate);
}

@end

@implementation UILabelCounterEaseInOut

-(CGFloat) update: (CGFloat) t
{
    int sign =1;
    int r = (int) kUILabelCounterRate;
    if (r % 2 == 0)
        sign = -1;
    t *= 2;
    if (t < 1)
        return 0.5f * powf(t, kUILabelCounterRate);
    else
        return sign * 0.5f * (powf(t-2, kUILabelCounterRate) + sign * 2);
}

这里用子类化这么说是不严谨的,因为这其实就类似于Java或者C#是面向了接口编程了而已,因为这里的每个类都只是实现了update这个函数接口而已。

然后实现里相对比较直观,

-(void)countFrom:(CGFloat)startValue to:(CGFloat)endValue withDuration:(NSTimeInterval)duration {
    if (duration == 0.0) {
        // No animation
        [self setTextValue:endValue];
        [self runCompletionBlock];
        return;
    }

    self.lastUpdate = [NSDate timeIntervalSinceReferenceDate];

    switch(self.method)
    {
        case UILabelCountingMethodLinear:
            self.counter = [[UILabelCounterLinear alloc] init];
            break;
        case UILabelCountingMethodEaseIn:
            self.counter = [[UILabelCounterEaseIn alloc] init];
            break;
        case UILabelCountingMethodEaseOut:
            self.counter = [[UILabelCounterEaseOut alloc] init];
            break;
        case UILabelCountingMethodEaseInOut:
            self.counter = [[UILabelCounterEaseInOut alloc] init];
            break;
    }

    NSTimer *timer = [NSTimer timerWithTimeInterval:(1.0f/30.0f) target:self selector:@selector(updateValue:) userInfo:nil repeats:YES];
    [[NSRunLoop mainRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
    [[NSRunLoop mainRunLoop] addTimer:timer forMode:UITrackingRunLoopMode];
    self.timer = timer;
}

首先如果动画的时间是0,就默认直接回调。但是,我想说duration == 0.0,浮点数这么判断,真的没问题吗?

由于NSTimer无法得知确切得知道执行了多少,所以这里要记录上一步回调的lastUpdate。
根据不同的方法选择不同的Counter进行插值,然后创建NSTimer,这里我们要特别注意两句话:

[[NSRunLoop mainRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
[[NSRunLoop mainRunLoop] addTimer:timer forMode:UITrackingRunLoopMode];

这表示NSTimer默认加入的是RunLoop的Default Mode,但是Default Mode在进入Tracking Mode的时候(也就是当用户滑动的时候)会被阻塞,影响动画的执行。因此要特别加入TrackingMode。

但是我又想说了,NSRunLoopCommonModes难道不是包含UITrackingRunLoopMode吗?天呐,还是我理解的不对,重复添加的意义呢!!!!

然后在NSTimer的回调函数中,

NSTimeInterval now = [NSDate timeIntervalSinceReferenceDate];
self.progress += now - self.lastUpdate;
self.lastUpdate = now;

if (self.progress >= self.totalTime) {
    [self.timer invalidate];
    self.timer = nil;
    self.progress = self.totalTime;
}

[self setTextValue:[self currentValue]];

if (self.progress == self.totalTime) {
    [self runCompletionBlock];
}

就是计算当前的progress进度,更新相应的值而已,没有难度。

Swift 重写

先来效果:

首先我们来回忆下Objective-C版本中的一些逻辑,使用NSTimer动态根据进度更新时间。但是我们知道,NSTimer有一个很大的问题就是他的“刷新率”不够准确。如果NSTimer设置了每两秒更新一次,那么如果RunLoop中有个耗时的任务,就会讲这个更新任务推迟,用时间轴来理解:

0 -> 2s (NSTimer回调) -> 3s (耗时的任务) -> 2s (NSTimer回调)。

那么这个会导致什么问题呢?NSTimer的回调和屏幕刷新不是同步的。因此,在Swift版本的重写中,我采用了CADisplayLink来改写,它的好处我们同样用时间轴来理解:

0 -> 2s(CADisplayLink回调) -> 3s(耗时的任务) -> 1s(空闲) -> 2s(CADisplayLink回调)

可以看出,CADisplayLink始终是保持和屏幕刷新率一样。

这里强调一下,不论是NSTimer抑或是CADisplayLink,既然他们都是基于RunLoop的,就无法脱离被所处同一个RunLoop里的其他任务所影响的宿命,所以网上那些说CADisplayLink不会被阻塞的说法都是错误的。

Swift 面向接口编程

之前我们提过“子类化“这个说法是不准确的,因此,我们在Swift中,可以基于protocol-oriented进行编程。

let WZCountRate:Float = 3.0

protocol Interpolation {
    func update(val:Float) -> Float;
}

struct Linear:Interpolation {
    func update(val: Float) -> Float {
        return val
    }
}

struct EaseIn:Interpolation {
    func update(val: Float) -> Float {
        return powf(val, WZCountRate)
    }
}

struct EaseOut:Interpolation {
    func update(val: Float) -> Float {
        return 1 - powf((1 - val), WZCountRate)
    }
}

struct EaseInOut:Interpolation {
    func update(val: Float) -> Float {
        var sign:Float = 1
        let r = Int(WZCountRate)

        if (r % 2 == 0) {
           sign = -1
        }

        var t = val * 2
        if (t < 1) {
            return 0.5 * powf(t, WZCountRate)
        } else {
            return 0.5 * (powf(t - 2.0, WZCountRate) + sign * 2) * sign
        }
    }
}

可以看到,我们用四个Struct结构体遵从了Interpolation这个接口,实现了四种不同的插值方法。

别的方面没什么特别难的,

Access Control 对Selector Callback的影响

我们都知道,Swift对类引入了Access Control这一机制,默认情况下是internal的权限。由于CADisplayLink还是基于Selector设置回调的,如下:

let displayLink = CADisplayLink(
   target: self,
   selector: Selector("displayTick:")
)

当这个displayTick函数处于public或者internal权限的时候,没有任何问题。而当你想声明如

private func displayTick()

的时候,就会产生doesn’t recognize selector的runtime error。

原因如下:
基于Selector的回调方式还是采用了Objective-C传统的runtime 查函数表的方式,但是在Swift中声明为private的函数,对于Objective-C Runtime是不可见的。因此,如果你需要让私有函数可以被查询到,你需要添加@objc关键词。

@objc private func displayTick()

当然啦, 对于IBOutlets, IBActions 以及 Core Data 相关属性来说,默认是可以被Objective-C Runtime查询到的。

最后,附上项目地址