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 去嘗試如何去使用該組件,如何使用下一篇將給出;另外,我沒有給出太多的對外可暴露的方法,比如:可自行設置背景色,線條色,文字顏色等,大家也可以自行擴展。

有任何問題,歡迎交流,謝謝!

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,836評論 6 540
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,275評論 3 428
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 177,904評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,633評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,368評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,736評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,740評論 3 446
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,919評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,481評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,235評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,427評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,968評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,656評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,055評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,348評論 1 294
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,160評論 3 398
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,380評論 2 379

推薦閱讀更多精彩內容

  • 轉載自:https://github.com/Tim9Liu9/TimLiu-iOS[https://github...
    香橙柚子閱讀 8,655評論 0 36
  • 今天感恩節哎,感謝一直在我身邊的親朋好友。感恩相遇!感恩不離不棄。 中午開了第一次的黨會,身份的轉變要...
    迷月閃星情閱讀 10,594評論 0 11
  • 彩排完,天已黑
    劉凱書法閱讀 4,267評論 1 3
  • 沒事就多看看書,因為腹有詩書氣自華,讀書萬卷始通神。沒事就多出去旅游,別因為沒錢而找借口,因為只要你省吃儉用,來...
    向陽之心閱讀 4,809評論 3 11
  • 表情是什么,我認為表情就是表現出來的情緒。表情可以傳達很多信息。高興了當然就笑了,難過就哭了。兩者是相互影響密不可...
    Persistenc_6aea閱讀 125,632評論 2 7