CALayer的探究应用

先上本文讲述的demo效果图

这几天博主在看kitten yang的A GUIDE TO IOS ANIMATION,作者对动画的使用令我感触很深(同为同龄人实在感到惭愧),于是决定重新学习一次layer。

coreAnimation作为iOS最重要的框架之一,CALayer的重要性毋庸置疑,本文将从上图的demo讲起,我会分成常规用法跟自己思考实现的用法来实现,以此来更加深入的学习layer。

ps:本文不包括CALayer的属性讲解以及使用。如有需要,请自行百度学习

进度条

常规做法

如上图所示,进度条并不是单纯的线性增长,在50%之前,每一次进度增加,进度条就会在y轴上面偏移一段距离,直到增长到一半进度的时候偏移位置达到顶点,然后随着进度继续增加,y轴的偏移越来越小,直到变回一条直线。

从实现角度而言,使用CAShapeLayer然后在每次进度改变的时候更新其path值就能够实现。如果使用CAShapeLayer的方式,我们需要创建两个实例对象,一个放在下面作为进度条背景,另一个在上面随着进度改变而改变。图示如下:

每次进度发生改变的时候,我们都要根据当前进度计算出进度坐标位置,然后更新两个图层的path,代码如下:

- (void)updatePath
{
    UIBezierPath * path = [UIBezierPath bezierPath];
    [path moveToPoint: CGPointMake(25, 150)];
    [path addLineToPoint: CGPointMake((CGRectGetWidth([UIScreen mainScreen].bounds) - 50) * _progress + 25, 150 + (25.f * (1 - fabs(_progress - 0.5) * 2)))];
    [path addLineToPoint: CGPointMake(CGRectGetWidth([UIScreen mainScreen].bounds) - 25, 150)];
    self.background.path = path.CGPath;
    self.top.path = path.CGPath;
    self.top.strokeEnd = _progress;
}

事实上,使用这种方式实现进度效果的时候,进度会比直接在当前上下文绘制的响应上要慢上几帧,即是我们肉眼可以看到这种延时更新的效果,是不利于用户体验的。其次,我们需要额外创建一个背景图层,在内存上有了额外的花销。

自定义layer

这小节我们要通过自定义CALayer的子类来实现上面的进度条效果,我们需要对外开放progress属性。每次这个值发生改变的时候我们要调用[self setNeedsDisplay]来重新绘制进度条

@property(nonatomic, assign) CGFloat progress;

重写setter方法,检测进度值范围以及重新绘制进度条

- (void)setProgress: (CGFloat)progress
{
    _progress = MIN(1.f, MAX(0.f, progress));
    [self setNeedsDisplay];
}

重新回顾一下进度条,我们可以把进度条分成两条线,分别是绿色的已完成进度条和灰色的进度条。根据进度条的不同,分为<0.5, =0.5, >0.5三种状态:

从上图可知,在进度达到一半的时候,我们的进度条在Y轴上的偏移量达到最大值。因此,我们应当定义一个最大偏移值MAX_OFFSET

define MAX_OFFSET 25.f

另一方面,当前进度条的y轴偏移量是根据进度按比例进行偏移的。在我们改变进度_progress的时候,重新绘制进度条。下面是绿色进度条的绘制

- (void)drawInContext: (CGContextRef)ctx
{
    CGFloat offsetX = _origin.x + MAX_LENGTH * _progress;
    CGFloat offsetY = _origin.y + _maxOffset * (1 - fabs((_progress - 0.5f) * 2));

    CGMutablePathRef mPath = CGPathCreateMutable();
    CGPathMoveToPoint(mPath, NULL, _origin.x, _origin.y);
    CGPathAddLineToPoint(mPath, NULL, offsetX, offsetY);

    CGContextAddPath(ctx, mPath);
    CGContextSetStrokeColorWithColor(ctx, [UIColor greenColor].CGColor);
    CGContextSetLineWidth(ctx, 5.f);
    CGContextSetLineCap(ctx, kCGLineCapRound);
    CGContextStrokePath(ctx);
    CGPathRelease(mPath);
}

ps: 这里存在一个很重要的问题,自定义的layer必须加在我们自定义的view上面,才能实现drawInContext:方法进行不断的重绘。关于coreGraphics相关方法的更多使用,请参考这篇文章

第二部分的灰色线条基于当前偏移的坐标为起点进行绘制,在这里有两个小陷阱:

  • 不熟练的开发者很容易直接把绘制灰色线条的代码放在上面这段代码的后面。这样会导致灰色线条在绿色线条后面绘制而将绿色线条遮住了一部分使得绿色线条端末非圆形
  • 没有对_progress的值进行判断。当_progress0时,上面的代码也会在线条左侧生成一个绿色小圆点,这是不准确的。

因此,我们在确定好当前进度对应的偏移坐标时,应该直接绘制灰色线条,再绘制绿色进度条。在绘制绿色线条前应当对_progress进行一次判断

- (void)drawInContext: (CGContextRef)ctx
{
    CGFloat offsetX = _origin.x + MAX_LENGTH * _progress;
    CGFloat offsetY = _origin.y + _maxOffset * (1 - fabs((_progress - 0.5f) * 2));

    CGMutablePathRef mPath = CGPathCreateMutable();
    CGPathMoveToPoint(mPath, NULL, offsetX, offsetY);
    CGPathAddLineToPoint(mPath, NULL, _origin.x + MAX_LENGTH, _origin.y);

    CGContextAddPath(ctx, mPath);
    CGContextSetStrokeColorWithColor(ctx, [UIColor lightGrayColor].CGColor);
    CGContextSetLineWidth(ctx, 5.f);
    CGContextSetLineCap(ctx, kCGLineCapRound);
    CGContextStrokePath(ctx);
    CGPathRelease(mPath);

    if (_progress != 0.f) {
        mPath = CGPathCreateMutable();
        CGPathMoveToPoint(mPath, NULL, _origin.x, _origin.y);
        CGPathAddLineToPoint(mPath, NULL, offsetX, offsetY);

        CGContextAddPath(ctx, mPath);
        CGContextSetStrokeColorWithColor(ctx, [UIColor greenColor].CGColor);
        CGContextSetLineWidth(ctx, 5.f);
        CGContextSetLineCap(ctx, kCGLineCapRound);
        CGContextStrokePath(ctx);
        CGPathRelease(mPath);
    }
}

这时候在controller里面加上一个UISlider拖拉来控制你的进度条进度,看看是不是想要的效果完成了。

扩展

上面我们在实现绘制的时候,对填充色彩颜色是写死的,这样不利于代码扩展。回顾CAShapeLayer,在继承CALayer的基础上添加了fillColor、strokeColor等类似属性,我们可以通过添加类似的成员属性来完成封装,这里我们需要为进度条添加两个属性,分别表示进度条颜色跟背景颜色

@property(nonatomic, assign) CGColorRef backgroundColor;
@property(nonatomic, assign) CGColorRef strokeColor;

我们在设置颜色的时候直接传入color.CGColor就可以完成赋值了,我们把上面的设置颜色代码分别改成下面所示后重新运行

CGContextSetStrokeColorWithColor(ctx, _backgroundColor);
CGContextSetStrokeColorWithColor(ctx, _strokeColor);

有的朋友们会发现一个坑爹的事情,崩溃了,出现了EXC_BAD_ACCESS错误——如果你使用系统提供的[UIColor xxxColor].CGColor,那么这里不会出问题。

这是因为我们增加的两个属性为assign类型,在我们使用这个color的时候,它早就被释放了。由这里我们可以看到两件事情:

  • CAShapeLayer会对非对象且属于coreGraphics的属性进行桥接或者引用操作
  • [UIColor xxxColor]方法返回的对象应该是全局或者静态对象。为了节省内存消耗,应该是使用懒加载方式。有必要的情况下,可以不调用这些方法来实现优化内存的效果

因此,我们应该重写这两个属性的setter方法来实现引用(欢迎来到MRC)

- (void)setStrokeColor: (CGColorRef)strokeColor
{
    CGColorRelease(_strokeColor);
    _strokeColor = strokeColor;
    CGColorRetain(_strokeColor);
    [self setNeedsDisplay];
}

除此之外,CAShapeLayer还有一个有趣的属性strokeEnd,这个属性决定了整个图层有多少部分需要被渲染的。想查看这个属性的看官们可以在最开始的常规代码中为layer设置这个属性,然后你会发现这时候不管我们的progress设置为多少,进度条的绿色部分总是等同于strokeEnd。效果如下图所示

可以看到,基于strokeEnd进行绘制的时候,界面的绘制难度更加复杂了。但是我们同样可以把这个拆分,分为两种情况

1、strokeEnd>progress

这个情况对应图中上面两个图,当然,在progress=1progress=0的状态是一样的。可以看到,当progress不为零的时候,进度条分为三部分:

  • 偏移点左侧的绿色线条
  • 右侧多出的绿色线条
  • 最后的灰色线条

交接点的y坐标应当是由strokeEnd超出progress的百分比部分除以当前右侧总长度占线条总长度的百分比,如下图所示

因此我们需要判断两个坐标点,其中偏移点按照上面代码一样根据progress得出,计算背景色和进度颜色交接点的代码如下:

CGFloat contactX = _origin.x + MAX_LENGTH * _strokeEnd;
CGFloat contactY = _origin.y + (offsetY - _origin.y) * ((1 - (_strokeEnd - _progress) / (1 - _progress)));

2、strokeEnd<=progress

这时候就对应下面的两张图了,同样的,我们可以把进度条拆分成三部分:

  • 最左侧的绿色进度条
  • 处于进度条和偏移点中间的背景颜色条
  • 右侧的背景颜色条

按照上面的图解方式进行分析,相当于把右侧的位置信息放到了左侧,我们可以轻易的得出颜色交接点坐标的计算方式

CGFloat contactX = _origin.x + MAX_LENGTH * _strokeEnd;
CGFloat contactY = (offsetY - _origin.y) * (_progress == 0 ?: _strokeEnd / _progress) + _origin.y;

有了上面的解析计算,drawInContext的代码如下

- (void)drawInContext: (CGContextRef)ctx
{
    CGFloat offsetX = _origin.x + MAX_LENGTH * _progress;
    CGFloat offsetY = _origin.y + _maxOffset * (1 - fabs(_progress - 0.5f) * 2);
    CGFloat contactX = 25.f + MAX_LENGTH * _strokeEnd;
    CGFloat contactY = _origin.y + _maxOffset * (1 - fabs(_strokeEnd - 0.5f) * 2);

    CGRect textRect = CGRectOffset(_textRect, MAX_LENGTH * _progress, _maxOffset * (1 - fabs(_progress - 0.5f) * 2));
    if (_report) {
        _report((NSUInteger)(_progress * 100), textRect, _strokeColor);
    }
    CGMutablePathRef linePath = CGPathCreateMutable();

    //绘制背景线条
    if (_strokeEnd > _progress) {
        CGFloat scale =  _progress == 0 ?: (1 - (_strokeEnd - _progress) / (1 - _progress));
        contactY = _origin.y + (offsetY - _origin.y) * scale;
        CGPathMoveToPoint(linePath, NULL, contactX, contactY);
    } else {
        CGFloat scale = _progress == 0 ?: _strokeEnd / _progress;
        contactY = (offsetY - _origin.y) * scale + _origin.y;
        CGPathMoveToPoint(linePath, NULL, contactX, contactY);
        CGPathAddLineToPoint(linePath, NULL, offsetX, offsetY);
    }
    CGPathAddLineToPoint(linePath, NULL, _origin.x + MAX_LENGTH, _origin.y);
    [self setPath: linePath onContext: ctx color: [UIColor colorWithRed: 204/255.f green: 204/255.f blue: 204/255.f alpha: 1.f].CGColor];

    CGPathRelease(linePath);
    linePath = CGPathCreateMutable();

    //绘制进度线条
    if (_progress != 0.f) {
        CGPathMoveToPoint(linePath, NULL, _origin.x, _origin.y);
        if (_strokeEnd > _progress) { CGPathAddLineToPoint(linePath, NULL, offsetX, offsetY); }
            CGPathAddLineToPoint(linePath, NULL, contactX, contactY);
        } else {
            if (_strokeEnd != 1.f && _strokeEnd != 0.f) {
                CGPathMoveToPoint(linePath, NULL, _origin.x, _origin.y);
            CGPathAddLineToPoint(linePath, NULL, contactX, contactY);
        }
    }
    [self setPath: linePath onContext: ctx color: [UIColor colorWithRed: 66/255.f green: 1.f blue: 66/255.f alpha: 1.f].CGColor];
    CGPathRelease(linePath);
}

我们把添加CGPathRef以及设置线条颜色、大小等参数的代码封装成setPath: onContext: color方法,以此来减少代码量。

coreAnimation以及coreGraphics作为最核心的框架之一,有很多值得我们去探索的特性,这些特性是怎么实现的对我们来说是一个迷,但是我们可以尝试去探索这些特性。

本文demo: demo 转载请注明本文地址及作者

PREVIOUS侧滑界面的小实验
NEXT创建个人博客详细过程