精通 SwiftUI - iOS 17 版

第 8 章
實作路徑與形狀來繪製線條與圓餅圖

對於有經驗的開發者,你可能已使用過 Core Graphics API 來繪製形狀與物件。這是一個非常強大的框架,可於建立向量圖。在 SwiftUI 中,它也提供幾個向量繪圖 API,可供開發者繪製線條與形狀。

在本章中,你將學習如何使用 Path 與內建的 Shape(如 CircleRoundedRectangle ), 來繪製線條、圓弧、圓餅圖與環圈圖。下列是我將要介紹的主題:

  • 了解 Path 以及如何利用它來繪製線條。
  • 什麼是 Shape 協定?如何遵循這個協定來繪製出自訂的形狀?
  • 如何繪製圓餅圖( pie chart)?
  • 如何以開口圓環( open circle)來建立一個進度指示器?
  • 如何繪製環圈圖( donut chart)?

圖8.1 列出了我們在後面的小節中所要建立的一些形狀與圖表。

圖 8.1. 範例形狀與圖形
圖 8.1. 範例形狀與圖形

了解 Path

在 SwiftUI 中,你可使用 Path 繪製線條與形狀。如果你參考 Apple 的文件 (https://developer.apple.com/documentation/swiftui/path) , Path 是一個包含 2D 形狀輪廓的結構,基本上,線條與形狀是以路徑逐步描繪。以圖 8.2 為例,這是我們要在螢幕上繪製的矩形。

圖 8.2 具有座標的矩形
圖 8.2 具有座標的矩形

請敘說你要如何逐步繪製正方形呢?你可能會提供下列的描述:

  1. 移動至點( 20, 20)。
  2. 從( 20, 20 )畫一條線至( 300, 20 )。
  3. 從( 300, 20 畫一條線至 (300, 200 )。
  4. 從( 300, 200 ) 畫一條線至( 20, 200 )。
  5. 以綠色填滿整個區域。

這就是所謂的 Path。如果將上面的步驟寫成程式碼,程式碼如下所示:

Path() { path in
    path.move(to: CGPoint(x: 20, y: 20))
    path.addLine(to: CGPoint(x: 300, y: 20))
    path.addLine(to: CGPoint(x: 300, y: 200))
    path.addLine(to: CGPoint(x: 20, y: 200))
}
.fill(.green)

這裡初始化一個 Path,並在閉包中提供詳細的說明。你可以呼叫 move(to:) 方法移動至一個特定的座標。要從目前的點畫一條線到特定的點,則可以呼叫 addLine(to:) 方法。預設上,iOS 會以預設的前景色(即黑色)來填滿路徑,若填滿其他顏色,則可以使用 .fill 修飾器,並設定為其顏色。

你可以使用 「App」模板建立一個新專案來測試程式碼。將專案命名為 SwiftUIShape(或你喜歡的任何名稱),然後在 body 輸入上列的程式碼片段,預覽畫布即會顯示出一個綠色矩形,如圖 8.3 所示。

圖 8.3. 使用路徑繪製一個矩形
圖 8.3. 使用路徑繪製一個矩形

使用Stroke 繪製邊框

你不需要以顏色填滿整個區域,如果你只想繪製線條的話,則可以使用 .stroke 修飾器,並指定線條的寬度與顏色,如圖 8.4 所示。

圖 8.4 使用 Stroke 繪製線條
圖 8.4 使用 Stroke 繪製線條

因為我們沒有指定將線條繪製到原點的步驟,所以顯示為一個開放路徑。要封閉路徑的話,你可以在 Path 閉包的結尾處呼叫 closeSubpath() 方法,此方法會自動將目前點與起點連接起來。

圖 8.5 使用 closeSubpath() 封閉路徑
圖 8.5 使用 closeSubpath() 封閉路徑

繪製曲線

Path 提供了多個內建的 API 來幫助你繪製不同的形狀。你不只能夠畫出直線,還可以使用 addQuadCurveaddCurveaddArc 方法來繪製出曲線與圓弧。例如:你想要在矩形頂部繪製出一個圓頂,如圖 8.6 所示。

圖 8.6 具有矩形底座的圓頂
圖 8.6 具有矩形底座的圓頂

程式碼可以這樣編寫:

Path() { path in
    path.move(to: CGPoint(x: 20, y: 60))
    path.addLine(to: CGPoint(x: 40, y: 60))
    path.addQuadCurve(to: CGPoint(x: 210, y: 60), control: CGPoint(x: 125, y: 0))
    path.addLine(to: CGPoint(x: 230, y: 60))
    path.addLine(to: CGPoint(x: 230, y: 100))
    path.addLine(to: CGPoint(x: 20, y: 100))
}
.fill(Color.purple)

addQuadCurve 方法可以讓你透過定義一個控制點(control point )來繪製曲線。參考圖 8.6,(40, 60) 與(210, 60) 就是所謂的「錨點」(anchor point ),(125, 0) 則是計算建立圓頂形狀的控制點,我不打算在這裡討論有關繪製曲線的數學,你可嘗試更改控制點的值來查看效果。簡單而言,該控制點控制如何繪制曲線。如果你將控制點放在更靠近矩形頂部的位置(例如:125, 30),則會繪製出不圓的外觀。

Fill 與 Stroke

如果要畫出形狀的邊框,並同時以顏色填滿形狀,該怎麼做呢? fillstroke 修飾器無法並行使用,不過你可以使用 ZStack 來達到相同的效果,程式碼如下所示:

ZStack {
    Path() { path in
        path.move(to: CGPoint(x: 20, y: 60))
        path.addLine(to: CGPoint(x: 40, y: 60))
        path.addQuadCurve(to: CGPoint(x: 210, y: 60), control: CGPoint(x: 125, y: 0))
        path.addLine(to: CGPoint(x: 230, y: 60))
        path.addLine(to: CGPoint(x: 230, y: 100))
        path.addLine(to: CGPoint(x: 20, y: 100))
    }
    .fill(Color.purple)

    Path() { path in
        path.move(to: CGPoint(x: 20, y: 60))
        path.addLine(to: CGPoint(x: 40, y: 60))
        path.addQuadCurve(to: CGPoint(x: 210, y: 60), control: CGPoint(x: 125, y: 0))
        path.addLine(to: CGPoint(x: 230, y: 60))
        path.addLine(to: CGPoint(x: 230, y: 100))
        path.addLine(to: CGPoint(x: 20, y: 100))
        path.closeSubpath()
    }
    .stroke(Color.black, lineWidth: 5)
}

我們使用相同的路徑建立兩個 Path 物件,然後使用 ZStack 來讓一個 Path 物件疊在另一個 Path 物件上面。下面是使用 fill 填滿紫色的圓頂矩形,並以黑色邊框疊在上面,如圖 8.7 所示。

圖 8.7. 具有邊框的圓頂矩形
圖 8.7. 具有邊框的圓頂矩形

繪製圓弧與圓餅圖

SwiftUI 為開發者提供了一個方便的 API 來繪製圓弧,該 API 對於組合各種形狀和物件(包含圓餅圖)非常有用。要繪製圓弧,你可以撰寫程式碼如下:

Path { path in
    path.move(to: CGPoint(x: 200, y: 200))
    path.addArc(center: .init(x: 200, y: 200), radius: 150, startAngle: .degrees(0), endAngle: .degrees(90), clockwise: true)
}
.fill(.green)

如果你將程式碼放入 body 中,則會在預覽畫布中看到一個填滿綠色的圓弧,如圖 8.8 所示。

圖 8.8. 圓弧範例
圖 8.8. 圓弧範例

在上列的程式碼中,我們先至起點 (200, 200),然後呼叫 addArc 來建立圓弧。addArc 方法接受幾個參數:

  • center - 圓的中心點。
  • radius - 建立圓弧的圓半徑。
  • startAngle - 圓弧的起點角度。
  • endAngle - 圓弧的終點角度。
  • clockwise - 畫圓弧的方向。

如果只看「startAngle」與「endAngle」等兩個參數的名稱,應該會對其含義有點困惑, 圖 8.9 可讓你更加了解這些參數的含義。

圖 8.9. 了解起點角度與終點角度
圖 8.9. 了解起點角度與終點角度

使用 addArc 可輕鬆建立不同色扇形的圓餅圖,你只需要以 ZStack 來重疊不同的扇形即可。組成其圖的各個扇形都有不同 startAngle 值與 endAngle 值,下列是程式碼片段:

ZStack {
    Path { path in
        path.move(to: CGPoint(x: 187, y: 187))
        path.addArc(center: .init(x: 187, y: 187), radius: 150, startAngle: .degrees(0), endAngle: .degrees(190), clockwise: true)
    }
    .fill(.yellow)

    Path { path in
        path.move(to: CGPoint(x: 187, y: 187))
        path.addArc(center: .init(x: 187, y: 187), radius: 150, startAngle: .degrees(190), endAngle: .degrees(110), clockwise: true)
    }
    .fill(.teal)

    Path { path in
        path.move(to: CGPoint(x: 187, y: 187))
        path.addArc(center: .init(x: 187, y: 187), radius: 150, startAngle: .degrees(110), endAngle: .degrees(90), clockwise: true)
    }
    .fill(.blue)

    Path { path in
        path.move(to: CGPoint(x: 187, y: 187))
        path.addArc(center: .init(x: 187, y: 187), radius: 150, startAngle: .degrees(90), endAngle: .degrees(360), clockwise: true)
    }
    .fill(.purple)

}

這將渲染出一個具有四個扇形的圓餅圖,如果你需要更多的扇形,則只要使用不同角度值來建立其他的路徑物件即可。順帶一提,我使用的顏色是來自 iOS 所提供的標準顏色物件。你可以至下列的網址來了解完整的顏色物件:https://developer.apple.com/documentation/uikit/uicolor/standard_colors.

有時,你可能想從圓餅圖切分出來,以突顯特定的扇形。舉例而言,要以紫色突顯扇形時,你可以應用 offset 修飾器來改變扇形的位置:

Path { path in
    path.move(to: CGPoint(x: 187, y: 187))
    path.addArc(center: .init(x: 187, y: 187), radius: 150, startAngle: .degrees(90), endAngle: .degrees(360), clockwise: true)
}
.fill(.purple)
.offset(x: 20, y: 20)

或者,你可以疊加一個邊框來進一步吸引人們目光。如果你要在突顯的扇形上加入標籤,則可以疊上一個 Text 視圖,如下所示:

Path { path in
    path.move(to: CGPoint(x: 187, y: 187))
    path.addArc(center: .init(x: 187, y: 187), radius: 150, startAngle: .degrees(90), endAngle: .degrees(360), clockwise: true)
    path.closeSubpath()
}
.fill(.purple)
.offset(x: 20, y: 20)
.overlay(
    Text("25%")
        .font(.system(.largeTitle, design: .rounded))
        .bold()
        .foregroundColor(.white)
        .offset(x: 80, y: -110)
)

該路徑有與紫色扇形相同的起點角度與終點角度,但是它只僅繪製邊框及加入一個文字視圖,以使扇形突出,圖 8.10 為最後的結果。

圖 8.10. 突出扇形的分裂式圓餅圖
圖 8.10. 突出扇形的分裂式圓餅圖

了解 Shape 協定

在我們深入了解 Shape 協定之前,我們先從一個簡單的作業來開始。根據所學,使用 Path 繪製下列的形狀,如圖8.11 所示。

圖 8.11. 你的作業-使用Path 來繪製形狀
圖 8.11. 你的作業-使用Path 來繪製形狀

請先不要看解答,試著自己做看看。

好,要建立一個像這樣的形狀,你可使用 addLineaddQuadCurve 來建立一個 Path

Path() { path in
    path.move(to: CGPoint(x: 0, y: 0))
    path.addQuadCurve(to: CGPoint(x: 200, y: 0), control: CGPoint(x: 100, y: -20))
    path.addLine(to: CGPoint(x: 200, y: 40))
    path.addLine(to: CGPoint(x: 200, y: 40))
    path.addLine(to: CGPoint(x: 0, y: 40))
}
.fill(Color.green)

如果你閱讀過 Path 的文件,則可能找到另一個名為 addRect 的函數,該函數可以讓你以特定的寬度與高度來繪製矩形。因此,下面是替代的解決方案:

Path() { path in
    path.move(to: CGPoint(x: 0, y: 0))
    path.addQuadCurve(to: CGPoint(x: 200, y: 0), control: CGPoint(x: 100, y: -20))
    path.addRect(CGRect(x: 0, y: 0, width: 200, height: 40))
}
.fill(Color.green)

現在,我們來討論一下 Shape 協定,這個協定非常簡單,只有一個需求,當你使用它時,你必須實作下列函數:

func path(in rect: CGRect) -> Path

那麼,我們何時需要使用 Shape 協定呢?試問你如何重新使用剛建立的 Path 呢?例如: 你想要建立一個圓頂(Dome)形狀、大小彈性的按鈕,該如何實作呢?

再看一下上列的程式碼,你以絕對座標與尺寸來建立一個路徑。為了建立相同但大小可變的形狀,則可以建立一個結構來採用 Shape 協定,並實作 path(in:) 函數。當 path(in:) 函數被框架呼叫時,你將獲得 rect 的大小,然後可在 rect 中繪製路徑。

我們來了解如何建立圓頂形狀,如此你便能更了解 Shape 協定。

struct Dome: Shape {
    func path(in rect: CGRect) -> Path {
        var path = Path()

        path.move(to: CGPoint(x: 0, y: 0))
        path.addQuadCurve(to: CGPoint(x: rect.size.width, y: 0), control: CGPoint(x: rect.size.width/2, y: -(rect.size.width * 0.1)))
        path.addRect(CGRect(x: 0, y: 0, width: rect.size.width, height: rect.size.height))

        return path
    }
}

使用該協定後,我們會獲得用於繪製路徑的矩形區城,我們從 rect 可以找到矩形區域的寬度與高度來計算控制點,並繪製矩形底座。

藉由這個形狀,你就可以使用它來建立各種 SwiftUI 控制元件。舉例而言,你可以建立一個具有圓頂形狀的按鈕,如下所示:

Button(action: {
    // 執行動作
}) {
    Text("Test")
        .font(.system(.title, design: .rounded))
        .bold()
        .foregroundColor(.white)
        .frame(width: 250, height: 50)
        .background(Dome().fill(Color.red))
}

我們將圓頂形狀作為按鈕的背景,其寬度與高度是基於指定的框架大小。

圖 8.12 建立圓頂形狀的按鈕
圖 8.12 建立圓頂形狀的按鈕

使用內建形狀

在前面,我們使用 Shape 協定自訂了一個形狀。而 SwiftUI其實有幾種內建形狀,如圓形( Circle )、矩形( Rectangle )、圓角矩形( RoundedRectangle )與橢圓( Ellipse )等, 如果你不想要太花俏的話,這些形狀已經足以建立一些常見的物件了。

圖 8.13 停止按鈕
圖 8.13 停止按鈕

舉例而言,你要建立一個如圖 8.13 所示的「停止」按鈕,此按鈕是由一個圓角矩形與一個圓形所組成,你可以撰寫程式碼如下:

Circle()
    .foregroundColor(.green)
    .frame(width: 200, height: 200)
    .overlay(
        RoundedRectangle(cornerRadius: 5)
            .frame(width: 80, height: 80)
            .foregroundColor(.white)
    )

這裡,我們初始化一個 Circle 視圖,然後將一個 RoundedRectangle 視圖疊在上面。

使用 Shape 建立進度指示器

透過內建形狀的混搭,你可以為應用程式建立各種類型的向量式(vector-based )UI 控制元件。我再舉另一個例子,圖 8.14 為一個使用 Circle 建立的進度指示器。

圖 8.14 進度指示器
圖 8.14 進度指示器

這個進度指示器其實是由兩個圓形所組成,下方是一個灰色圓環,而在灰色圓環的上方則是一個開口圓環,指示完成的進度。你可以在 ContentView 中撰寫程式碼,如下所示:

struct ContentView: View {

    private var purpleGradient = LinearGradient(gradient: Gradient(colors: [ Color(red: 207/255, green: 150/255, blue: 207/255), Color(red: 107/255, green: 116/255, blue: 179/255) ]), startPoint: .trailing, endPoint: .leading)

    var body: some View {

        ZStack {
            Circle()
                .stroke(Color(.systemGray6), lineWidth: 20)
                .frame(width: 300, height: 300)

        }
    }
}

我們使用 stroke 修飾器來畫出圓環的輪廓,若是你喜歡較粗(或較細)的線條,則可以調整 lineWidth 參數。而 purpleGradient 屬性定義了紫色漸層,我們稍後在繪製開口圓環時會使用它。

圖 8.15. 繪製灰色圓環
圖 8.15. 繪製灰色圓環

現在,在 ZStack 中插入下列的程式碼,以建立開口圓環:

Circle()
    .trim(from: 0, to: 0.85)
    .stroke(purpleGradient, lineWidth: 20)
    .frame(width: 300, height: 300)
    .overlay {
        VStack {
            Text("85%")
                .font(.system(size: 80, weight: .bold, design: .rounded))
                .foregroundColor(.gray)
            Text("Complete")
                .font(.system(.body, design: .rounded))
                .bold()
                .foregroundColor(.gray)
        }
    }

建立一個開口圓環的技巧是加上一個 trim 修飾器。你可指定 from 值與 to 值,以指示要顯示圓環的哪一個部分,在這個範例中,我們想要顯示 85% 的進度,所以設定 from 的值為「0」、to 的值為「0.85」。

為了顯示完成百分比( completion percentage),我們將一個文字視圖疊在圓環的中間, 如圖 8.16 所示。

圖 8.16. 繪製進度視圖
圖 8.16. 繪製進度視圖

繪製環圈圖

最後要示範的是環圈圖,如果你完全了解 trim 修飾器的用法,那麼你可能已經知道我們將如何實作環圈圖了。處理 trim 修飾器的值,我們可以將圓環切分成多段。

這是我們用來建立環圈圖的技巧,程式碼如下所示:

ZStack {
    Circle()
        .trim(from: 0, to: 0.4)
        .stroke(Color(.systemBlue), lineWidth: 80)

    Circle()
        .trim(from: 0.4, to: 0.6)
        .stroke(Color(.systemTeal), lineWidth: 80)

    Circle()
        .trim(from: 0.6, to: 0.75)
        .stroke(Color(.systemPurple), lineWidth: 80)

    Circle()
        .trim(from: 0.75, to: 1)
        .stroke(Color(.systemYellow), lineWidth: 90)
        .overlay(
            Text("25%")
                .font(.system(.title, design: .rounded))
                .bold()
                .foregroundColor(.white)
                .offset(x: 80, y: -100)
        )
}
.frame(width: 250, height: 250)

第一段圓弧只顯示圓環的 40%,第二段圓弧顯示圓環的 20%,不過請注意 from 值是「0.4」,而不是「0」,這可以讓第二段圓弧連接第一段圓弧。

對於最後一個圓弧,我故意把線寬設得大一點,以使該段圓弧突出,如圖8.17 所示。如果你不喜歡這樣的設計,則可以將 linewidth 值由「90」改為「80」。

圖 8.17 繪製環圈圖
圖 8.17 繪製環圈圖

本章小結

我希望你喜歡本章內容,並愛上範例專案。藉由框架所提供的繪圖 API,你可以輕鬆為應用程式建立自訂形狀。Path 與 Shape 的運用還有很多,我僅介紹本章中的一些技巧,但請試著運用所學來施展一些魔法吧 !

在本章所準備的範例檔中,有完整的專案可以下載: