利用 UIBezierPath 實現圓環進度條,甜甜圈圖表 & 圓餅圖

在使用記錄類的 App,比方記帳或跑步記錄 App 時,我們時常看到美麗的圓環進度條。

有很多方法可以實現圓環進度條,接下來我們將在 Xcode playground 練習,使用 UIBezierPath 繪製圓環的形狀。(ps: 關於 UIBezierPath 繪製形狀的原理可參考以下連結)

圓環進度條(circular progress ring)

import UIKit

let aDegree = Double.pi / 180
let lineWidth: Double = 10
let radius: Double = 50
let startDegree: Double = 270
let circlePath = UIBezierPath(ovalIn: CGRect(x: lineWidth, y: lineWidth, width: radius*2, height: radius*2))
let circleLayer = CAShapeLayer()
circleLayer.path = circlePath.cgPath
circleLayer.strokeColor = UIColor.gray.cgColor
circleLayer.lineWidth = lineWidth
circleLayer.fillColor = UIColor.clear.cgColor
let percentage: CGFloat = 60
let endDegree = startDegree + 360 * percentage / 100
let percentagePath = UIBezierPath(arcCenter: CGPoint(x: lineWidth + radius, y: lineWidth + radius), radius: radius, startAngle: aDegree * startDegree, endAngle: aDegree * endDegree, clockwise: true)
let percentageLayer = CAShapeLayer()
percentageLayer.path = percentagePath.cgPath
percentageLayer.strokeColor = UIColor(red: 0, green: 0, blue: 1, alpha: 1).cgColor
percentageLayer.lineWidth = lineWidth
percentageLayer.fillColor = UIColor.clear.cgColor

let viewWidth = 2*(radius+lineWidth)
let view = UIView(frame: CGRect(x: 0, y: 0, width: viewWidth, height: viewWidth))
view.layer.addSublayer(circleLayer)
view.layer.addSublayer(percentageLayer)
let label = UILabel(frame: view.bounds)
label.textAlignment = .center
label.text = "\(percentage)%"
view.addSubview(label)
view
  • 結果。

程式解說

以 UIBezierPath 搭配 CAShapeLayer 繪製圓形的線條,用 CAShapeLayer 的 strokeColor & lineWidth 控制線條的顏色和粗細。circleLayer 將繪製完整的灰色圓環,percentageLayer 則代表有缺口的藍色圓環進度條,蓋在 circleLayer 身上。

我們利用以下程式控制圓環開始和結束的角度。

let percentagePath = UIBezierPath(arcCenter: CGPoint(x: lineWidth + radius, y: lineWidth + radius), radius: radius, startAngle: aDegree * startDegree, endAngle: aDegree * endDegree, clockwise: true)

值得注意的,view 的邊界和圓形之間會有間隔,間隔為 lineWidth 的一半,這是因為圓形的 stroke 線條繪製時 path 將對應到線條的中間。

let viewWidth = 2*(radius+lineWidth)
let view = UIView(frame: CGRect(x: 0, y: 0, width: viewWidth, height: viewWidth))
將 view 的背景顏色設為 yellow

調整線條端點形狀。

在繪製線條時,我們可以設定 CAShapeLayer 的 lineCap,調整線條端點的形狀,比方 .round 代表圓形。

percentageLayer.lineCap = .round

如下圖所示,lineCap 有 butt,round,square 三種樣式,預設是 butt。值得注意的,採用 round & square 時,連線會有一小塊超出端點。

甜甜圈圖表(donut chart)

甜甜圈圖表也是另一種 App 上常見的圓環例子,用來表示比例關係,比方每個月時間花在寫程式,約會和運動的比例。

試試能不能用圓環進度條學到的技巧畫出類似下圖的 donut chart 吧。

解答

import UIKit

