iOS Swift5从0到1系列(九):自定义组件(一):圆形进度条(动画+倒计时)

一、前言

本组件将暂时应用于广告页。

一般来说,在广告页一定会有一个倒计时的 View 控件,low 一点的呢,就是一个类似于短信倒计时的『 XX秒,点击跳过』,好一点的呢,就是一个自带进度条动画的倒计时控件,我们先来看看最终的效果:

倒计时效果.gif

本篇,我们将开始我们的第一个组件开发:一个简单的倒计时动画控件。

二、组件化开发

2.1、为什么要组件化开发

无论一个规模多大或是多小的项目,主流通用做法都是将功能单一的代码单独放在一起,然而,怎么放是我们要面对的问题:

  • 一般偷懒的做法,就是直接在工程项目中,创建一个Group,然后将代码放入其中;这种做法简单、快捷、暴力,但带来的后果是,如果其它项目要用,就只能 copy 源代码,且之后如果有所修改,那多个项目就要维护多套代码;
  • 组件化开发:类似于第三方的开源代码,我们需要制作库,而制作成库又有两种方式:

我们之前已经有一个系列专门介绍过 CocoaPods 和 SPM,没看过的小伙伴可以去了解一下。本篇及后续的组件,我们都将采用库的方式来制作并提供,这样的好处在于,多个项目间,只有一份源码,只需要通过配置来引用;后续升级、维护都较为方便。

本篇制作的库,不仅要支持 CocoaPods,同时也支持 SPM,这是我们之前没有分享到的,但大家不要怕,很简单,我们可以回忆一下,我之前分别给出的基于 CocoaPods 的库制作 demo 和 基于 SPM 的库制作 demo,它俩的区别在于:

CocoaPods-VS-SPM.png

我们可以看到:

  • 配置文件不同;
  • 源码目录不同;

配置文件是各家规定的,这个没法去改;但是,源码目录,我们可以统一,统一后通过配置文件来配置即可。

2.2、同时支持 CocoaPods & SPM

2.2.1、手动创建

抛开表面看实质,再了解了各家区别后,我们都不需要通过什么命令行,工具向导来创建,直接手动创建如下:

## 创建 lib 目录
$ mkdir CircleProgressView && cd CircleProgressView
## 创建 SPM 配置文件
$ touch Package.swift
## 创建 CocoaPods 配置文件
$ touch CircleProgressView.podspec
## 创建 源码目录
$ mkdir Source

2.2.2、配置 SPM

// swift-tools-version:5.3
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription

let package = Package(
    // 库名
    name: "CircleProgressView",
    
    // 支持的平台列表
    platforms: [.iOS(.v10)],
    
    // 对外支持的功能
    products: [
        .library(name: "CircleProgressView", targets: ["CircleProgressView"]),
    ],

    // 指定源码目录(如果不指定,默认目录名是:Sources/*.*)
    targets: [
        .target(name: "CircleProgressView", dependencies: [], path: "Source")
    ],
    
    // 支持的 swift 版本,从 5 开始
    swiftLanguageVersions: [.v5]
)

如何本地化引用 SPM 不需要我说了吧?如果忘记了,去看一下《iOS包依赖管理工具(五):Swift Package Manager(SPM)自定义篇》,找找感觉吧。至于测试?咱就免了吧。

2.2.3、配置 CocoaPods

Pod::Spec.new do |s|
  s.name             = 'CircleProgressView'
  s.version          = '1.0.0'
  s.summary          = 'A View Component for CountDown'

  s.description      = <<-DESC
  TODO: Add long description of the pod here.
  DESC

  s.homepage         = 'https://github.com/qingye/ios-swift5-demo/tree/master/CircleProgressView'
  s.license          = { :type => 'MIT', :file => 'LICENSE' }
  s.author           = { '青叶小小' => '24854015@qq.com' }
  s.source           = { :git => 'https://github.com/qingye/ios-swift5-demo/tree/master/CircleProgressView.git', :tag => s.version.to_s }

  s.ios.deployment_target = '10.0'
  s.source_files = 'Source/*.swift'

end

注:该代码地址https://github.com/qingye/ios-swift5-demo/tree/master/CircleProgressView是对的,只是偷懒,没有单独分库,所以 pod 和 spm 都拉不到,但大家实际开发中肯定是要单独成库的。

如何基本 pod 使用本地库,大家可以去看一下《iOS包依赖管理工具(三):创建自己的 Pod 库》

以上两种方式,我都自测过,没有任何问题,如果大家使用过程中有问题,欢迎及时提出,我好改正,谢谢!

三、实现 CircleProgressView

本篇将采用 SPM 本地方式来使用。

3.1、UIView 与 CALayer

开始之前,我们要先来简单了解下 CALayer 这么个东东(本篇不展开,仅简单介绍)。

首先,每一个 UIView 都有一个 CALayer,那么它俩的关系呢,在官方文档中有这么一段话:

Layers provide infrastructure for your views. Specifically, layers make it easier and more efficient to draw and animate the contents of views and maintain high frame rates while doing so. However, there are many things that layers do not do. Layers do not handle events, draw content, participate in the responder chain, or do many other things

简单来说就是:

  • CALayer 是一个画布(你可以认识就是一张白纸),它负责绘制和动画,维持很高的帧率(一般 fps:60/s);
  • UIView 呢负责处理事件、内容、参与响应链,以及其它非 CALayer 的事;

通常,我们设置 bounds、radius、shadow 等这些都与绘制相关的事情都是交由 CALayer 来完成,UIView 只是实现了 CALayer 的 delegate 而已。

每个 UIView 都有一个默认的 CALayer,我们称之为 root layer,那么类似 view.addSubview,layer 也有 addSublayer;即一个 UIView 可以含有多个 layers,层叠的顺序同样是后加的在上面,我们通过一系列的设置,如:大小、透明度等完成我们想要的层级叠加。

OK!基础知识大家应该了解了,我们就正式进入我们的圆形进度条开发!

3.2、圆(角度 vs 弧度)

如前言中所见到的倒计时控件,它是一个圆,所以,我们需要先了解在 iOS 中,圆的起始点,才能正确做图:

圆(角度vs弧度) (2).png

与我们常识不一样,在 iOS(以及其它 Android / JavaScript 语言中,准确的说,计算机的世界中),起点是从 X 轴开始,到 X 轴结束,假设圆的半径是 r ,那么,起点坐标就是(r, 0)。

从我们最早开始学习 C / Win32 开始,图形 API 就提供了最基础的:点、线、矩形、椭圆(圆是椭圆的特例)等;同时,还提供了:Brush(刷子,即填充色)、Stroke(线条颜色);当然还有宽度、长度等 API。因此,任何其它高级图形 API ,都是这一系列的组合(我们应当有这种思维,不仅是图形,例如:接口、类等,即无论多么高级的 API,都是通过类似积木似的组合而成的)。

在 iOS 中,系统提供了一个 API 叫作:UIBezierPath(贝塞尔曲线,图形学上用处很广),它让我们能够圈定一个路径(Path),这个路径可以是闭合,也可以是开合。我们作圆就需要用到该API:

@available(iOS 3.2, *)
open class UIBezierPath : NSObject, NSCopying, NSSecureCoding {
    ......
    // 圆角图形:矩形大小 & 圆角大小
    // 当 width = height && radius = 1/2 * width 时就是 圆
    public convenience init(roundedRect rect: CGRect, cornerRadius: CGFloat)
    // 圆孤:矩形大小 & 圆角大小 & 起始弧度 & 结束弧度 & 时钟方向(true 为顺时针)
    public convenience init(arcCenter center: CGPoint, radius: CGFloat, startAngle: CGFloat, endAngle: CGFloat, clockwise: Bool)
    ......
}

3.3、背景与圆孤

聪明的读者肯定已经知道了,结合 3.1 和 3.2 两小节,我们至少有两个新 CALayer,一个作为背景(圆),一个作为动画(圆孤):

// 仅初始化 layer & label,以及颜色,后续再给定实际大小和约束
func initView() {
    // fillColor   用于背景颜色填充
    // strokeColor 用于线条颜色
    
    // 0.25 透明度的白色背景
    bgLayer.fillColor = UIColor(red: 1, green: 1, blue: 1, alpha: 0.25).cgColor
    layer.addSublayer(bgLayer)
    
    // 边框全白,边框宽度为 4
    progressLayer = CAShapeLayer()
    progressLayer.fillColor = nil
    progressLayer.strokeColor = UIColor(red: 1, green: 1, blue: 1, alpha: 1).cgColor
    progressLayer.lineWidth = 4.0
    layer.addSublayer(progressLayer)
}

3.4、大小与约束

大家还记得上一篇提到过的生命周期么?如果我们想知道,我们(这个View)被创建出来,实际的大小是多少,该用到哪个方法?这里,我们将使用『 layoutSubviews 』方法!注意,它不是『 viewWillLayoutSubviews 』,也不是『 viewDidLayoutSubviews 』,这两方法是 UIViewController 中的,前者是 UIView 中的:

UIViewController {
    // Called just before the view controller's view's layoutSubviews method is invoked. 
    // Subclasses can implement as necessary. The default is a nop.
    - (void)viewWillLayoutSubviews API_AVAILABLE(ios(5.0));
    // Called just after the view controller's view's layoutSubviews method is invoked. 
    // Subclasses can implement as necessary. The default is a nop.
    - (void)viewDidLayoutSubviews API_AVAILABLE(ios(5.0));
}

因此,我们将 override layoutSubviews,代码片段如下:

// 布局 resize 时会触发该方法
public override func layoutSubviews() {
    super.layoutSubviews()

    // 因为 宽 = 高,所以,圆角为宽 or 高的一半即可
    let radius = bounds.height / 2
    bgLayer.path = UIBezierPath(roundedRect: bounds, cornerRadius: radius).cgPath

    // 设置 start 从 12点钟方向开始(默认是3点钟方向)
    // end = 360度 * progress - start
    // 设置为 顺时针 方向
    let end = CGFloat(Double.pi * 2 * progress) - StartAngle
    progressLayer.path = UIBezierPath(arcCenter: CGPoint(rect: bounds), radius: radius,
                                      startAngle: -StartAngle, endAngle: end,
                                      clockwise: true).cgPath
}

