关于objective-c:打造高可用iOS进度条

69次阅读

共计 7261 个字符,预计需要花费 19 分钟才能阅读完成。

前言

做全屏的需要时,因为进度条会从半屏背景下的「根本不可能曝光」,变成全屏场景下「高频曝光」,所以须要打造一个丝滑、高可用的进度条,想当初我 Debug 到凌晨 4 点,就是为了解决暂停后进度条的动画问题。

明天把这个进度条的架构、设计逻辑和踩过的坑都整顿一下。

本文波及的代码已开源至 Github: 打造高可用进度条

接口介绍

BNCommonProgressBar.h

// 变更进度,animateWithDuration 是传入动画工夫
- (void)setValue:(CGFloat)value;
- (void)setValue:(CGFloat)value animateWithDuration:(NSTimeInterval)duration time:(NSTimeInterval)time;
- (void)setValue:(CGFloat)value animateWithDuration:(NSTimeInterval)duration completion:(void (^__nullable)(BOOL finished))completion;

// 重置所有状态,会将进度重置到 0
- (void)reset;

// 暂停动画
- (void)pauseAnimation;
// 复原动画
- (void)resumeAnimation;
// 清理动画状态,手动拖拽时先清理动画状态
- (void)removeProgressAnimation;

一、为什么 UISlider 不满足「高可用」的指标?

在论述 UISlider 不满足「高可用」指标之前,咱们先思考一下,满足什么样的条件的进度条,才能够算是「高可用」?

我想出四个指标:

    1. UI 可高度定制
    1. 晦涩的回调动画
    1. 可定制的响应范畴
    1. 响应手势,且无卡顿问题

其中 UISlider 可满足其中 3 和 4,因为 UISlider 是零碎提供的组件,「UI 可高度定制」这条必定不满足。

且 UISlider 对于动画的解决不够弱小,在视频播放的场景下,视频播放器会定时高频的回调视频播放进度,更新进度的动画要足够晦涩,但实际上应用 UISlider 的成果是上面这样的:

所以 UISlider 不满足 第 2 点:「晦涩的回调动画」,而视频号场景下,视频进度回调更新进度条进度是高曝光的场景,肯定要把这个动画做得足够晦涩。

在这样的背景下,放弃 UISlider,自定义进度条是惟一的抉择。

二、定制一份「高可用」进度条

Tips:BNCommonProgressBar是咱们定制的进度条的类名,首先先看一下 BNCommonProgressBar 实现的成果:

BNCommonProgressBar 设计与需要绝对应:

    1. 指标:UI 可高度定制 –> 计划:自定义 UI
    1. 指标:晦涩的回调动画 –> 计划:动画解决
    1. 指标:可定制的响应范畴 –> 计划:手势范畴解决
    1. 指标:响应手势,且无卡顿问题 –> 计划:拖拽手势解决, 卡顿问题解决

所以 BNCommonProgressBar 的设计也就分为 4 个模块。

(一)自定义 UI

BNCommonProgressBar初始化办法为:

- (instancetype)initWithFrame:(CGRect)frame
                    barHeight:(CGFloat)progressBarHeight
                    dotHeight:(CGFloat)dotHeight
                 defaultColor:(UIColor *)defaultColor
              inProgressColor:(UIColor *)inProgressColor
                    dragColor:(UIColor *)dragColor
                 cornerRadius:(CGFloat)cornerRadius
         progressBarIconImage:(UIImage *)progressBarIconImage
         enablePanProgressIcon:(BOOL)enablePanProgressIcon;

容许业务层配置进度条高度、进度圆点高度、默认色彩、处于进度拖拽时的色彩、是否容许拖拽等,相比 UISlider 有更高的自定义水平。

且如果这些接口不够应用,你能够间接在 BNCommonProgressBar 初始化办法中增加相应控件和组件,实现定制化,相对不会影响到 进度条动画 / 手势 等性能,实现性能隔离。

(二)回调进度解决

1. 问题剖析

为何触发暂停后,进度条不能立刻进行,而是滑动一段距离能力停下呢?

通过看进度条实现的代码,我发现了其中的端倪:

「进度条的地位」是通过「播放器的进度定时回调」来变更的

播放器大概每隔 0.25 秒会触发一次回调办法,通知进度条下个 0.25 秒应该挪动到哪个地位。

进度条为了实现进度变更的顺滑,采纳了 [UIView animationWithDuration:] 动画。

那么问题就来了:

如果在第 2 秒时,播放器回调通知了进度条下个 0.25 秒的地位,接着触发了 [UIView animationWithDuration:0.25] 的动画,

如果用户在 2.01 秒点击了暂停,如果这时不对动画做暂停的操作,进度条就会再挪动完剩下的 (0.25 – 0.01)秒的动画,也就呈现了暂停也滑动的体现。

解决这个问题最直观有两个计划:

计划一:减少播放器回调的频率

当频率足够高时,[UIView animationWithDuration:]的 duration 距离也会变小,那么暂停仍滑行的体现就会削弱

这个计划有两个问题:

  1. 减少播放器回调频率,只是削弱滑行的体现,但并没有真正解决滑行的问题。当同样的进度条援用到 iPad 上后,进度条会变长,那么问题仍会裸露
  2. 单纯减少播放器回调只是为了解决滑行问题,老本太高且没有必要。

计划二:暂停 CoreAnimation 进行中的动画

上面咱们就围绕着暂停 CoreAnimation 动画的计划,引入和补充一些对于 Layer 动画的知识点。

(1)实现过程

计划二有个直观的步骤:

  • a. 当视频暂停时,记录暂停那一刻 进度条的地位
  • b. 而后进行进度条的动画
  • c. 等到复原播放时,再从上次记录的地位从新复原动画。
a. 当视频暂停时,记录暂停那一刻 进度条的地位

首先问题是:应该记录进度条 view 的哪个属性呢?

能够间接记录 view.x 吗?

实际上是不行的,如果咱们将 [UIView animationWithDuration:]产生前 view.x 记录为 A,动画实现后 view.x 记录为 C,动画过程中记录为 B。

____I________I_______I___
  终点 A     暂停 B     起点 C 

你会发现,只有动画开始了,无论动画是否完结,你通过 view.x 拜访到的总是 C,而非动画过程暂停那一刻的地位 B。

甚至如果你对 view.layer.frame 进行 KVO 的监测,你会发现在动画变更过程中,KVO 并没有回调。

这是为什么呢?

CALayer 图层树

咱们都晓得,UIView 是对 CALayer 的一个封装,CALayer 类在概念上和 UIView 相似,同样也是一些被层级关系树治理的矩形块,同样也能够蕴含一些内容(像图片,文本或者背景色),治理子图层的地位。它们有一些办法和属性用来做动画和变换。和 UIView 最大的不同是 CALayer 不解决用户的交互。

CALayer 和 UIView 一样存在着一个层级树状构造, 称之为图层树(Layer Tree),也能够叫 模型树(Model Tree)。

这三种图层树有什么作用呢?说到有啥作用,就不得不提 Core Animation 外围动画了。因为这三个图层在外围动画中能力显示出它们的特点和用途。上面是官网文档的阐明:

  • 模型图层树 中的对象是应用程序与之交互的对象。此树中的对象是存储任何动画的目标值的模型对象。每当更改图层的属性时,都应用其中一个对象。
  • 示意图层树 中的对象蕴含任何正在运行的动画的航行中值。层树对象蕴含动画的目标值,而示意树中的对象反映屏幕上显示的以后值。您永远不应该批改此树中的对象。相同,您能够应用这些对象来读取以后动画值,兴许是为了从这些值开始创立新动画。
  • 渲染图层树 中的对象执行理论动画,并且是 Core Animation 的公有动画。

也就是说,图层树中咱们开发过程中能够理论用到的有两个属性:modelLayer(模型图层)、presentationLayer(体现图层)。

(渲染图层在 CALayer 没有提供间接的属性给咱们应用,是 core Animation 公有的)

什么是 modelLayer?

modelLayer 实际上就是承载着 layer 终态的各种数据,咱们开发过程中给 layer 的各种参数赋值,实际上也就是给 layer.modelLayer 赋值。

也即:view.layer == view.layer.modelLayer。

因为 modelLayer 是咱们在进行动画时设定好的最终值,所以在动画执行过程中,对 view.layer.frame 进行 KVO 监测,是不会有值的变更的。

什么是 presentationLayer?

presentationLayer 是咱们的配角,presentationLayer 指的也就是 屏幕上实时展现的图层的 layer,在 core animation 动画中,能够通过这个属性,获取动画过程中每个时刻动画图层的数据,这样如果在动画过程中须要做什么解决,就能够动静的获取 layer 上相干的数据了。

所以在执行 core animation 动画中,presentationLayer 是时刻变动的,但 modelLayer 是不会变的。

presentationLayer 有诸多用处,比方视频中的滚动弹幕如果是应用 layer 做动画的,当弹幕正在滚动时,你须要点击它以解决须要做的事件,这时候你就会须要 presentationLayer。再联合 hintTest 办法来做判断:

[self.layer.presentationLayer hitTest:point] // 判断是不是你点击的哪个弹幕
b. 进行进度条的动画

进行 core animation 动画有很多种形式,layer.removeAllAnimations 就是其中一种。

但 layer.removeAllAnimations 并不能实现咱们预期的成果,举例:

____I________I_______I___
  终点 A     暂停 B     起点 C 

在暂停 B 点,调用 removeAllAnimations,动画是会进行,但进度会间接跳到最终态 C,而非停在 B,所以咱们须要的是能够 pauseAnimation,而非 removeAnimation 的操作。

尽管 CALayer 没有提供 pauseAnimation 的接口,但咱们能够通过 CALayer 的工夫模型来实现 pause 的成果。

CAMediaTiming 协定

CAMediaTiming 协定的内容不多,头文件我列举于此。

@protocol CAMediaTiming

@property CFTimeInterval beginTime;
@property CFTimeInterval duration;
@property float speed;
@property CFTimeInterval timeOffset;
@property float repeatCount;
@property CFTimeInterval repeatDuration;
@property BOOL autoreverses;
@property(copy) CAMediaTimingFillMode fillMode;

@end

CALayer 实现了 CAMediaTiming 协定. CALayer 通过 CAMediaTiming 协定实现了一个有层级关系的工夫零碎.
(除了 CALayer,CAAnimation 也驳回了此协定, 用来实现动画的工夫零碎.)

beginTime

无论是图层还是动画, 都有一个工夫线 Timeline 的概念, 他们的 beginTime 是绝对于父级对象的开始工夫. 尽管苹果的文档中没有指明, 然而通过代码测试能够发现, 默认状况下所有的 CALayer 图层的工夫线都是统一的, 他们的 beginTime 都是 0, 相对工夫转换到以后 Layer 中的工夫大小就是相对工夫的大小. 所以对于图层而言, 尽管创立有先后, 然而他们的工夫线都是统一的(只有不被动去批改某个图层的 beginTime), 所以咱们能够设想成所有的图层默认都是从零碎重启后开始了他们的工夫线的计时.

然而动画的工夫线的状况就不同了, 当一个动画创立好, 被退出到某个 Layer 的时候, 会先被拷贝一份进去用于退出以后的图层, 在 CA 事务被提交的时候, 如果图层中的动画的 beginTime 为 0, 则 beginTime 会被设定为以后图层的以后工夫, 使得动画立刻开始. 如果你想某个间接退出图层的动画稍后执行, 能够通过手动设置这个动画的 beginTime, 但须要留神的是这个 beginTime 须要为 CACurrentMediaTime()+ 提早的秒数, 因为 beginTime 是指其父级对象的工夫线上的某个工夫, 这个时候动画的父级对象为退出的这个图层, 图层以后的工夫其实为[layer convertTime:CACurrentMediaTime() fromLayer:nil], 其实就等于 CACurrentMediaTime(), 那么再在这个 layer 的工夫线上往后提早肯定的秒数便失去下面的那个后果.

timeOffset

这个 timeOffset 可能是这几个属性中比拟难了解的一个, 官网的文档也没有讲的很分明. local time 也分成两种:一种是 active local time 一种是 basic local time. timeOffset 则是 active local time 的偏移量.
你将一个动画看作一个环,timeOffset 扭转的其实是动画在环内的终点, 比方一个 duration 为 5 秒的动画, 将 timeOffset 设置为 2(或者 7, 模 5 为 2), 那么动画的运行则是从原来的 2 秒开始到 5 秒, 接着再 0 秒到 2 秒, 实现一次动画.

speed

speed 属性用于设置以后对象的工夫流绝对于父级对象工夫流的流逝速度, 比方一个动画 beginTime 是 0, 然而 speed 是 2, 那么这个动画的 1 秒处相当于父级对象工夫流中的 2 秒处. speed 越大则阐明工夫流逝速度越快, 那动画也就越快. 比方一个 speed 为 2 的 layer 其所有的父辈的 speed 都是 1, 它有一个 subLayer,speed 也为 2, 那么一个 8 秒的动画在这个运行于这个 subLayer 只需 2 秒(8 / (2 * 2)). 所以 speed 有叠加的成果.

有下面三个属性,咱们就能够实现 pause 和 resume 的操作,相干代码如下:

#pragma mark 暂停和复原 CALayer 的动画
- (void)pauseLayer:(CALayer *)layer {CFTimeInterval pausedTime = [layer convertTime:CACurrentMediaTime() fromLayer:nil];

    // 让 CALayer 的工夫进行走动
    layer.speed = 0.0;
    // 让 CALayer 的工夫停留在 pausedTime 这个时刻
    layer.timeOffset = pausedTime;
}

- (void)resumeLayer:(CALayer *)layer {
    CFTimeInterval pausedTime = layer.timeOffset;
    // 1. 让 CALayer 的工夫持续行走
    layer.speed = 1.0;
    // 2. 勾销上次记录的停留时刻
    layer.timeOffset = 0.0;
    // 3. 勾销上次设置的工夫
    layer.beginTime = 0.0;
    // 4. 计算暂停的工夫(这里也能够用 CACurrentMediaTime()-pausedTime)
    CFTimeInterval timeSincePause = [layer convertTime:CACurrentMediaTime() fromLayer:nil] - pausedTime;
    // 5. 设置绝对于父坐标系的开始工夫(往后退 timeSincePause)
    layer.beginTime = timeSincePause;
}

下面办法中应用到的 CACurrentMediaTime(),也就是所谓的 马赫工夫,它是 CoreAnimation 上的一个全局工夫的概念。

马赫工夫在设施上所有过程都是全局的 – 然而在不同设施上并不是全局的 – 不过这曾经足够对动画的参考点提供便当了。

这个函数返回的值其实无关紧要(它返回了设施自从上次启动后的秒数,并不是你所关怀的),它实在的作用在于对动画的工夫测量提供了一个相对值。留神当设施休眠的时候马赫工夫会暂停,也就是所有的 CAAnimations(基于马赫工夫)同样也会暂停。

(三)手势范畴解决

BNCommonProgressBar 中针对 hitTest:withEvent: 办法进行解决,能够将响应范畴应用宏进行界定,也可由业务层传入,这里默认上下左右减少 BNResponseWidHeight/2 的响应范畴。

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {CGRect respRect = CGRectMake(- BNResponseWidHeight/ 2, - BNResponseWidHeight / 2, self.width + BNResponseWidHeight, self.height + BNResponseWidHeight);
    if (CGRectContainsPoint(respRect, point)) {return self;}
    return [super hitTest:point withEvent:event];
}

(四)拖拽手势解决,卡顿问题解决

拖拽手势解决的代码在onPanProgressIcon:


** 这个公众号会继续更新技术计划、关注业内技术动向,关注一下老本不高,错过干货损失不小。
↓↓↓**

正文完
 0