let aDegree = Double.pi / 180
let lineWidth: Double = 40
let radius: Double = 50
var startDegree: Double = 270
let viewWidth = 2*(radius+lineWidth)
let view = UIView(frame: CGRect(x: 0, y: 0, width: viewWidth, height: viewWidth))
let center = CGPoint(x: lineWidth + radius, y: lineWidth + radius)
var percentages: [Double] = [30, 30, 40]
for percentage in percentages {
let endDegree = startDegree + 360 * percentage / 100
let percentagePath = UIBezierPath(arcCenter: center, radius: radius, startAngle: aDegree * startDegree, endAngle: aDegree * endDegree, clockwise: true)
let percentageLayer = CAShapeLayer()
percentageLayer.path = percentagePath.cgPath
percentageLayer.strokeColor = UIColor(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1), alpha: 1).cgColor
percentageLayer.lineWidth = lineWidth
percentageLayer.fillColor = UIColor.clear.cgColor
view.layer.addSublayer(percentageLayer)
startDegree = endDegree
}
view

程式解說

利用 for 迴圈畫出每一段圓弧,在迴圈裡每一段圓弧的起點角度將等於上一段圓弧的終點角度。比方第一段圓弧是 270 ~ 300,第二段是 300 ~ 400。

甜甜圈圖表上標示文字。

定義產生 label 的 function createLabel。

let aDegree = Double.pi / 180
let lineWidth: Double = 40
let radius: Double = 50
var startDegree: Double = 270
let viewWidth = 2*(radius+lineWidth)
let view = UIView(frame: CGRect(x: 0, y: 0, width: viewWidth, height: viewWidth))
let center = CGPoint(x: lineWidth + radius, y: lineWidth + radius)
var percentages: [Double] = [30, 30, 40]

func createLabel(percentage: Double, startDegree: Double) -> UILabel {
let textCenterDegree = startDegree + 360 * percentage / 2 / 100
let textPath = UIBezierPath(arcCenter: center, radius: radius, startAngle: aDegree * textCenterDegree, endAngle: aDegree * textCenterDegree, clockwise: true)
let label = UILabel(frame: CGRect(x: 0, y: 0, width: 50, height: 30))
label.backgroundColor = .yellow
label.font = .systemFont(ofSize: 8)
label.text = "\(percentage)%"
label.sizeToFit()
label.center = textPath.currentPoint
return label
}

for percentage in percentages {
let endDegree = startDegree + 360 * percentage / 100
let percentagePath = UIBezierPath(arcCenter: center, radius: radius, startAngle: aDegree * startDegree, endAngle: aDegree * endDegree, clockwise: true)
let percentageLayer = CAShapeLayer()
percentageLayer.path = percentagePath.cgPath
percentageLayer.strokeColor = UIColor(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1), alpha: 1).cgColor
percentageLayer.lineWidth = lineWidth
percentageLayer.fillColor = UIColor.clear.cgColor
view.layer.addSublayer(percentageLayer)
let label = createLabel(percentage: percentage, startDegree: startDegree)
view.addSubview(label)
startDegree = endDegree
}
view

我們希望在每一段圓弧的中心放上 label,因此我們利用 startDegree + 360 * percentage / 2 / 100 計算圓弧中心的角度,然後在 UIBezierPath 繪製圓弧時,在 startAngle & endAngle 傳入此角度,此時 path 的 currentPoint 即為 label 的中心座標。

文字依據相對於圓心的角度旋轉。

func createLabel(percentage: Double, startDegree: Double) -> UILabel {
let textCenterDegree = startDegree + 360 * percentage / 2 / 100
let textPath = UIBezierPath(arcCenter: center, radius: radius, startAngle: aDegree * textCenterDegree, endAngle: aDegree * textCenterDegree, clockwise: true)
let label = UILabel(frame: CGRect(x: 0, y: 0, width: 50, height: 30))
label.backgroundColor = .yellow
label.font = .systemFont(ofSize: 8)
label.text = "\(percentage)%"
label.sizeToFit()
label.center = textPath.currentPoint
let labelDegree = textCenterDegree - 270
label.transform = CGAffineTransform(rotationAngle: .pi / 180 * labelDegree)
return label
}

透過以下兩行程式旋轉 label。計算旋轉的角度時,我們想把上方當成 0 度,但 UIBezierPath 畫圓時上方是 270 度,因此我們要再減 270。

let labelDegree = textCenterDegree - 270
label.transform = CGAffineTransform(rotationAngle: .pi / 180 * labelDegree)