至于 label 显示倒计时文字,这个我就不讲了,后面的源码中将会给出!

四、完整的源码

import UIKit

fileprivate let StartAngle = CGFloat(Double.pi / 2)

public class CircleProgressView: UIView {
    // 延迟初始化背景层,采用 fill 来绘层背景
    private lazy var bgLayer: CAShapeLayer = CAShapeLayer()
    // 延迟初始化进度条层,采用 stroke 来绘制边框
    private lazy var progressLayer: CAShapeLayer = CAShapeLayer()
    // 进度条百分比(最小为 0.0,最大为 1.0)
    private var progress: Double = 0
    // 延迟初始化标签文本内容
    private lazy var label: UILabel = UILabel(frame: CGRect.zero)

    public override init(frame: CGRect) {
        super.init(frame: frame)
        initView()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        initView()
    }
    
    // 仅初始化 layer & label,以及颜色,后续再给定实际大小和约束
    func initView() {
        // fillColor   用于背景颜色填充
        // strokeColor 用于线条颜色
        
        // 0.25 透明度的白色背景
        bgLayer.fillColor = UIColor(red: 1, green: 1, blue: 1, alpha: 0.25).cgColor
        layer.addSublayer(bgLayer)
        
        // 边框全白,边框宽度为 4
        progressLayer = CAShapeLayer()
        progressLayer.fillColor = nil
        progressLayer.strokeColor = UIColor(red: 1, green: 1, blue: 1, alpha: 1).cgColor
        progressLayer.lineWidth = 4.0
        layer.addSublayer(progressLayer)
        
        // 标签字体颜色为纯白
        label.textColor = UIColor(red: 1, green: 1, blue: 1, alpha: 1)
        // 使用约束来布局
        label.translatesAutoresizingMaskIntoConstraints = false
        addSubview(label)
    }
    
    
    
    // 布局 resize 时会触发该方法
    public override func layoutSubviews() {
        super.layoutSubviews()

        // 因为 宽 = 高,所以,圆角为宽 or 高的一半即可
        let radius = bounds.height / 2
        bgLayer.path = UIBezierPath(roundedRect: bounds, cornerRadius: radius).cgPath

        // 设置 start 从 12点钟方向开始(默认是3点钟方向)
        // end = 360度 * progress - start
        // 设置为 顺时针 方向
        let end = CGFloat(Double.pi * 2 * progress) - StartAngle
        progressLayer.path = UIBezierPath(arcCenter: CGPoint(rect: bounds), radius: radius,
                                          startAngle: -StartAngle, endAngle: end,
                                          clockwise: true).cgPath
        
        // 设置 label 的中心点 = self 的中心点
        NSLayoutConstraint.activate([
            label.centerXAnchor.constraint(equalTo: self.centerXAnchor),
            label.centerYAnchor.constraint(equalTo: self.centerYAnchor)
        ])
    }
    
    public func setProgress(_ progress: Double, duration: Double, animated: Bool) {
        // 进度条目标值,即 0.0 -> progress
        self.progress = progress
        // 初始化 label
        label.text = "\(duration)s"

        // 创建进度条动画,时长 = duration
        if animated {
            let animation = CABasicAnimation(keyPath: "strokeEnd")
            animation.duration = duration
            animation.fromValue = 0
            animation.toValue = 1
            progressLayer.add(animation, forKey: nil)
        }
    }
    
    public func setContent(_ text: String) {
        label.text = "\(text)s"
    }
}

extension CGPoint {
    // 扩展,取 rect 的中心点
    init(rect: CGRect) {
        self.init(x: rect.width / 2, y: rect.height / 2)
    }
}

源码已经给出(传送门:CircleProgressView),大家可以自行使用 SPM 或 CocoaPods 去尝试如何去使用该组件,如何使用下一篇将给出;另外,我没有给出太多的对外可暴露的方法,比如:可自行设置背景色,线条色,文字颜色等,大家也可以自行扩展。

有任何问题,欢迎交流,谢谢!

推荐阅读更多精彩内容

  • 转载自:https://github.com/Tim9Liu9/TimLiu-iOS[https://github...
    神合阅读 6,994评论 0 30
  • 今天感恩节哎,感谢一直在我身边的亲朋好友。感恩相遇!感恩不离不弃。 中午开了第一次的党会,身份的转变要...
    迷月闪星情阅读 9,720评论 0 10
  • 彩排完,天已黑
    刘凯书法阅读 3,630评论 1 3
  • 没事就多看看书,因为腹有诗书气自华,读书万卷始通神。没事就多出去旅游,别因为没钱而找借口,因为只要你省吃俭用,来...
    向阳之心阅读 4,089评论 3 11
  • 表情是什么,我认为表情就是表现出来的情绪。表情可以传达很多信息。高兴了当然就笑了,难过就哭了。两者是相互影响密不可...
    Persistenc_6aea阅读 111,744评论 2 7