教程 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
上使用.animation
modifier - 使用
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 時會十分有用。