圓餅圖(pie chart)

one more thing,既然都畫了 donut chart,讓我們也試試圓餅圖吧。

繪製圓餅圖跟繪製 donut chart 的程式類似,幾乎一模一樣,只差在幾個小地方。

解答

import UIKit

let aDegree = Double.pi / 180
let radius: Double = 50
var startDegree: Double = 270
let view = UIView(frame: CGRect(x: 0, y: 0, width: 2*radius, height: 2*radius))
var percentages: [Double] = [25, 40, 35]
for percentage in percentages {
let endDegree = startDegree + 360 * percentage / 100
let percentagePath = UIBezierPath()
percentagePath.move(to: view.center)
percentagePath.addArc(withCenter: view.center, radius: radius, startAngle: aDegree * startDegree, endAngle: aDegree * endDegree, clockwise: true)
let percentageLayer = CAShapeLayer()
percentageLayer.path = percentagePath.cgPath
percentageLayer.fillColor = UIColor(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1), alpha: 1).cgColor
view.layer.addSublayer(percentageLayer)
startDegree = endDegree
}
view

程式解說

程式跟繪製 donut chart 類似,只有兩個小地方不一樣。

  • 以 UIBezierPath 設定形狀時,先用 move 移動到圓心,然後再以 addArc 設定圓弧,如此畫出的形狀才會是從圓心連到圓弧兩端點的扇形。
  • donut char 是繪製圓形的線條,因此它設定 CAShapeLayer 的 strokeColor & lineWidth,而圓餅圖則是填滿顏色,因此改成設定 CAShapeLayer 的 fillColor。

加上文字,文字依據相對於圓心的角度旋轉。

let aDegree = Double.pi / 180
let radius: Double = 50
var startDegree: Double = 270
let view = UIView(frame: CGRect(x: 0, y: 0, width: 2*radius, height: 2*radius))
var percentages: [Double] = [25, 40, 35]

func createLabel(percentage: Double, startDegree: Double) -> UILabel {
let textCenterDegree = startDegree + 360 * percentage / 2 / 100
let textPath = UIBezierPath(arcCenter: view.center, radius: radius-10, startAngle: aDegree * textCenterDegree, endAngle: aDegree * textCenterDegree, clockwise: true)
let label = UILabel(frame: CGRect(x: 0, y: 0, width: 50, height: 30))
label.backgroundColor = .yellow
label.font = .systemFont(ofSize: 8)
label.text = "\(percentage)%"
label.sizeToFit()
label.center = textPath.currentPoint
let labelDegree = textCenterDegree - 270
label.transform = CGAffineTransform(rotationAngle: .pi / 180 * labelDegree)
return label
}

for percentage in percentages {
let endDegree = startDegree + 360 * percentage / 100
let percentagePath = UIBezierPath()
percentagePath.move(to: view.center)
percentagePath.addArc(withCenter: view.center, radius: radius, startAngle: aDegree * startDegree, endAngle: aDegree * endDegree, clockwise: true)
let percentageLayer = CAShapeLayer()
percentageLayer.path = percentagePath.cgPath
percentageLayer.fillColor = UIColor(red: .random(in: 0...1), green: .random(in: 0...1), blue: .random(in: 0...1), alpha: 1).cgColor
view.layer.addSublayer(percentageLayer)
let label = createLabel(percentage: percentage, startDegree: startDegree)
view.addSubview(label)
startDegree = endDegree
}
view

值得注意的,文字 path 的 radius 是圓餅圖 radius 減 10,如此它才會顯示在圓餅圖內。

let textPath = UIBezierPath(arcCenter: view.center, radius: radius-10, startAngle: aDegree * textCenterDegree, endAngle: aDegree * textCenterDegree, clockwise: true)

進階參考資源

作品集

--

--

彼得潘的 iOS App Neverland
彼得潘的 Swift iOS App 開發問題解答集

彼得潘的iOS App程式設計入門,文組生的iOS App程式設計入門講師,彼得潘的 Swift 程式設計入門,App程式設計入門作者,http://apppeterpan.strikingly.com