5

这个例子完全模仿了苹果的 UIActivityIndicatorView 控件,它显示了一个旋转的小菊花。这个控件使用起来是非常简单的,可以完全不需要你编写一行代码(使用 IB),也不需要任何图片!如果让你自己用 Core Graphics 实现这个控件,你会怎么做呢?

绘制外壳

class MockIndicator: UIView {var leafColor: UIColor = .whitevar hudColor: UIColor = UIColor(red: 170/255, green: 169/255, blue: 169/255, alpha: 0.5)override func draw(_ rect: CGRect) {// 1let shorterSide = min(rect.width, rect.height)// 2var frame = CGRect(x: (rect.width-shorterSide)/2, y:(rect.height-shorterSide)/2, width: shorterSide, height: shorterSide)// 3RectanglePainter.drawFillColor(frame, fillColor: hudColor, cornerRadius: 40/240*shorterSide)}
}
  1. 计算较短边,因为我们想绘制一个正方形的外壳。
  2. 计算外壳的 frame。如果初始化时传入的 rect 是一个长方形,我们需要将正方形外壳绘制在视图中心。
  3. 调用 RectanglePainter 绘制一个浅灰色的正方形外壳。

绘制叶片

        let context = UIGraphicsGetCurrentContext()// 1context?.saveGState()// 2context?.translateBy(x:rect.midX, y:rect.midY)// 3frame = CGRect(x: 0, y: -(0.25*shorterSide), width: 0.025*shorterSide, height: 0.1333*shorterSide)// 4RectanglePainter.drawFillColor(frame, fillColor: leafColor, cornerRadius: 0)// 5context?.restoreGState()
  1. 保存绘图状态。
  2. 我们需要围绕 hud 中心绘制一圈叶片(当然目前先绘制一片)。为了方便计算叶片的绘制位置,我们需要将 context 的坐标原点暂时移动到 hud 的中心。tranlateBy 方法用于移动坐标原点到指定位置。
  3. 计算叶片的 frame,注意此时坐标的计算方式已经变了,当前坐标的原点是视图中心。同时,叶片的坐标和宽高采用的都是相对数值,即都是按比例缩放的。
  4. 在制定位置绘制填充矩形充当菊花叶片。

绘制更多叶片

我们总共需要绘制 12 片叶片,叶片之间相差 30 度:

        // 1var h: CGFloat = 0var s: CGFloat = 0var b: CGFloat = 0var a: CGFloat = 0leafColor.getHue(&h, saturation: &s, brightness: &b, alpha: &a)for i in 0...11 {let context = UIGraphicsGetCurrentContext()// 2let angle = -CGFloat.pi/6*CGFloat(i)let brightness:CGFloat =  1-0.1*CGFloat(i%12)// 3let color = UIColor(hue: h, saturation: s, brightness: brightness, alpha: a)context?.saveGState()// 4context?.translateBy(x:rect.midX, y:rect.midY)// 5context?.rotate(by:angle)// 6frame = CGRect(x: 0, y: -(0.25*shorterSide), width: 0.025*shorterSide, height: 0.1333*shorterSide)// 7RectanglePainter.drawFillColor(frame, fillColor: color, cornerRadius: 0)context?.restoreGState()}
  1. 求取叶片颜色中的灰度、饱和度、亮度和透明度,因为我们准备对亮度进行运算。
  2. 角度计算,因为 12 个叶片的旋转角度是不同的,两两之间相差 30 度,转换为弧度。加上负号,是因为我们的绘制顺序是逆时针进行的。
  3. 改变叶片的颜色,将亮度值不断降低(逆时针)。
  4. 改变坐标原点。
  5. 逆时针旋转画布。每次循环都会在上一次的基础上多旋转 30 度。
  6. frame 保持不变,因为实际上画布在旋转了。如果画布不旋转,那我们就必须旋转图形了,但那个的计算要复杂许多。因此,我们采用旋转画布的方式。
  7. 绘制矩形。

旋转的花瓣

要让小菊花旋转,我们又要用定时器了。在 Indicator.h 中增加如下属性声明:

    private var tick:Int = 0				// 1private var timer: CADisplayLink?	// 2var isAnimating = false				// 3private var lastTime:CFTimeInterval = 0.0	// 4var secondPerLoop: Double = 1 // 5
  1. tick 假设我们旋转 1 周需要 12 次动画,那么 tick 就记录了当前进行到第几次。
  2. timer 定时器。
  3. isAnimating 记录动画的启动/停止状态。
  4. lastTime 是 CADisplayLink 定时器,它和一般的 Timer 不同,它初始化时不能设定触发间隔(它没有系统时钟信号),因此我们需要一个变量来保存上次触发函数的时间。
  5. secondPerLoop 小菊花每转一圈的时间,这里我们设置为 1,即每秒转一圈。

开启/关闭定时器,这和上一节没有太多区别了:

    func startAnimation() {if isAnimating {timer?.invalidate()}timer = CADisplayLink.init(target: self, selector: #selector(animate))timer?.add(to: RunLoop.current, forMode: .default)isAnimating = truelastTime = CACurrentMediaTime()}func stopAnimation() {if isAnimating {timer?.invalidate()isAnimating = falsetimer = nil}}

唯一需要注意的就是时钟启动时需要在 lastTime 中记录当前时间。

最重要的还是定时器回调函数:

    @objc private func animate() {guard let timer = timer else {return}// 1let period = secondPerLoop/12let currentTime = timer.timestamp// 2let elapsed = currentTime - lastTime// 3if elapsed > period {// 4tick += 1// 5if tick >= 12 {tick = 0}// 6setNeedsDisplay()// 7lastTime = currentTime}}
  1. 计算每转 1/12 圈(30 度)需要多少时间。
  2. 计算从上一次回调到现在过去了多少时间。
  3. 只有超过了 1/12 圈(30 度)需要的时间,我们才需要重新绘制。很显然,屏幕刷新一次的时间太短了,我们并不需要那么频繁地重绘图形。
  4. tick+1,将当前转到角度记录下来。
  5. 如果 tick 达到了满圈(360度),将角度归零,防止 tick 无限制累加下去,导致整数溢出。
  6. 重绘。这将导致 draw(_😃 方法被调用。
  7. 记录最新的 lastTime。

最终效果如图: