SwiftUI 框架

WWDC 22 的重點更新:SwiftUI 4.0 新功能一覽

WWDC 22 剛剛完結,隨著 iOS 16 和 Xcode 14,Apple 也推出了新版本的 SwiftUI。這次更新帶來了非常多功能,讓開發者可以構建更好的 App,並減少需要編寫的程式碼。在這篇文章中,我會為大家簡單介紹 SwiftUI 4.0 的新功能。
WWDC 22 的重點更新:SwiftUI 4.0 新功能一覽
Photo by Joel Lee on Unsplash
WWDC 22 的重點更新:SwiftUI 4.0 新功能一覽
Photo by Joel Lee on Unsplash
In: SwiftUI 框架, iOS 16, Xcode 14

WWDC 22 剛剛完結,其中的一大重點還是 SwiftUI 框架。如大家所料,隨著 iOS 16 和 Xcode 14,Apple 也推出了新版本的 SwiftUI。

這次更新帶來了非常多的功能,讓開發者可以構建更好的 App,並減少需要編寫的程式碼。在這篇教學文章中,我會為大家簡單介紹 SwiftUI 4.0 的新功能。

SwiftUI 圖表

以後要建立圖表,我們再也不需要構建自己圖表庫,或是依靠第三方程式庫了!現在,SwiftUI 框架有 Charts API。有了這個宣告式框架,只要編寫幾行程式碼,就可以構建出一個圖表動畫。

簡單來說,我們只需要定義 Mark,就可以構建出 SwiftUI 圖表。讓我們看看這個簡單的例子:

import SwiftUI
import Charts

struct ContentView: View {
    var body: some View {
        Chart {
            BarMark(
                x: .value("Day", "Monday"),
                y: .value("Steps", 6019)
            )

            BarMark(
                x: .value("Day", "Tuesday"),
                y: .value("Steps", 7200)
            )
        }
    }
}

無論我們想要構建長條圖還是折線圖,我們都會從 Chart 視圖開始。在圖表裡面,我們可以定義 bar mark,來提供圖表資料。BarMark 視圖是用來構建長條圖的,每一個 BarMark 視圖都會有 xy 值,x 值就是代表 x 軸的圖表資料,如此類推。在以上的程式碼中,我把 x 軸的標籤設置為 Day,而 y 軸就是總步數。

讓我們在 Xcode 14 輸入以上程式碼,預覽就會自動顯示有兩個垂直長方體的長條圖。

swiftui-charts-bar

以上就是創建長條圖最簡單的方法。不過,我們通常都不會對圖表數據進行硬編碼 (hardcode),而是在 Charts API 編寫一組數據。讓我們看看以下例子:

swiftui-bar-chart

在預設情況下,Charts API 會以相同顏色呈現所有長方體。如果我們想把每個長方體設置為不同的顏色,可以將 foregroundStyle 修飾符附加到 BarMark 視圖:

.foregroundStyle(by: .value("Day", weekdays[index]))

如果我們想為所有長方體添加註釋,可以使用 annotation 修飾符:

.annotation {
    Text("\(steps[index])")
}

作出這些改動後,長條圖就更加漂亮了。

swiftui-colored-bar-chart

如果想要建立橫向的長條圖,我們只需要把 BarMark 視圖內的 xy 參數 (parameter) 交換就可以了。

swiftui-horizontal-bar-chart-ios

如果把 BarMark 視圖轉換成 LineMark,圖表就會變成折線圖了。

Chart {
    ForEach(weekdays.indices, id: \.self) { index in
        LineMark(
            x: .value("Day", weekdays[index]),
            y: .value("Steps", steps[index])
        )
        .foregroundStyle(.purple)
        .lineStyle(StrokeStyle(lineWidth: 4.0))
    }
}

我們也可以使用 foregroundStyle 更改折線圖的顏色。如果要更改線的寬度,就可以附加 lineStyle 修飾符。

Charts API 非常靈活,我們可以在同一個視圖中疊加多個圖表:

swiftui-line-chart

我除了 BarMarkLineMark 之外,SwiftUI Charts 框架還有 PointMarkAreaMarkRectangularMark、和 RuleMark,讓我們構建不同類型的圖表。

可擴展的 Bottom Sheet

Apple 在 iOS 15 推出了 UISheetPresentationController,用來呈現可擴展的 Bottom Sheet;可惜這個類別只在 UIKit 中可用。如果我們想在 SwiftUI 中使用它,就需要編寫額外的程式碼,來把組件集合到 SwiftUI 專案中。今年,Swift 提供了一個新的修飾符 PresentationDetents,用來呈現可擴展的 Bottom Sheet。

我們只需要把這個修佈符放在一個 sheet 視圖中,就可以使用它:

struct BottomSheetDemo: View {
    @State private var showSheet = false

    var body: some View {
        VStack {
            Button("Show Bottom Sheet") {
                showSheet.toggle()
            }
            .buttonStyle(.borderedProminent)
            .sheet(isPresented: $showSheet) {
                Text("This is the resizable bottom sheet.")
                    .presentationDetents([.medium])
            }

            Spacer()
        }
    }
}

presentationDetents 修飾符會接受一組用於 Sheet 的 detent。在上面的程式碼中,我們將 detent 設置為 .medium,這表示一個 Bottom Sheet 會佔據螢幕一半。

swiftui-bottom-sheet-medium-size

要讓 Bottom Sheet 變成可擴展,我們要為 presentationDetents 修飾符提供多於一個 detent。

.presentationDetents([.medium, .large])

現在,我們會看到一個 drag bar,表示 Sheet 可以擴展。如果想隱藏 drag indicator,我們可以附加 presentationDragIndicator 修飾符,並設置為 .hidden

.presentationDragIndicator(.hidden)

除了 .medium 等預設的 detent 之外,我們還可以使用 .height.fraction 來創建客製化的 detent。讓我們看看這個例子:

.presentationDetents([.fraction(0.1), .medium, .large])

這樣的這,Bottom Sheet 第一次出現時,就只會佔據螢幕的 10% 左右。

MultiDatePicker

swiftui-ios-multidatepicker

最新版本的 SwiftUI 帶來了新的日期選擇器 (date picker),讓使用者可以選擇多個日期。以下是範例程式碼:

struct MultiDatePickerDemo: View {

    @State private var selectedDates: Set<DateComponents> = []

    var body: some View {
        MultiDatePicker("Choose your preferred dates", selection: $selectedDates)
            .frame(height: 300)
    }
}

NavigationView 在 iOS 16 已經被棄用,取而代之的是新的 NavigationStackNavigationSplitView。在 iOS 16 之前,我們會使用 NavigationView 來建立導航界面:

NavigationView {
    List {
        ForEach(1...10, id: \.self) { index in
            NavigationLink(destination: Text("Item #\(index) detail")) {
                Text("Item #\(index)")
            }
        }
    }
    .listStyle(.plain)

    .navigationTitle("Navigation Demo")
}

我們可以搭配 NavigationLink 使用,來建立 push 和 pop 導航。

swiftui-navigation-stack

由於 NavigationView 在 iOS 16 已經被棄用,它提供了一個新的視圖 NavigationStack,讓開發者建立同類型的導航界面。讓我們看看以下例子:

NavigationStack {
    List {
        ForEach(1...10, id: \.self) { index in
            NavigationLink {
                Text("Item #\(index) Detail")
            } label: {
                Text("Item #\(index)")
            }
        }
    }
    .listStyle(.plain)

    .navigationTitle("Navigation Demo")
}

以上程式碼與舊方法十分類似,唯一的不同之處就是我們用的是 NavigationStack 而不是 NavigationView 。那 NavigationStack 有甚麼改善呢?

讓我們看看另一個例子:

NavigationStack {
    List {
        NavigationLink(value: "Text Item") {
            Text("Text Item")
        }

        NavigationLink(value: Color.purple) {
            Text("Purple color")
        }
    }
    .listStyle(.plain)

    .navigationTitle("Navigation Demo")
    .navigationDestination(for: Color.self) { item in
        item.clipShape(Circle())
    }
    .navigationDestination(for: String.self) { item in
        Text("This is the detail view for \(item)")
    }
}

以上的列表很簡單,只有兩行:Text itemPurple color。但是,這兩行的 underlying type 並不相同,一個是文本物件,而另一個是 Color 物件。

NavigationLink 視圖在 iOS 16 中進步了。我們不再需要指定目標視圖,它可以採用一個數值來代表示目標。與新的 navigationDestination 修飾符搭配使用時,我們就可以輕鬆控制目標視圖。在上面的程式碼中,我們有兩個 navigationDestination 修飾符,一個用於文本物件,另一個用於 Color 物件。

