教程 2 - Building Lists and Navigation
Section 4 - Step 2: 靜態 List
var body: some View {
List {
LandmarkRow(landmark: landmarkData[0])
LandmarkRow(landmark: landmarkData[1])
}
}
這里的 List 和 HStack 或者 VStack 之類的容器很相似,接受一個 view builder 並采用 View DSL 的方式列舉了兩個 LandmarkRow。這種方式構建了對應着 UITableView 的靜態 cell 的組織方式。
public init(content: () -> Content)
我們可以運行 app,並使用 Xcode 的 View Hierarchy 工具來觀察 UI,結果可能會讓你覺得很眼熟:

實際上在屏幕上繪制的 UpdateCoalesingTableView 是一個 UITableView 的子類,而兩個 cell ListCoreCellHost 也是 UITableViewCell 的子類。對於 List 來說,SwiftUI 底層直接使用了成熟的 UITableView 的一套實現邏輯,而並非重新進行繪制。相比起來,像是 Text 或者 Image 這樣的單一 View 在 UIKit 層則全部統一由 DisplayList.ViewUpdater.Platform.CGDrawingView 這個 UIView 的子類進行繪制。
不過在使用 SwiftUI 時,我們首先需要做的就是跳出 UIKit 的思維方式,不應該去關心背后的繪制和實現。使用 UITableView 來表達 List 也許只是權宜之計,也許在未來也會被另外更高效的繪制方式取代。由於 SwiftUI 層只是 View 描述的數據抽象,因此和 React 的 Virtual DOM 以及 Flutter 的 Widget 一樣,背后的具體繪制方式是完全解耦合,並且可以進行替換的。這為今后 SwiftUI 更進一步留出了足夠的可能性。
Section 5 - Step 2: 動態 List 和 Identifiable
List(landmarkData.identified(by: \.id)) { landmark in
LandmarkRow(landmark: landmark)
}
除了靜態方式以外,List 當然也可以接受動態方式的輸入,這時使用的初始化方法和上面靜態的情況不一樣:
public struct List<Selection, Content> where Selection : SelectionManager, Content : View {
public init<Data, RowContent>(
_ data: Data, action: @escaping (Data.Element.IdentifiedValue) -> Void,
rowContent: @escaping (Data.Element.IdentifiedValue) -> RowContent)
where
Content == ForEach<Data, Button<HStack<RowContent>>>,
Data : RandomAccessCollection,
RowContent : View,
Data.Element : Identifiable
//...
}
這個初始化方法的約束比較多,我們一行行來看:
Content == ForEach<Data, Button<HStack<RowContent>>>因為這個函數簽名中並沒有出現Content,Content僅只List<Selection, Content>的類型聲明中有定義,所以在這與其說是一個約束,不如說是一個用來反向確定List實際類型的描述。現在讓我們先將注意力放在更重要的地方,稍后會再多講一些這個。Data : RandomAccessCollection這基本上等同於要求第一個輸入參數是Array。RowContent : View對於構建每一行的rowContent來說,需要返回是View是很正常的事情。注意rowContent其實也是被@ViewBuilder標記的,因此你也可以把LandmarkRow的內容展開寫進去。不過一般我們會更希望盡可能拆小 UI 部件,而不是把東西堆在一起。Data.Element : Identifiable要求Data.Element(也就是數組元素的類型) 上存在一個可以辨別出某個實例的滿足Hashable的 id。這個要求將在數據變更時快速定位到變化的數據所對應的 cell,並進行 UI 刷新。
關於 List 以及其他一些常見的基礎 View,有一個比較有趣的事實。在下面的代碼中,我們期望 List 的初始化方法生成的是某個類型的 View:
var body: some View {
List {
//...
}
}
但是你看遍 List 的文檔,甚至是 Cmd + Click 到 SwiftUI 的 interface 中查找 View 相關的內容,都找不到 List : View 之類的聲明。
難道是因為 SwiftUI 做了什么手腳,讓本來沒有滿足 View 的類型都可以“充當”一個 View 嗎?當然不是這樣…如果你在運行時暫定 app 並用 lldb 打印一下 List 的類型信息,可以看到下面的下面的信息:
(lldb) type lookup List
...
struct List<Selection, Content> : SwiftUI._UnaryView where ...
進一步,_UnaryView 的聲明是:
protocol _UnaryView : View where Self.Body : _UnaryView {
}
SwiftUI 內部的一元視圖 _UnaryView 協議雖然是滿足 View 的,但它被隱藏起來了,而滿足它的 List 雖然是 public 的,但是卻可以把這個協議鏈的信息也作為內部信息隱藏起來。這是 Swift 內部框架的特權,第三方的開發者無法這樣在在兩個 public 的聲明之間插入一個私有聲明。
最后,SwiftUI 中當前 (Xcode 11 beta 1) 只有對應 UITableView 的 List,而沒有 UICollectionView 對應的像是 Grid 這樣的類型。現在想要實現類似效果的話,只能嵌套使用 VStack 和 HStack。這是比較奇怪的,因為技術層面上應該和 table view 沒有太多區別,大概是因為工期不太夠?相信今后應該會補充上 Grid。
教程 3 - Handling User Input
Section 3 - Step 2: @State 和 Binding
@State var showFavoritesOnly = true
var body: some View {
NavigationView {
List {
Toggle(isOn: $showFavoritesOnly) {
Text("Favorites only")
}
//...
if !self.showFavoritesOnly || landmark.isFavorite {
這里出現了兩個以前在 Swift 里沒有的特性:@State 和 $showFavoritesOnly。
如果你 Cmd + Click 點到 State 的定義里面,可以看到它其實是一個特殊的 struct:
@propertyWrapper public struct State<Value> : DynamicViewProperty, BindingConvertible {
/// Initialize with the provided initial value.
public init(initialValue value: Value)
/// The current state value.
public var value: Value { get nonmutating set }
/// Returns a binding referencing the state value.
public var binding: Binding<Value> { get }
/// Produces the binding referencing this state value
public var delegateValue: Binding<Value> { get }
}
@propertyWrapper 標注和上一篇中提到的 @_functionBuilder 類似,它修飾的 struct 可以變成一個新的修飾符並作用在其他代碼上,來改變這些代碼默認的行為。這里 @propertyWrapper 修飾的 State 被用做了 @State 修飾符,並用來修飾 View 中的 showFavoritesOnly 變量。
和 @_functionBuilder 負責按照規矩“重新構造”函數的作用不同,@propertyWrapper 的修飾符最終會作用在屬性上,將屬性“包裹”起來,以達到控制某個屬性的讀寫行為的目的。如果將這部分代碼“展開”,它實際上是這個樣子的:
// @State var showFavoritesOnly = true
var showFavoritesOnly = State(initialValue: true)
var body: some View {
NavigationView {
List {
// Toggle(isOn: $showFavoritesOnly) {
Toggle(isOn: showFavoritesOnly.binding) {
Text("Favorites only")
}
//...
// if !self.showFavoritesOnly || landmark.isFavorite {
if !self.showFavoritesOnly.value || landmark.isFavorite {
我把變化之前的部分注釋了一下,並且在后面一行寫上了展開后的結果。可以看到 @State 只是聲明 State struct 的一種簡寫方式而已。State 里對具體要如何讀寫屬性的規則進行了定義。對於讀取,非常簡單,使用 showFavoritesOnly.value 就能拿到 State 中存儲的實際值。而原代碼中 $showFavoritesOnly 的寫法也只不過是 showFavoritesOnly.binding 的簡化。binding 將創建一個 showFavoritesOnly 的引用,並將它傳遞給 Toggle。再次強調,這個 binding 是一個引用類型,所以 Toggle 中對它的修改,會直接反應到當前 View 的 showFavoritesOnly 去設置它的 value。而 State 的 value didSet 將觸發 body 的刷新,從而完成 State -> View 的綁定。
在 Xcode 11 beta 1 中,Swift 中使用的修飾符名字是
@propertyDelegate,不過在 WWDC 上 Apple 提到這個特性時把它叫做了@propertyWrapper。根據可靠消息,在未來正式版中應該也會叫做@propertyWrapper,所以大家在看各種資料的時候最好也建議一個簡單的映射關系。如果你想要了解更多關於
@propertyWrapper的細節,可以看看相關的提案和論壇討論。比較有意思的細節是 Apple 在將相應的 PR merge 進了 master 以后又把這個提案的打回了“修改”的狀態,而非直接接受。除了@propertyWrapper的名稱修正以外,應該還會有一些其他的細節修改,但是已經公開的行為模式上應該不會太大變化了。
SwiftUI 中還有幾個常見的 @ 開頭的修飾,比如 @Binding,@Environment,@EnvironmentObject 等,原理上和 @State 都一樣,只不過它們所對應的 struct 中定義讀寫方式有區別。它們共同構成了 SwiftUI 數據流的最基本的單元。對於 SwiftUI 的數據流,如果展開的話足夠一整篇文章了。在這里還是十分建議看一看 Session 226 - Data Flow Through SwiftUI 的相關內容。
教程 5 - Animating Views and Transitions
Section 2 - Step 4: 兩種動畫的方式
在 SwiftUI 中,做動畫變的十分簡單。Apple 的教程里提供了兩種動畫的方式:
- 直接在
View上使用.animationmodifier - 使用
withAnimation { }來控制某個State,進而觸發動畫。
對於只需要對單個 View 做動畫的時候,animation(_:) 要更方便一些,它和其他各類 modifier 並沒有太大不同,返回的是一個包裝了對象 View 和對應的動畫類型的新的 View。animation(_:) 接受的參數 Animation 並不是直接定義 View 上的動畫的數值內容的,它是描述的是動畫所使用的時間曲線,動畫的延遲等這些和 View 無關的東西。具體和 View 有關的,想要進行動畫的數值方面的變更,由其他的諸如 rotationEffect 和 scaleEffect 這樣的 modifier 來描述。
在上面的 教程 5 - Section 1 - Step 5 里有這樣一段代碼:
Button(action: {
self.showDetail.toggle()
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.animation(nil)
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
.animation(.spring())
}
要注意,SwiftUI 的 modifier 是有順序的。在我們調用 animation(_:) 時,SwiftUI 做的事情等效於是把之前的所有 modifier 檢查一遍,然后找出所有滿足 Animatable 協議的 view 上的數值變化,比如角度、位置、尺寸等,然后將這些變化打個包,創建一個事物 (Transaction) 並提交給底層渲染去做動畫。在上面的代碼中,.rotationEffect 后的 .animation(nil) 將 rotation 的動畫提交,因為指定了 nil 所以這里沒有實際的動畫。在最后,.rotationEffect 已經被處理了,所以末行的 .animation(.spring()) 提交的只有 .scaleEffect。
withAnimation { } 是一個頂層函數,在閉包內部,我們一般會觸發某個 State 的變化,並讓 View.body 進行重新計算:
Button(action: {
withAnimation {
self.showDetail.toggle()
}
}) {
//...
}
如果需要,你也可以為它指定一個具體的 Animation:
withAnimation(.basic()) {
self.showDetail.toggle()
}
這個方法相當於把一個 animation 設置到 View 數值變化的 Transaction 上,並提交給底層渲染去做動畫。從原理上來說,withAnimation 是統一控制單個的 Transaction,而針對不同 View 的 animation(_:) 調用則可能對應多個不同的 Transaction。
教程 7 - Working with UI Controls
Section 4 - Step 2: 關於 View 的生命周期
ProfileEditor(profile: $draftProfile)
.onDisappear {
self.draftProfile = self.profile
}
在 UIKit 開發時,我們經常會接觸一些像是 viewDidLoad,viewWillAppear 這樣的生命周期的方法,並在里面進行一些配置。SwiftUI 里也有一部分這類生命周期的方法,比如 .onAppear 和 .onDisappear,它們也被“統一”在了 modifier 這面大旗下。
但是相對於 UIKit 來說,SwiftUI 中能 hook 的生命周期方法比較少,而且相對要通用一些。本身在生命周期中做操作這種方式就和聲明式的編程理念有些相悖,看上去就像是加上了一些命令式的 hack。我個人比較期待 View 和 Combine能再深度結合一些,把像是 self.draftProfile = self.profile 這類依賴生命周期的操作也用綁定的方式搞定。
相比於 .onAppear 和 .onDisappear,更通用的事件響應 hook 是 .onReceive(_:perform:),它定義了一個可以響應目標 Publisher 的任意的 View,一旦訂閱的 Publisher 發出新的事件時,onReceive 就將被調用。因為我們可以自行定義這些 publisher,所以它是完備的,這在把現有的 UIKit View 轉換到 SwiftUI View 時會十分有用。
