一、前言
本組件將暫時應用于廣告頁。
一般來說,在廣告頁一定會有一個倒計時的 View 控件,low 一點的呢,就是一個類似于短信倒計時的『 XX秒,點擊跳過』,好一點的呢,就是一個自帶進度條動畫的倒計時控件,我們先來看看最終的效果:
本篇,我們將開始我們的第一個組件開發:一個簡單的倒計時動畫控件。
二、組件化開發
2.1、為什么要組件化開發
無論一個規模多大或是多小的項目,主流通用做法都是將功能單一的代碼單獨放在一起,然而,怎么放是我們要面對的問題:
- 一般偷懶的做法,就是直接在工程項目中,創建一個Group,然后將代碼放入其中;這種做法簡單、快捷、暴力,但帶來的后果是,如果其它項目要用,就只能 copy 源代碼,且之后如果有所修改,那多個項目就要維護多套代碼;
- 組件化開發:類似于第三方的開源代碼,我們需要制作庫,而制作成庫又有兩種方式:
我們之前已經有一個系列專門介紹過 CocoaPods 和 SPM,沒看過的小伙伴可以去了解一下。本篇及后續的組件,我們都將采用庫的方式來制作并提供,這樣的好處在于,多個項目間,只有一份源碼,只需要通過配置來引用;后續升級、維護都較為方便。
本篇制作的庫,不僅要支持 CocoaPods,同時也支持 SPM,這是我們之前沒有分享到的,但大家不要怕,很簡單,我們可以回憶一下,我之前分別給出的基于 CocoaPods 的庫制作 demo 和 基于 SPM 的庫制作 demo,它倆的區別在于:
我們可以看到:
- 配置文件不同;
- 源碼目錄不同;
配置文件是各家規定的,這個沒法去改;但是,源碼目錄,我們可以統一,統一后通過配置文件來配置即可。
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 中,圓的起始點,才能正確做圖:
與我們常識不一樣,在 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 去嘗試如何去使用該組件,如何使用下一篇將給出;另外,我沒有給出太多的對外可暴露的方法,比如:可自行設置背景色,線條色,文字顏色等,大家也可以自行擴展。
有任何問題,歡迎交流,謝謝!