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
視圖都會有 x
和 y
值,x
值就是代表 x 軸的圖表資料,如此類推。在以上的程式碼中,我把 x
軸的標籤設置為 Day,而 y
軸就是總步數。
讓我們在 Xcode 14 輸入以上程式碼,預覽就會自動顯示有兩個垂直長方體的長條圖。
以上就是創建長條圖最簡單的方法。不過,我們通常都不會對圖表數據進行硬編碼 (hardcode),而是在 Charts API 編寫一組數據。讓我們看看以下例子:
在預設情況下,Charts API 會以相同顏色呈現所有長方體。如果我們想把每個長方體設置為不同的顏色,可以將 foregroundStyle
修飾符附加到 BarMark
視圖:
.foregroundStyle(by: .value("Day", weekdays[index]))
如果我們想為所有長方體添加註釋,可以使用 annotation
修飾符:
.annotation {
Text("\(steps[index])")
}
作出這些改動後,長條圖就更加漂亮了。
如果想要建立橫向的長條圖,我們只需要把 BarMark
視圖內的 x
和 y
參數 (parameter) 交換就可以了。
如果把 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 非常靈活,我們可以在同一個視圖中疊加多個圖表:
我除了 BarMark
和 LineMark
之外,SwiftUI Charts 框架還有 PointMark
、AreaMark
、RectangularMark
、和 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 會佔據螢幕一半。
要讓 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 帶來了新的日期選擇器 (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)
}
}
NavigationStack 和 NavigationSplitView
NavigationView
在 iOS 16 已經被棄用,取而代之的是新的 NavigationStack
和 NavigationSplitView
。在 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 導航。
由於 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 item 和 Purple color。但是,這兩行的 underlying type 並不相同,一個是文本物件,而另一個是 Color
物件。
NavigationLink
視圖在 iOS 16 中進步了。我們不再需要指定目標視圖,它可以採用一個數值來代表示目標。與新的 navigationDestination
修飾符搭配使用時,我們就可以輕鬆控制目標視圖。在上面的程式碼中,我們有兩個 navigationDestination
修飾符,一個用於文本物件,另一個用於 Color
物件。
當使用者選擇了 NavigationStack
內的某個物件,SwiftUI 就會檢查 NavigationLink
內 value
的物件型別,並調用與該物件型別相關的目標視圖。
這就是新的 NavigationStack
的操作方式。以上只是 NavigationStack
的簡單介紹。我們還可以使用新的 navigationDestination
修飾符,來以編程方式控制導航。比如說,我們可以創建一個按鈕,讓使用者從 navigation stack 中任何一個細節視圖直接跳轉到主視圖。我們會另外再寫一篇教學文章,來詳細說說這個題目。
分享資料的 ShareLink
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 會顯示一個分享選單。
我們可以提供自己的文本和圖像,來客製化分享按鈕:
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
,指定每一列的名稱和數值。
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 行。
我們可以這樣在 lineLimit
修飾符中指定一個範圍,來更改 Text Field 一開始的大小:
Form {
Section("Comment") {
TextField("Please type your feedback here", text: $inputText, axis: .vertical)
.lineLimit(3...5)
}
}
在這個情況下,iOS 就會預設顯示一個 3 行的 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%。
或者,我們可以為 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
修飾符來客製化樣式:
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 maxHeight
為 200
,而水平的 Group 的 maxHeight
則是 100
。
ViewThatFits
就會評估特定空間的高度,並在瑩幕上呈現最適合的視圖。假設我們把幀高度 (frame height) 設置為 100
:
ButtonGroupView()
.frame(height: 100)
ViewThatFits
就會決定這個情況比較適合呈現水平對齊的 Button Group。假設我們把框架的高度更改為 150
,ViewThatFits
視圖就會顯示垂直的 Button Group。
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。當然,我們也可以使用 VStack
和 HStact
來製作 Grid layout,不過 Grid
視圖就可以簡化製作過程。
我們可以這樣編寫程式碼,來構建一個 2x2 的 Grid:
Grid {
GridRow {
IconView(systemName: "trash")
IconView(systemName: "trash")
}
GridRow {
IconView(systemName: "trash")
IconView(systemName: "trash")
}
}
在 Grid
視圖中,我們會有一系列嵌套著 Grid Cell 的 GridRow
。
比如說,我們想把第二行的兩列合併,並顯示一個圖標視圖。我們可以附加 gridCellColumns
修飾符,並把數值設置為 2
:
Grid {
GridRow {
IconView(systemName: "trash")
IconView(systemName: "trash")
}
GridRow {
IconView(systemName: "trash")
.gridCellColumns(2)
}
}
我們也可以嵌套 Grid
視圖,來組成更複雜的 layout:
AnyLayout 和 Layout 協定
新版本的 SwiftUI 推出了 AnyLayout
和 Layout
協定,讓開發者可以建立客製化和更複雜的 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。
另外,我們也可以附加 animation
到 layout
,來動畫化 layout 的轉換。
這個範例讓使用者點擊堆疊視圖來改變 layout。在其他 App 中,我們可能想 layout 根據設備的方向和螢幕尺寸而更改。在這種情況下,我們可以使用 .horizontalSizeClass
變數來偵測方向改變:
@Environment(\.horizontalSizeClass) var horizontalSizeClass
然後,我們就可以這樣更新 layout
變數:
let layout = horizontalSizeClass == .regular ? AnyLayout(HStack(spacing: 0)) : AnyLayout(VStack(spacing: 0))
舉個例子,如果我們將 iPhone 13 Pro Max 轉為橫向,layout 就會變成水平堆疊視圖。
在大多數情況下,我們可以使用 SwiftUI 內建的 layout container(例如 HStack
和 VStack
)來組合 layout。那如果這些 layout container 不足以讓我們製作需要的 layout 類型怎麼辦?iOS 16 引入的 Layout
協定就可以讓我們定義自己的客製化 layout。這個課題就更加複雜了,因此我們會再寫一篇教學文章,深入了解這個新的協定。
總結
今年,Apple 再次為 SwiftUI 框架帶來很多很好的功能。Charts API、改進了的 navigation 視圖、新推出的 AnyLayout
等,全都有助我們建立更好更優雅的 UI。我仍然在探索 SwiftUI 的新 API,如果我錯過了甚麼好功能,歡迎大家留言讓我知道。
備註:我們正就著 iOS 16 更新《精通 SwiftUI》一書。如你有意學習 SwiftUI,歡迎透過網頁購買書籍,我們會在今年免費為大家更新本書。