當使用者選擇了 NavigationStack 內的某個物件,SwiftUI 就會檢查 NavigationLinkvalue 的物件型別,並調用與該物件型別相關的目標視圖。

swiftui-navigation-link-value

這就是新的 NavigationStack 的操作方式。以上只是 NavigationStack 的簡單介紹。我們還可以使用新的 navigationDestination 修飾符,來以編程方式控制導航。比如說,我們可以創建一個按鈕,讓使用者從 navigation stack 中任何一個細節視圖直接跳轉到主視圖。我們會另外再寫一篇教學文章,來詳細說說這個題目。

iOS 16 在 SwiftUI 推出了 ShareLink 控件 (control),讓開發者顯示分享選單 (Share Sheet)。使用 ShareLink 非常簡單,讓我們看看以下例子:

struct ShareLinkDemo: View {
    private let url = URL(string: "https://www.appcoda.com")!

    var body: some View {
        ShareLink(item: url)
    }
}

我們要向 ShareLink 控件提供要分享的物件,這會顯示一個預設的分享按鈕。點擊按鈕後,App 會顯示一個分享選單。

swiftui-sharelink

我們可以提供自己的文本和圖像,來客製化分享按鈕:

ShareLink(item: url) {
    Label("Share", systemImage: "link.icloud")
}

我們也可以附加 presentationDetents 修飾符,來控制分享選單的大小:

ShareLink(item: url) {
    Label("Share", systemImage: "link.icloud")
}
.presentationDetents([.medium, .large])

iPadOS 的 Table

Apple 為 iPadOS 引入了新的 Table container,讓我們可以更容易地以表格形式呈現數據。以下的範例程式碼是一個包含 3 列的表格:

struct TableViewDemo: View {

    private let members: [Staff] = [
        .init(name: "Vanessa Ramos", position: "Software Engineer", phone: "2349-233-323"),
        .init(name: "Margarita Vicente", position: "Senior Software Engineer", phone: "2332-333-423"),
        .init(name: "Yara Hale", position: "Development Manager", phone: "2532-293-623"),
        .init(name: "Carlo Tyson", position: "Business Analyst", phone: "2399-633-899"),
        .init(name: "Ashwin Denton", position: "Software Engineer", phone: "2741-333-623")
    ]

    var body: some View {
        Table(members) {
            TableColumn("Name", value: \.name)
            TableColumn("Position", value: \.position)
            TableColumn("Phone", value: \.phone)
        }
    }
}

我們可以從一組數據(例如:一個 Staff 的陣列)建立一個 Table。我們可以利用 TableColumn,指定每一列的名稱和數值。

swiftui-table-ipados

Table 在 iPadOS 和 macOS 都適用。同一個列表可以在 iOS 上自動呈現,但它只會顯示第一列。

可擴展的 Text Field

TextField 在 iOS 16 可以說是大大改善了。我們現在可以使用 axis 參數,去告訴 iOS 應否擴展 Text Field。來看看以下例子:

Form {
    Section("Comment") {
        TextField("Please type your feedback here", text: $inputText, axis: .vertical)
            .lineLimit(5)
    } 
}

lineLimit 修飾符指定了最大行數。上面的程式碼會在一開始呈現一個單行的 Text Field,當我們輸入時,它就會自動擴展,但將其大小會被限制為 5 行。

expandable-textfield-swiftui-ios

我們可以這樣在 lineLimit 修飾符中指定一個範圍,來更改 Text Field 一開始的大小:

Form {
    Section("Comment") {
        TextField("Please type your feedback here", text: $inputText, axis: .vertical)
            .lineLimit(3...5)
    } 
}

在這個情況下,iOS 就會預設顯示一個 3 行的 Text Field。

swiftui-text-field

Gauge

SwiftUI 推出了一個新的視圖 Gauge,用來顯示進度條,最簡單的使用方法是這樣的:

struct GaugeViewDemo: View {
    @State private var progress = 0.5

    var body: some View {
        Gauge(value: progress) {
            Text("Upload Status")
        }
    }
}

在這個最基本的形式中,Gauge 的預設範圍是 0 到 1。如果我們將 value 參數設置為 0.5,SwiftUI 就會呈現一個進度條,指示任務已完成了 50%。

swiftui-gauge

或者,我們可以為 current value、minimum value 和 maximum 設置標籤:

Gauge(value: progress) {
    Text("Upload Status")
} currentValueLabel: {
    Text(progress.formatted(.percent))
} minimumValueLabel: {
    Text(0.formatted(.percent))
} maximumValueLabel: {
    Text(100.formatted(.percent))
}

如果不想使用預設範圍,我們也可以如此指定客製化的範圍:

Gauge(value: progress, in: 0...100) {
  .
  .
  .
}

Gauge 視圖提供了不同的樣式,讓我們可以客製化自己的進度條。除了上圖直線樣式的進度條外,我們讓可以附加 gaugeStyle 修飾符來客製化樣式:

circular-gauge

ViewThatFits

SwiftUI 另外一個新功能 ViewThatFits 十分有用,可以讓開發者建立更有彈性的 UI layout。這是一個特殊型別的視圖,用來評估可用空間,並在顯示最適合的視圖。

讓我們看看以下的例子。我們用了 ViewThatFits 來定義 Button Group 兩種可用的 layout:

struct ButtonGroupView: View {
    var body: some View {
        ViewThatFits {
            VStack {
                Button(action: {}) {
                    Text("Buy")
                        .frame(maxWidth: .infinity)
                        .padding()
                }
                .buttonStyle(.borderedProminent)
                .padding(.horizontal)

                Button(action: {}) {
                    Text("Cancel")
                        .frame(maxWidth: .infinity)
                        .padding()
                }
                .tint(.gray)
                .buttonStyle(.borderedProminent)
                .padding(.horizontal)
            }
            .frame(maxHeight: 200)


            HStack {
                Button(action: {}) {
                    Text("Buy")
                        .frame(maxWidth: .infinity)
                        .padding()
                }
                .buttonStyle(.borderedProminent)
                .padding(.leading)

                Button(action: {}) {
                    Text("Cancel")
                        .frame(maxWidth: .infinity)
                        .padding()
                }
                .tint(.gray)
                .buttonStyle(.borderedProminent)
                .padding(.trailing)
            }
            .frame(maxHeight: 100)

        }
    }
}

一個 Button Group 是使用 VStack 視圖垂直對齊的,而另一個 Button Group 則是水平對齊的。垂直的 Group maxHeight200,而水平的 Group 的 maxHeight 則是 100

ViewThatFits 就會評估特定空間的高度,並在瑩幕上呈現最適合的視圖。假設我們把幀高度 (frame height) 設置為 100

ButtonGroupView()
    .frame(height: 100)

ViewThatFits 就會決定這個情況比較適合呈現水平對齊的 Button Group。假設我們把框架的高度更改為 150ViewThatFits 視圖就會顯示垂直的 Button Group。

swiftui-button-group-viewthatfits

Gradient 和 Shadow

swiftui-gradient-shadow

新版本的 SwiftUI 讓我們可以簡單地添加線性漸變 (linear gradient)。我們只需要把 gradient 修佈符添加到 Color,SwiftUI 就會自動產生漸變。看看以下的例子:

Image(systemName: "trash")
    .frame(width: 100, height: 100)
    .background(in: Rectangle())
    .backgroundStyle(.purple.gradient)
    .foregroundStyle(.white.shadow(.drop(radius: 1, y: 3.0)))
    .font(.system(size: 50))

我們也可以使用 shadow 修佈符來添加陰影效果。以下的範例程式碼就可以添加 drop shadow 效果:

.foregroundStyle(.white.shadow(.drop(radius: 1, y: 3.0)))

Grid API

SwiftUI 4.0 推出了一個新的 Grid API,讓我們建立Grid layout。當然,我們也可以使用 VStackHStact 來製作 Grid layout,不過 Grid 視圖就可以簡化製作過程。

swiftui-grid-gridrow

我們可以這樣編寫程式碼,來構建一個 2x2 的 Grid:

Grid {
    GridRow {
        IconView(systemName: "trash")
        IconView(systemName: "trash")
    }

    GridRow {
        IconView(systemName: "trash")
        IconView(systemName: "trash")
    }
}

Grid 視圖中,我們會有一系列嵌套著 Grid Cell 的 GridRow

swiftui-grid-api

比如說,我們想把第二行的兩列合併,並顯示一個圖標視圖。我們可以附加 gridCellColumns 修飾符,並把數值設置為 2

Grid {
    GridRow {
        IconView(systemName: "trash")
        IconView(systemName: "trash")
    }

    GridRow {
        IconView(systemName: "trash")
            .gridCellColumns(2)
    }
}

我們也可以嵌套 Grid 視圖,來組成更複雜的 layout:

swiftui-multi-grid

AnyLayout 和 Layout 協定

新版本的 SwiftUI 推出了 AnyLayoutLayout 協定,讓開發者可以建立客製化和更複雜的 layout。AnyLayout 是 layout 協定的類型擦除實例 (type-erased instance)。我們可以使用 AnyLayout 創建一個 dynamic layout,來回應使用者交互 (users' interactions) 或環境變化。

例如,我們的 App 一開始時使用 VStack 垂直排列兩個圖像,在使用者點擊堆疊視圖時,就會變成水平堆疊。我們可以如此使用 AnyLayout 來實作:

struct AnyLayoutDemo: View {

    @State private var changeLayout = false

    var body: some View {
        let layout = changeLayout ? AnyLayout(HStack(spacing: 0)) : AnyLayout(VStack(spacing: 0))


        layout {
            Image("macbook-1")
                .resizable()
                .scaledToFill()
                .frame(maxWidth: 300, maxHeight: 200)
                .clipped()

            Image("macbook-2")
                .resizable()
                .scaledToFill()
                .frame(maxWidth: 300, maxHeight: 200)
                .clipped()

        }
        .animation(.default, value: changeLayout)
        .onTapGesture {
            changeLayout.toggle()
        }

    }
}

我們可以定義一個 layout 變數,來保存 AnyLayout 的實例。如此一來,layout 就會根據 changeLayout 的數值改變為水平或垂直 layout。

另外,我們也可以附加 animationlayout,來動畫化 layout 的轉換。

swiftui-anylayout-demo

這個範例讓使用者點擊堆疊視圖來改變 layout。在其他 App 中,我們可能想 layout 根據設備的方向和螢幕尺寸而更改。在這種情況下,我們可以使用 .horizo​​ntalSizeClass 變數來偵測方向改變:

@Environment(\.horizontalSizeClass) var horizontalSizeClass

然後,我們就可以這樣更新 layout 變數:

let layout = horizontalSizeClass == .regular ? AnyLayout(HStack(spacing: 0)) : AnyLayout(VStack(spacing: 0))

舉個例子,如果我們將 iPhone 13 Pro Max 轉為橫向,layout 就會變成水平堆疊視圖。

swiftui-anylayout-sizeclass

在大多數情況下,我們可以使用 SwiftUI 內建的 layout container(例如 HStackVStack)來組合 layout。那如果這些 layout container 不足以讓我們製作需要的 layout 類型怎麼辦?iOS 16 引入的 Layout 協定就可以讓我們定義自己的客製化 layout。這個課題就更加複雜了,因此我們會再寫一篇教學文章,深入了解這個新的協定。

總結

今年,Apple 再次為 SwiftUI 框架帶來很多很好的功能。Charts API、改進了的 navigation 視圖、新推出的 AnyLayout 等,全都有助我們建立更好更優雅的 UI。我仍然在探索 SwiftUI 的新 API,如果我錯過了甚麼好功能,歡迎大家留言讓我知道。

備註:我們正就著 iOS 16 更新《精通 SwiftUI》一書。如你有意學習 SwiftUI,歡迎透過網頁購買書籍,我們會在今年免費為大家更新本書。

譯者簡介:Kelly Chan-AppCoda 編輯小姐。

作者
Simon Ng
軟體工程師,AppCoda 創辦人。著有《iOS 17 App 程式設計實戰心法》、《iOS 17 App程式設計進階攻略》以及《精通SwiftUI》。曾任職於HSBC, FedEx等跨國企業,專責軟體開發、系統設計。2012年創立AppCoda技術部落格,定期發表iOS程式教學文章。現時專注發展AppCoda業務,致力於iOS程式教學、產品設計及開發。你可以到推特與我聯絡。
評論
更多來自 AppCoda 中文版
很好! 你已成功註冊。
歡迎回來! 你已成功登入。
你已成功訂閱 AppCoda 中文版 電子報。
你的連結已失效。
成功! 請檢查你的電子郵件以獲取用於登入的連結。
好! 你的付費資料已更新。
你的付費方式並未更新。