iPhone 內置的「活動」App 使用三個環形進度條來顯示你的移動、鍛煉和站立的進度。 這種進度條又被稱為活動環。 如果你未曾使用過「活動」App或者你不知道什麼是活動環,請查看圖 30.1。 自 Apple Watch 使用環形進度條後,這設計漸漸成為流行的 UI 模式。
在本章中,我們將深入講解環形進度條並使用 SwiftUI 構建一個類似的活動環。 我們的目標不僅僅是創建一個靜態活動環,而是帶有動畫的。圖 30.2 示範了最終完成品的動畫效果。或者你可以在 https://link.appcoda.com/progressring 上查看示範。
讓我們創建一個新項目來構建這個環形進度指示器。 像往常一樣,請使用 App 模板, 將其命名為 SwiftUIProgressRing 或你喜歡的任何名稱。
為了更好地整理我們的程式碼,使用 SwiftUI 視圖模板建立一個新檔案並將其命名為ProgressRingView.swift
。 完成後,Xcode 應該使用自動加入以下程式碼:
import SwiftUI
struct ProgressRingView: View {
var body: some View {
Text("Hello, World!")
}
}
#Preview {
ProgressRingView()
}
在我們深入實作之前,請再次查看圖 30.1 和圖 30.2。 你應該會發現,一個活動環實際上是由兩個或多個圓形進度條組成的。 所以,我們需要構建一個圓形進度條視圖,可以靈活地顯示指定的百分比值,並允許使用者調整進度條的寬度和顏色。
例如,如果你告訴條形視圖以紅色顯示 60% 的進度並將其寬度設置為 250 點。 循環進度視圖應顯示如下內容:
通過構建圓形進度條視圖,開發活動環就變得非常容易。 例如,我們可以在圖 30.4 所示的上面疊加另一個尺寸更大、顏色不同的圓形進度條,就成為一個活動環。
這就是我們將如何構建活動環的方式。 現在讓我們正式開始開發圓形進度條!
如前所述,我們將要實作的圓形進度條,而這進度條可以靈活地支持多種顏色和漸變。 為了這個示範,我們將使用 Color
擴展來準備一組預定顏色。 在項目導航器中,右鍵單擊 SwiftUIProgressRing 並選擇 New file..., 選擇 Swift 文件 模板並將文件命名為 Color+Ext.swift
。 將文件內容更換為以下程式碼:
import SwiftUI
extension Color {
public init(red: Int, green: Int, blue: Int, opacity: Double = 1.0) {
let redValue = Double(red) / 255.0
let greenValue = Double(green) / 255.0
let blueValue = Double(blue) / 255.0
self.init(red: redValue, green: greenValue, blue: blueValue, opacity: opacity)
}
public static let lightRed = Color(red: 231, green: 76, blue: 60)
public static let darkRed = Color(red: 192, green: 57, blue: 43)
public static let lightGreen = Color(red: 46, green: 204, blue: 113)
public static let darkGreen = Color(red: 39, green: 174, blue: 96)
public static let lightPurple = Color(red: 155, green: 89, blue: 182)
public static let darkPurple = Color(red: 142, green: 68, blue: 173)
public static let lightBlue = Color(red: 52, green: 152, blue: 219)
public static let darkBlue = Color(red: 41, green: 128, blue: 185)
public static let lightYellow = Color(red: 241, green: 196, blue: 15)
public static let darkYellow = Color(red: 243, green: 156, blue: 18)
public static let lightOrange = Color(red: 230, green: 126, blue: 34)
public static let darkOrange = Color(red: 211, green: 84, blue: 0)
public static let purpleBg = Color(red: 69, green: 51, blue: 201)
}
在上面的程式碼中,我們建立了一個 init
方法,它提供 red
、green
和 blue
的參數。 這使得使用 RGB 顏色代碼初始化 Color 實體變得更加容易。 所有顏色均來自平面調色板 (https://flatuicolors.com/palette/defo), 如果你喜歡使用其他顏色,你可以簡單地修改顏色值。
參考圖 30.4,圓形進度條實際上由兩個圓圈組成:下方為灰色的完整圓圈,上方為漸變色的另一個部分(或完整)圓圈。 因此,為了實作進度條,我們需要一個ZStack
來覆蓋兩個視圖:
現在打開 ProgressRingView.swift
並宣告以下變數:
var thickness: CGFloat = 30.0
var width: CGFloat = 250.0
由於這個圓形進度條應該支援各種大小,上面的變數都有一個預設值。 顧名思義,thickness
變數控制進度條的粗細,而 width
變數儲存圓的直徑。
你可以使用內置的 Circle
視圖創建圓形視圖,如下所示:
我們使用 stroke
修飾器來繪製灰色圓圈的輪廓。 如圖所示,thickness
屬性用於控制輪廓的寬度, width
屬性是圓的直徑。 我故意突出框架,以便你可以看到厚度和寬度。
接下來,我們將實作環形。 創建這種環形的一種方法是使用Circle
。 我們已經在第 8 章討論過畫圓。這一次,讓我向你展示另一種實現方式。 我們將使用Shape
協議來創建一個客製化的環形。
在同一文件中,插入以下程式碼:
struct RingShape: Shape {
var progress: Double = 0.0
var thickness: CGFloat = 30.0
func path(in rect: CGRect) -> Path {
var path = Path()
path.addArc(center: CGPoint(x: rect.width / 2.0, y: rect.height / 2.0),
radius: min(rect.width, rect.height) / 2.0,
startAngle: .degrees(0),
endAngle: .degrees(360 * progress), clockwise: false)
return path.strokedPath(.init(lineWidth: thickness, lineCap: .round))
}
}
我們通過採用Shape
協議創建了一個RingShape
結構, 在結構中宣告了兩個屬性。 progress
屬性允許用戶指定進度百分比,手 thickness
屬性,類似於 ProgressRingView
中的屬性,可讓你控制環的寬度。
要繪製圓環,我們使用 addArc
方法,然後使用 strokedPath
。 弧的半徑可以通過將框架的寬度(或高度)除以 2 來計算。起始角度當前設定為零度。 而結束角度(ending angle),我們就將 360 乘以進度值來計算。 例如,如果我們將 progress
設定為 0.5,就會繪製一個半環(從 0 到 180 度)。
要使用 RingShape
,你可以像這樣更新 body
變數:
ZStack {
Circle()
.stroke(Color(.systemGray6), lineWidth: thickness)
RingShape(progress: 0.5, thickness: thickness)
}
.frame(width: width, height: width, alignment: .center)
進行修改後,你應該會在灰色圓圈的頂部看到部分環形覆蓋。 請注意,由於我們將 strokedPath
的 lineCap
參數設為 .round
,所以它的兩端都是圓帽形。
除了環的顏色,你可能還會注意到我們需要調整的一些程式碼。 圓弧的起點與圖 30.4 是不同的。要解決此問題,你需要將 startAngle
從0更改為 -90。
在 RingShape
中宣告以下屬性:
var startAngle: Double = -90.0
然後像這樣更新 addArc
方法:
path.addArc(center: CGPoint(x: rect.width / 2.0, y: rect.height / 2.0),
radius: min(rect.width, rect.height) / 2.0,
startAngle: .degrees(startAngle),
endAngle: .degrees(360 * progress + startAngle), clockwise: false)
我們將 startAngle
參數更改為 -90
度數。 另外,還需要更改 endAngle
參數,因為起始角度已更改。 修改後,圓弧現在就逆時針旋轉 90 度。
現在你有了一個可以顯示不同的進度的環,如果每條進度條都能顯示漸變顏色,不就更好嗎? SwiftUI 提供了三種類型的漸變,包括線性漸變、角度漸變和徑向漸變。 Apple 使用角度漸變來為進度條加進漸變效果。
這是一個使用 AngularGradient
的示例:
AngularGradient(gradient: Gradient(colors: [.darkPurple, .lightYellow]), center: .center, startAngle: .degrees(0), endAngle: .degrees(180))
角度漸變是隨著角度的變化做出不同的漸變顏色。 在上面的程式碼中,我們將漸變從 0 度渲染到 180 度。 圖 30.9 顯示了兩種不同角度的結果。
由於環形的起始角度設定為 -90 度,我們將像這樣應用角度漸變(假設進度設置為 0.5):
AngularGradient(gradient: Gradient(colors: [.darkPurple, .lightYellow]), center: .center, startAngle: .degrees(startAngle), endAngle: .degrees(360 * 0.5 + startAngle))
現在讓我們修改程式碼為RingShape
加入漸變效果。 首先,在 ProgressRingView
中宣告以下屬性:
var gradient = Gradient(colors: [.darkPurple, .lightYellow])
var startAngle = -90.0
然後通過附加 .fill
修飾器,為 RingShape
填入漸變色,如下所示:
RingShape(progress: 0.5, thickness: thickness)
.fill(AngularGradient(gradient: gradient, center: .center, startAngle: .degrees(startAngle), endAngle: .degrees(360 * 0.5 + startAngle)))
完成修改後,圓形進度條就即時顯示漸變效果。
進度百分比現在固定為 0.5。 不用多說,你也知道我們需要為此建立一個變數以使其可以隨時調整。 在 ProgressRingView
中,宣告一個名為 progress
的變數,如下所示:
@Binding var progress: Double
我們開發的 ProgressRingView
是能夠讓使用者控制進度百分比。 因此,進度是應該由使用者提供。 這就是為什麼 progress
被標記為綁定變數的原因。
要使用該變數,我們可以相應地更新以下程式碼:
RingShape(progress: progress, thickness: thickness)
.fill(AngularGradient(gradient: gradient, center: .center, startAngle: .degrees(startAngle), endAngle: .degrees(360 * progress + startAngle)))
Xcode 現在應該在 #Preview
中顯示錯誤,因為我們必須將 ProgressRingView
傳給 progress
參數。 因此,像這樣更新ProgressRingView_Previews
:
#Preview("ProgressRingView (50%)") {
ProgressRingView(progress: .constant(0.5))
}
#Preview("ProgressRingView (90%)") {
ProgressRingView(progress: .constant(0.9))
}
我想預覽兩個不同的進度值的最終結果,所以就創建了兩個ProgressRingView
實體。 現在我們就可以輕鬆地同時查看兩個結果。
圓形進度條看起來做得不錯,那就讓我們試一試創建一個如圖 30.12 所示的範列。該圖有三個用於調整進度的按鈕。 當任何一個按鈕被點擊時,進度條會逐漸增加(或減少)到指定的百分比。 例如,當前進度設置為 0。當點擊「50%」按鈕時,進度條會從 0% 逐漸上升到 50%。
現在讓我們切換到 ContentView.swift
來創建這個示範App。 首先,宣告一個狀態變數來存放進度,如下所示:
@State var progress = 0.0
然後在 body
變數中插入以下程式碼來建立 UI:
VStack {
ProgressRingView(progress: $progress)
HStack {
Group {
Text("0%")
.font(.system(.headline, design: .rounded))
.onTapGesture {
self.progress = 0.0
}
Text("50%")
.font(.system(.headline, design: .rounded))
.onTapGesture {
self.progress = 0.5
}
Text("100%")
.font(.system(.headline, design: .rounded))
.onTapGesture {
self.progress = 1.0
}
}
.padding()
.background(Color(.systemGray6))
.clipShape(RoundedRectangle(cornerRadius: 15.0, style: .continuous))
.padding()
}
.padding()
}
在預覽畫布中,你應該有如下圖所示的內容。 進度條只顯示下方的灰色圓圈,主要原因是進度值預設為零。 單擊 Play 按鈕運行App,嘗試點擊不同的按鈕以查看進度條如何變化。
Does it work up to your expections? I think not. When you tap the 50% button, the progress bar instantly fills half of the ring without any animation. This isn't what we expect.
App 的運作是否符合你的期望? 我想不是。 當你點擊 50% 按鈕時,進度條會立即填滿圓的一半,而沒有帶任何動畫。 這當然不是我們所期望的效果。
我想你可能知道為什麼視圖沒有動畫, 因為我們還沒有將 .animation
修飾器附加到環形。 切換至 ProgressRingView.swift
並將 .animation
修飾器附加到 ProgressRingView
的 ZStack
。 你可以在 .frame
修飾器之後插入程式碼:
.animation(.easeInOut(duration: 1.0), value: progress)
好的,看來我們已經找到了解決方案。 讓我們回到 ContentView.swift
並再次測試App。 試試再按任何按鈕看看效果。
你的結果是什麼? 我們之間的改動有效嗎?
不幸的是,圓環仍然沒有為進度變化設置動畫,但漸變色的變化現在已有帶動畫了。
原因是什麼?
在解決這個問題之前,讓我進一步解釋一下 .animation
修飾器是如何運作的。 在 .animation
修飾器的 官方文件) 中,它提到 該修飾器可為所有可以動畫化的值(animatable values)自動加入動畫。 這裡的關鍵字是 animatable。 當你在視圖上使用 .animation
修飾器時,SwiftUI 會自動為對視圖的可動畫屬性進行動畫處理。
SwiftUI 帶有一個名為Animatable
的協議。 對於支援動畫的視圖,你可以採用協議並提供 animatableData
屬性。 這個屬性告訴 SwiftUI 視圖有哪些數據可以動畫化。
在第 9 章中,我介紹了 SwiftUI 動畫的基礎知識。 你可以使用 .scaleEffect
輕鬆為視圖的大小變化或使用 .offset
動畫位置變化加入動畫。 可能對於你來說,所有這些動畫都是自動運行的。 但在這些動畫背後,Apple 的工程師實際上採用了Animatablew
協議,並為CGSize
和CGPoint
提供了動畫數據。
那麼,為什麼 RingShape
不能將進度動畫化呢?
RingShape
結構符合 Shape
協議。 如果你查看它的API文件,Shape
採用了 Animatable
協議並提供了預設實現(default implementation)。 然而,animatableData
屬性的預設實現是返回 EmptyAnimatableData
的實體(instance),這意味著沒有動畫數據。 這就是為什麼 ProgressRingView
的進度變化沒有帶動畫。
要解決此問題並使進度可動畫化,你需要做的就是覆載(override)原本的實現並提供可動畫化的值。 回到 RingShape
結構,在 path
函數之前加入以下程式碼:
var animatableData: Double {
get { progress }
set { progress = newValue }
}
程式碼非常簡單, 我們只是告訴 SwiftUI 為 progress
值設置動畫。就是這樣!
現在回到 ContentView.swift
再進行另一個測試, 這次變更進度就可以見到動畫。
有了動畫後,這個圓形進度條的用戶體驗變得更好了。 但是,你可能會注意到一個小問題。 當百分比設定為 100% 時,圓弧變成一個完整的圓,遮蓋圓帽(round cap)。 為了突出圓弧的結束位置,最好添加帶有陰影的圓帽,如圖 30.1 中的活動環。
問題是如何計算這個小圓的位置或圓弧的終點位置? 這需要一些數學知識。 圖 30.17 顯示了我們如何計算小圓圈的位置。
現在就讓我們創建這個小圓圈。 我稱這個視圖為 RingTip
並在 ProgressRingView.swift
文件中實現它,如下所示:
struct RingTip: Shape {
var progress: Double = 0.0
var startAngle: Double = -90.0
var ringRadius: Double
private var position: CGPoint {
let angle = 360 * progress + startAngle
let angleInRadian = angle * .pi / 180
return CGPoint(x: ringRadius * cos(angleInRadian), y: ringRadius * sin(angleInRadian))
}
var animatableData: Double {
get { progress }
set { progress = newValue }
}
func path(in rect: CGRect) -> Path {
var path = Path()
guard progress > 0.0 else {
return path
}
let frame = CGRect(x: position.x, y: position.y, width: rect.size.width, height: rect.size.height)
path.addRoundedRect(in: frame, cornerSize: frame.size)
return path
}
}
RingTip
結構接受三個參數:progress
、startAngle
和 ringRadius
用於計算圓的位置。 一旦我們確定了位置,就可以使用 addRoundedRect
繪製圓的路徑。
現在回到 ProgressRingView
並宣告以下計算屬性來計算環的半徑:
private var radius: Double {
Double(width / 2)
}
接下來,在 ZStack
中的 RingShape
之後插入以下程式碼來創建 RingTip
:
RingTip(progress: progress, startAngle: startAngle, ringRadius: radius)
.frame(width: thickness, height: thickness)
.foregroundColor(progress > 0.96 ? gradient.stops[1].color : Color.clear)
我們通過傳當前進度、起始角度和圓環的半徑來建立RingTip
。 前景色設置為結束漸變色。 你可能想知道為什麼我們只在進度大於 0.96 時才顯示漸變色。 看看圖 30.18,你就會明白我為什麼會做出這個決定。
在 ZStack
中添加 RingTip
後,運行App試一試。 按 100% 按鈕, 進度條現在應該有一個圓頂。
你已經構建了一個非常漂亮的圓形進度條, 但是我們還要在圓弧末端添加點陰影。 在 SwiftUI 中,你可以簡單地附加 .shadow
修飾器來添加陰影。 就這個App,我們可以將修飾器附加到 RingTip
。 最困難的部分是我們需要弄清楚要在哪裡添加陰影。
計算陰影位置與計算環尖的方式非常相似。 因此,在 ProgressRingView.swift
,加入一個用於計算環尖端位置的函數:
private func ringTipPosition(progress: Double) -> CGPoint {
let angle = 360 * progress + startAngle
let angleInRadian = angle * .pi / 180
return CGPoint(x: radius * cos(angleInRadian), y: radius * sin(angleInRadian))
}
然後添加一個新的計算屬性來計算環尖端的陰影偏移值(Shadow offset),如下所示:
private var ringTipShadowOffset: CGPoint {
let shadowPosition = ringTipPosition(progress: progress + 0.01)
let circlePosition = ringTipPosition(progress: progress)
return CGPoint(x: shadowPosition.x - circlePosition.x, y: shadowPosition.y - circlePosition.y)
}
在當前進度上加上 0.01,我們可以計算出陰影位置。 這只是我提供的計算陰影位置的解決方案, 試試自己想一下,你或許能會找出一個更好的替代解決方案。
我們可以將 .shadow
修飾器附加到 RingTip
:
.shadow(color: progress > 0.96 ? Color.black.opacity(0.15) : Color.clear, radius: 2, x: ringTipShadowOffset.x, y: ringTipShadowOffset.y)
我只是想添加一個淺色的陰影,所以將opacity
設定為0.15。 如果你喜歡較暗色的陰影,請增加opacity
值(例如 1.0)。 更改程式碼後,當進度大於 0.96,你應該會在環的末尾看到一個陰影。 你也可以嘗試將進度值設置為大於 1.0 的值,然後看看進度條的外觀。
現在你已經創建了一個圓形進度條,是時候進行練習了。 你的任務是利用你已構建的內容並創建一個活動環。 另外,還需要提供四個按鈕來調整活動環,如圖 30.21 所示。
通過構建一個活動環,我們在本章中介紹了許多 SwiftUI 功能。 你現在應該知道如何使用 Shape
以及如何使用 Animatable 協議為不同形狀設置動畫。
在本章所準備的範例檔中,有最後完整的 Xcode 專案,可供你下載參考: