基礎的 demo地址
VStack
背景色
修改背景色用 background 修飾符
VStack {
...
}
.background(Color(red: 0xf2/255.0, green: 0xf2/255.0, blue: 0xf2/255.0))
Rectangle
或者用一個嵌入一個 Rectangle:
VStack {
Rectangle()
.fill(Color.green)
.frame(width: 200, height: 200)
.padding()
.border(Color.red)
}
圓角
此外,記得圓角一定要放在背景色之后設置,否則無效
.background(Color(red: 0xf2/255.0, green: 0xf2/255.0, blue: 0xf2/255.0))
.cornerRadius(10)
描繪邊框
通過 overlay 修飾符,可以在 View 上描繪(覆蓋)一層幾何圖形,比如圓角矩形(RoundedRectangle)、矩形(Rectangle)、圓形(Circle)等。
Image(...)
.overlay(RoundedRectangle(cornerSize: CGSize(width: 20,height: 20)).stroke(Color.gray,lineWidth: 4))
填充安全區
如果要讓舉行填充整個屏幕空間(包括安全區),可以:
Rectangle()
.fill(Color.black.opacity(0.6))
.edgesIgnoringSafeArea(.all)
分割線
Divider()
.background(Color.purple) // 默認顏色為灰色
.scaleEffect(CGSize(width: 1, height: 10)) // 高度放大10倍
.padding(Edge.Set.init(arrayLiteral: .top, .bottom), 20) // 上下內邊距設置20,
不能超過 10 個子 View 的限制
其實所有的容器(Container)都有這個限制,包括:
VStack
HStack
ZStack
Group
List
解決的辦法就是在容器中嵌套容器,當然每一層仍然不能超過10個,比如在 VStack 中嵌套 10 個 Group,然后在每個 Group 中又嵌套 10 個 View。
布局
padding
SwiftUI 的布局基于 padding。左留白:
.padding(.leading, 10)
兩邊留白:
.padding(.horizontal) // 或者 .padding([.leading, .bottom]),或者 .padding(.vertical,11)
四邊留白:
.padding(10.0) // 或者 .padding(),默認好像是 8?
offset 和 padding
設置圖片的坐標向上偏移:
Image(...).offset(x: 0, y: -130).padding(.bottom, -130)
offset 偏移的是圖片的內容,并不會移動圖片的 frame。這會導致雖然圖片顯示內容上移后,下方留下 130 像素的空白區域。要消除這個區域,我們使用了一個 padding 修飾符,讓下邊距扣減 130。
所以在實際開發中,offset 和 padding 需要組合使用。
Image
常用 Modifier:
1. cornerRadius(3.0) 圓角
2. resizable() 允許修改原圖大小,這個修飾符必須放在其它修改 frame(包括 frame 和縱橫比)之前,否則其它修飾符無效
3. apsectRatio(contentMode: ) 修改 content mode。scaleToFit/scaleToFill 是這個方法的便利方法
4. frame(minWitdh: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) 寬高自適應 0~∞,等同于占滿全屏
5. frame(width:40, height: 40) 設置固定大小
6. clipped() 修改 frame 之后還必須調用這個修飾符,否則圖片的內容又可能超出 frame
7. tapAction { self.zoomed.toggle() } 觸摸事件處理
8. clipShape(Circle()) 裁剪成圓形
9. overlay(Circle().stroke(Color.white,lineWidth: 4)) 覆蓋(描繪)白邊
10. shadow(radius: 10) 陰影
11. listRowInsets() 如果 Image 位于 List 中,Image 四邊會自動帶有 padding,如果需要去掉這個 padding,需要應用 listRowInsets(EdgeInsets())
字體圖標
Image 類可以使用系統內置的系統圖標,這些系統圖標可以通過一個 SF Symbols 的 App (MacOS) 來查找。由于是字體圖標,你可以修改字體的大小和顏色,比如:
Image(systemName: "person.crop.circle").imageScale(.large).color(.gray)
NavigationView 和 navigationBarTitle
相當于 UIKit 中的 Navigation View Controller。用于將某個 View 包裹在一個 Navigation View Controller并提供了 Navigation Bar。
NavigationView() {
List(){ ... }.navigationBarTitle(Text("一個 Title"), displayMode: .inline)
}
上面的代碼在 List 的外面套了一個 NavigationViewController,同時定義了一個導航欄,使用傳統樣式的標題(文字居中)。
注意,navigation bar 并不是在 NavigationView 上進行設置,而是在它所包裹的 View 上設置。這類似于在 ViewController 上設置 navigation Bar 而不是在 NavigationViewController 上設置。從這里也可以看出,SwiftUI View 就相當于 UIKit 的 UIView。
在 iPad 上的 NavigationView
這是 NavigationView 的一個坑,默認情況下 NavigationView 在 iPad 上是以 split view 的方式顯示的,因此它和 iPhone 上看起來不一樣!
要改變這點,需要手動設置 navigationViewStyle :
NavigationView {
...
}.navigationViewStyle(StackNavigationViewStyle())
NavigationLink 導航組件
跳轉按鈕,允許跳轉到另外一個頁面:
NavigationLink(destination:Text("下一頁")) {
Image(...)
}
NavigationLink() 的第一個參數 destination 是一個SwiftUI View,指向要跳轉到的那個頁面,第二個參數 label 是一個 block,這個 block 返回的也是一個SwiftUI View,用來定義按鈕的外觀,可以是一個 Text ,也可以是一個 Image。
NavigationLink 的另一種用途是使用它來作為一種導航,就像 UIKit 中的 Segue,它不一定有 UI(你看不見它),但是你可以通過它,讓其它 UI 控件也能實現頁面的導航。
首先你需要一個 @State 屬性,來控制某個頁面的顯示與隱藏:
@State var pushActive = false
然后你需要一個“不可見”的 NavigationLink 來充當這個導航 Segue:
NavigationLink(
destination: MechanicalCheckView(viewModel: MechanicalCheckViewModel()),
isActive: self.$pushActive
) {
EmptyView()
}.hidden()
這里,除了 destintaion 和 label 參數外,還多了一個 isActive 參數,用來綁定 pushActive 屬性(使用 $ 關鍵字)。當 pushActive 被改變時,UI 會顯示完全不同的結果(綁定了刷新動作)。當 pushActive 默認值為 false 時,此導航不會發生,頁面顯示之前的頁面。一旦將 pushActive 修改為 true,NavigationLink 的導航會生效,也就是顯示第二個頁面。同時,這個 NavigationLink 不需要顯示,因此它的 label 是一個空白視圖(EmptyView 不會占用屏幕空間)。同時用 hidden() 進行了隱藏。
什么時候發生 push 導航?那是由另一個控件(比如普通 button)來觸發的,觸發導航的方式很簡單,修改 pushActive = true 即可:
Button(action: {
pushActive = true
}, label: {
...
})
這就是 NavigatoinLink 的真實用途,它并不僅僅是用于在頁面上顯示一個button,而是用來代替 UIKit 的 segue 組件。
dismiss 返回
struct DestinationView: View {
// 聲明屬性presentationMode
@Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var body: some View {
Text("Destination View")
.onTapGesture {
// 返回
self.presentationMode.wrappedValue.dismiss()
}
}
}
@Environment將全局變量綁定到本地屬性。
ScrollView
ScrollView 控制水平布局還是垂直布局是方式是在內部使用 HStack 或 VStack:
ScrollView(.horizontal, showsIndicators: false){
HStack(spacing: 15) {
ForEach(...){
...
}
}
}
這里 .horizontal 不是控制水平布局的,僅僅是指定允許手指滑動的方向,showsIndicators 指定是否顯示滾動條。
自定義導航欄/工具欄
自定義導航欄其實就是自定義工具欄,因為你可以先隱藏導航欄,然后在設置一個工具欄代替它:
content
.navigationBarBackButtonHidden(true)
.navigationBarTitleDisplayMode(.inline)
.toolbar(content: buildToolbar)
接下來看具體怎么實現。
實現 ViewModifier
新建 swift 文件 Toolbar.swift。首先繼承 ViewModifier:
struct FvtToolbar: ViewModifier {
var title: String
var rightTitle: String
var rightAction: ()->()
init(_ title:String, _ rightTitle:String, _ rightAction:@escaping ()->()){
self.title = title
self.rightTitle = rightTitle
self.rightAction = rightAction
}
}
3 個屬性分別是:
*title 工具欄中間的標題
*rightTitle 工具欄右按鈕的標題
*rightAction 工具欄右按鈕的點擊事件處理回調
*init 方法負責初始化它們。
作為一個 ViewModifier,最重要的方法就是 body 方法:
func body(content: Content) -> some View {
content
.navigationBarBackButtonHidden(true)
.navigationBarTitleDisplayMode(.inline)
.toolbar(content: buildToolbar)
}
content 參數實際上就是工具欄所在的 View ,將由 modifier 方法傳入。我們先隱藏了 back button 和導航欄原來的標題,然后用 toolbar(content:) 方法設置一個工具欄。其中 buildToolbar 是一個特殊 ToolbarContentBuilder 結構體的實例。我們無需真的去定義一個 ToolbarContentBuilder,而是只需定義一個能夠返回 ToolbarContent 的方法即可:
@ToolbarContentBuilder
func buildToolbar() -> some ToolbarContent {
ToolbarItem(placement: .principal) {
Text(title)
.font(.system(size: 17, weight: .bold, design: .default))
}
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
rightAction()
}) {
Text(rightTitle)
.font(.system(size: 17, weight: .regular, design: .default))
}
}
}
@ToolbarContentBuilder 注解將方法包裝成 toolbar 方法要求傳入的 ToolbarContentBuilder 結構。而這個方法對方法名和參數都沒有要求,但對返回值,要求是 ToolbarContent,即一個 ToolbarItem 的集合,同時這個集合可以通過所謂的“多表達式閉包“得到。所謂的”多表達式閉包“,如同 body 方法,一個閉包中會包含多個 swift 表達式,swift 將這些表達式包裝在一個集合中作為閉包的返回值。
擴展裝飾器方法
為了便于使用,我們可以為 View 擴展出一個裝飾器方法:
extension View {
func fvtToolbar(_ title:String, _ rightTitle:String, _ rightAction:@escaping ()->()) -> some View {
return modifier(FvtToolbar(title, rightTitle, rightAction))
}
}
fvtToolbar 方法接受 3 個參數,分別是 title、rightTitle 和 rightAction。返回一個 View。方法的返回值必須用 modifier 方法包裹,以支持 View 的鏈式調用。此外,modifier 會將自己的 content 傳遞給所包裹的對象。被包裹的對象必須是一個 ViewModifier
比如FvtToolbar,它就實現了 ViewModifier (一個協議)。這樣 ,modifier 會調用它的 body 方法,從而實現對目標視圖的修改(比如將導航欄用我們提供的 Toolbar 代替)。
調用擴展的裝飾器
在視圖的 body 屬性的最外層根視圖使用該裝飾器
.fvtToolbar("MECHANICAL_CHECK_TITLE".localize,
"NAVIGATION_BUTTON_CANCEL".localize){
...
}
這樣,視圖會呈現一個由文字標題欄和取消按鈕構成的 toolbar。
EditButton
SwfitUI 內置了編輯按鈕:
EditButton().padding()
SegmentedControl
SegmentedControl 是一個容器,里面包裹了多個 Text:
SegmentedControl(selection: $profile.prefersSeason) {
ForEach(User.Season.allCases.identified(by: \.self)){
season in
Text(season.rawValue).tag(season)
}
}
$profile.prefersSeason 進行了雙向綁定,其中 profile 是一個視圖狀態(@State)。ForEach 循環總是需要Sequence對象有一個唯一 id。
Identified(by:)
SwiftUI 的 List 在循環一個數組時,要求數組元素要么實現了 Identifiable 協議(其實就一個 id 屬性),要么調用 identified(by:) 方法:
List(landmarkds.indetified(by: \.id))()
\.id
或\.self
是swift 的 keypath 語法。
綁定視圖的刷新 - @State
視圖狀態綁定了刷新操作,當視圖中的 @State 屬性被修改,自動觸發頁面的刷新(調用 body 塊)。
@State var zoomed = false
@State 還帶來了另外一個效果,就是改變結構體成員的可變性。結構體跟類不同,普通成員函數中,無法對自身結構體的屬性進行賦值操作,比如如下代碼:
func present(isPresented: Bool) -> some View {
isShow = isPresented
return self
}
編譯器報錯:Cannot assign to property: ‘self’ is immutable。以往的解決辦法是在函數前用 mutating 修飾:
mutating func present(isPresented: Bool) -> some View {
...
}
但現在不用了,直接在 isShow 前面加一個 @State:
@State var isShow: Bool = false
@Published 屬性包裹器
類似于 @State,@Published 允許你將被修飾的屬性綁定指定視圖的刷新操作。不同的是 @State 的綁定動作是自動的,默認就是綁定到 @State 屬性所在的 View,但 @Published 需要你手動綁定到指定的 View。換句話說,@Published 屬性可以用于任何 View ,但 @State 只能用于當前 View。
如果要讓某個類中的屬性能夠綁定視圖刷新操作,這個類必須實現 ObservableObject 協議:
class Bag: ObservableObject {
var items = [String]()
}
如果你想讓 items 屬性能夠綁定視圖,則用 @Published 修飾它:
@Published var items = [String]()
這個語法糖會自動添加 willSet 屬性監聽器方法。
這樣,Bag 就變成了一個 ObservableObject 對象。你可以在任意 View 中綁定它。只需在這個 View 中使用 @ObservedObject 聲明一個 Bag 屬性:
struct ContentView: View {
@ObservedObject var bag = Bag()
var body: some View {
...
}
}
注意 @ObservedObject 關鍵字的使用,它將 bag 對象包裝成外部可訪問和修改的。這點和 @State 不同,@State 的對象一般是 private 的。這樣,當你修改 bag 中的 items 值時,會觸發 ContentView 更新。
View 的生命周期
類似于 UIViewController 的 viewDidAppear/viewDidDisappear, SwiftUI View 也有對應的生命周期方法:
.onAppear {
// viewModel.startSNRCheck()
viewModel.showRemovingStep = true
}
.onDisappear {
// viewModel.startSNRCheck()
viewModel.showRemovingStep = true
}
還有一個特殊的 onReceive 方法:
.onReceive(viewModel.$isTestSucceeded) { testResult in
isTestSucceeded = testResult
}
這個方法主動監聽某個 Observable 對象的 published 屬性,如果值發生變化,調用指定的塊,塊參數為新值。
SwiftUI 的動畫
withAnimation
當某個 @State 屬性被改變時,頁面改變,同時讓這種改變以動畫方式進行,那么可以將該屬性的修改代碼包裹在 withAnimation 塊中:
withAnimation(.basic(duration:1)){
self.zoomed.toggle()
}
Preview 可能看不到動畫效果,需要在模擬器里面執行。withAnimation 的動畫會影響整個 View,因為 @State 的刷新會導致整個 body 刷新。
transition
此外還有另一種 SwiftUI 動畫 Transition ,則是單獨對某個 View 執行動畫:
Text("...").transition(.move(edge.trailing))
.move 指定該動畫是平移,并且視圖將從(from)屏幕的右側(trailing)滑入。注意 .move() 的參數實際是 from,to 就是當前位置。
變形恢復
可以讓 View 恢復到變形(縮放、平移、旋轉)之前 :
.transition(.identity)
旋轉和縮放
旋轉使用修飾符 rotationEffect,縮放使用修飾符 scaleEffect:
Image(...)
.rotationEffect(.degrees(showDetaiol ? 90 : ))
.scaleEffect(showDetail ? 1.5 : 1)
如果在切換 showDetail 狀態時使用了 withAnimation 塊,則旋轉和縮放將以動畫方式執行:
withAnimation(.spring()){
self.showDetail.toggle()
}
animation
transition 是幾何變形,但要讓這個變形能夠以動畫方式進行,需要用到隱式動畫 animation。在 SwiftUI 中,withAnimation 叫做顯式動畫,animation 叫做隱式動畫。animation 修飾符和 transition 修飾符一樣,也是作用于特定的 View(而不是 body 中的所有 view),它支持多種動畫特效,比如彈簧動畫:
.opacity(isShow ? 1 : 0)
.animation(.spring())
當然也可以勻速進行并指定動畫時長 duration:
.animation(.linear(duration: 2))
GeometryReader
此外需要注意,隱式動畫會影響到所有動畫屬性,包括位置,也就是說,隱式動畫一創建時會默認位置從左上角開始(0,0)。因此為了讓它不影響到我們的位置,我們需要用到 GeometryReader:
var body: some View {
GeometryReader { geometry in
VStack{}
.opacity(isShow ? 1 : 0)
.animation(.spring())
.frame(width: geometry.size.width, height: geometry.size.height, alignment: .center)
}
這樣它在動畫開始時將 frame 設置到布局時的原始位置(寬高、中心都等于 geometry)而不是左上角(0,0)。
animation 的位置
animation 修飾符的位置非常重要,它讓之前的動畫特效生效。如果你把它放到一個 scaleEffect 之前執行,那么 animation 沒有任何意義:
.animation(.easeIn(duration: 2))
.scaleEffect( isPresented ? 1: 0)
因為在 animation 之前沒有任何特效設置語句。你必須把 scaleEffect 放到前面執行:
.scaleEffect( isPresented ? 1: 0)
.animation(.easeIn(duration: 2))
animatino 取消
animation 還有一種特殊用法 animation(nil) ,表示取消之前的動畫,例如:
Image(...)
.rotationEffect(.degrees(showDetaiol ? 90 : ))
.animation(nil)
.scaleEffect(showDetail ? 1.5 : 1)
.animation(.spring())
此時,旋轉動畫將被清除(注意,僅僅是不以動畫方式執行,但旋轉仍然有效,只不過是瞬間旋轉)。但在 animation(nil) 之后的縮放動畫和彈簧動畫仍然執行。
不對稱動畫
不對稱動畫屬于 transition 動畫中的一種,但是它的進入和退出動畫是非對稱的。對于對稱的過渡動畫(轉場動畫,transition 動畫),它的入場方式和出場方式是對稱的,比如從左邊進入,從左邊退出。一般退出動畫和進入動畫是做相反運動,在這種情況下,我們只需指定入場方式即可,出場方式 SwiftUI 會自動根據入場方式計算。但對于非對稱動畫,Swift UI 無法通過入場方式推斷出場方式,因此你必須同時指定入場動畫和出場動畫:
Image(...)
.transition(
.asymmetric(
insertion: .move(edge:.trailing),
removal: .scale
)
)
這里,Image 將從屏幕右側滑入,但退出時則是逐漸縮小至不可見。
組合動畫
combined 修飾符可以將兩個動畫組合成一個,形成一種1+1的效果:
Image(...)
.transition(
AnyTransition.move(edge:.trailing).combined(with:.opacity)
)
從右邊滑入+漸入效果。
AnyTransition
transition 修飾符中用了一個參數,比如我們用過的 move,scale,opacity 等,它們代表了不同的 transition 動畫特效,但無一例外,統統都是 AnyTransition 類型。我們實際上可以自己擴展 AnyTransition 類型,從而定義自己的動畫特效:
extension AnyTransition {
static var moveAndScale: AnyTransition {
AnyTransition.move(edge: .trailing).combined(with: .opacity)
}
static var myTransition: AnyTransition {
AnyTransition.asymmetric(
insertion: .move(edge:trailing),
removal: .scale()
)
}
}
波浪動畫和 delay
波浪動畫實際上就是多個動畫延遲執行。比如有10個動畫(可以相同或不同),一個接一個地執行,在上一個動畫執行后若干秒,又啟動下一個動畫,以此類推。
Image(...)
.animation(Animation.spring(initialVelocity: 5).speed(2).delay(Double(index)*0.03))
speed(2) 讓動畫以2倍速執行,delay 將 image 延遲若干秒,延遲的時間將根據 Image 在數組中的索引而相應遞增。
SwiftUI View 轉換成 UIViewController
要將一個 SwiftUI 的 View 對象轉換成 一個傳統的 UIKit 的 UIViewController,可以使用 UIHostingController 類:
window.rootViewController = UIHostingController(rootView: HomeView())
where 范型約束
約束返回結果必須遵循某種規范(協議)。
func max<T>(_ x: T, _ y: T) -> where T: Comparable
還有另外一種寫法:
func max<T: Comparable>(_ x: T, _ y: T) -> T
Some 不透明類型
在 View 協議中,body 屬性被定義為:
var body:Self.Body
這里,body 的類型是 Self.Body,而 Body是一個 associatedType,也就是 View 的別名。
而 Swift 中規定,如果一個協議中使用了 assoiatedType 或者 Self 關鍵字,那么此協議不能作為返回類型。
Self.Body 語法是為了引用 View 協議中的 associatedType。這里 Self 有點類似于 class 中的 self,都是引用“非靜態”的成員。
但是 Self 表示“實現類的實例“,即實現了該協議的子類的實例而非簡單的實例(因為協議是不能實例化的,而實現類可以)。
簡而言之,Self 專門用于協議引用自身成員變量。
所以我們在自己的 View 中, body 的類型如果寫成 Self.body 是無法編譯的,我們可以用具體類型(不能是協議),但是這個類型必須實現了 View ,這是 Body 這個類型別名所規定的,在 Body 規定了這個類型必須是 View:
associatedType Body: View
這樣實際上就要求了 body 屬性必須是具體的某種 View 類型。那么我們可以這樣寫:
var body: Text {
Text("...")
}
指定了返回類型為 Text,因為實際上返回的就是一個 Text。當然更進一步,我們寫成了這樣
var body: some View {
Text("...")
}
這里 some 表示“某一種”,不管 Text 也好,List 也好,它們都是 View ,符合這個條件。這樣,就不必要那么具體,只需要是“某一種 View“即可。
所以 some View 表示了一種含義:某種類型(不是協議),但又不是具體的類型,而是泛指一批類型中的任意一個,這就是不透明類型。
UIView 轉換成 SwiftUI View
只需遵循 UIViewRepresentable 即可:
struct MapView: UIViewRepresentable {
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
return mapView
}
func updateUIView(_ uiView: MKMapView, context: Context){
uiView.setRegion(
MKCoordinateRegion()
center: ...,
span: ...
),
animated: true
}
}
然后可以在 View 中使用它:
MapView()
UIView Controller 轉換成 SwiftUI View
很多時候 SwiftUI View 對應的是 UIKit 中的 UIViewController 而不是 UIView。同時,在 UIKit 中有許多 UI View Controller 并不能在 SwiftUI 中找到對應的 View,這就需要我們自己把 UIViewController 轉換成 SwiftUI View。
比如SwiftUI 沒有提供 PageViewController 類似的 View,你需要手動將 PageViewController 轉換成 SwiftUI View。
UIViewControllerRespresentable
首先定義一個新的 SwiftUI View 讓它遵循 UI ViewControllerRepresentable 并實現兩個方法:
struct PageVC: UIView: UIViewControllerRepresentable {
let pages = featuredLandmarks.map {
UIHostingController(rootView: Image($0.imageName))
}
func makeUIViewController(context: Context) -> UIPageViewController {
let pageVC = UIPageViewController(transitionStyle:.scroll, navigationOrientation: .horizontal)
}
func updateUIViewController(_ uiViewController: UIPageViewController, context: Context){
uiViewController.setViewController(
[pages[0]],
direction: .forward,
animated: true
)
}
}
其中需要注意 UIHostingController 的使用,它將 SwiftUI View 轉換成了 UIViewController。
UIPageViewController 使用委托模式,它把數據源委托給 dataSource 進行,所以我們還需要創建一個 DataSource 的類并實現委托方法:
class PageDataSource: NSObject, UIPageViewControllerDataSource {
let pages: [UIViewController]
init(pages: [UIViewController]){
self.pages = pages
}
// 前進(左滑)
func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore: UIViewController) -> {
let index = pages.firstIndex(of:viewController)!
return currentIndex == 0 ? pages.last : pages[index - 1]
}
// 后退(右滑)
func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore: UIViewController) -> {
let index = pages.firstIndex(of:viewController)!
return currentIndex == pages.count -1 ? pages.first : pages[index + 1]
}
}
Coordinator
然后設置上 page controller 的 dataSource 屬性:
func makeUIViewController(context: Context) -> UIPageViewController {
...
pageVC.dataSource = PageDataSource(pages: pages)
return pageVC
}
事實上 Xcode 會在這里給出一個警告,Instance will be immediately deallocated because property ‘dataSource’ is ‘weak’ 意思是 .dataSource 是一個弱引用,你不能這樣直接把一個 DataSource 對象賦值給它,而要采取特殊的方法。
SwiftUI 采用另外一種方法將傳遞數據給 UIViewController weak 屬性(比如 delegate 和 dataSource),叫做 Coordinator。在PageVC 需要實現另外一個協議方法:
func makeCoordinator() -> PageDataSource {
return PageDataSource(pages: pages)
}
然后你可以這樣賦值給 dataSource 屬性
pageVC.dataSource = context.coordintator
這樣就將簡單變量(pages )變成了函數調用(指針),這樣無論何時調用 .dataSource,你都有一個 condinator 可用(因為 context.condinator 會調用 makeCoordinator 方法)。
隱藏狀態欄
狀態欄屬于安全區的內容,因此可以通過取消頂部安全區來隱藏狀態欄:
MapView().edgesIgnoringSafeArea(.top).frame(height:360)
注意,edgesIgnoringSafeArea 修飾符必須位于 frame 等布局修飾符之前。否則會導致 frame 計算不正確。
自定義畫布大小
對于 cell,通常不需要在預覽區展示完整畫布,只需展示 cell 差不多大小的畫布即可。這可以通過 previewLayout 修飾符來實現:
static var previews: some View {
LandmarkCell().previewLayout(.fixed(width:300, height: 70))
}
預覽設備型號
可以用 previewDevice 設置預覽設備的型號:
LandmarkList().previewDevice(PreviewDevice(rawValue:"iPhone 8")).previewDisplayName("iPhone 8")
rawValue 的寫法跟 target 下拉列表中列出的保持一致。previewDisplayName 指定設備屏幕預覽下方的標題。
視圖狀態的雙向綁定 - $ 關鍵字
對于某些 View,可以將視圖的屬性和一個 Observable 變量雙向綁定。比如 Toggle 控件 的 isOn 屬性:
Toggle(isOn: $showFavoritesOnly) { ... }
這里,showFavoritesOnly 是一個 @State 屬性:
@State private var showFavoritesOnly = false
什么叫做雙向綁定呢?當我們將 Toggle 的 isOn 屬性 和 一個 Binding 對象進行綁定后(也就是調用 Toggle(isOn:) ),每當 isOn 屬性被改變(比如用戶的 UI 操作),則 Binding 對象所包裹的 T 對象會被改變。相反,當 Binding 對象所包裹的 T 對象改變,isOn 屬性也會被改變。
實際上這里的 showFavaoritesOnly 就 是 一 個 Binding<T>類 型 的 對 象 。 showFavoritesOnly 就是一個 Binding<T> 類型的對象。showFavoritesOnly就是一個Binding<T>類型的對象。是一個語法糖,它幫我們將 showFavoritesOnly(Bool 類型對象)轉換成一個 Binding 類型。
實際上這里還有第三個方向的綁定,這就是 showFavoritesOnly 的 @State 綁定,@State 是另一個語法糖,它綁定了刷新操作——可以想象成自動生成了 setter 方法代碼并觸發刷新操作。這樣,每當 showFavoritesOnly 屬性改變,就會觸發 View 的刷新動作。
環境對象
所謂環境對象,借用 Java IoC 中的話說,就是容器對象,或者依賴注入對象、容器托管對象。如果我們把整個 App 看成是 IoC 容器,那么我們可以向容器中注入環境對象,然后在需要的時候使用它。用于它是受 App 容器管理的,所以它的生命周期可以和 App 一樣長,并且對 App 中的所有其它對象來說是可見的,因此可以把它當成全局對象使用。
注入環境對象
首先新建一個 SwiftUI 應用。在 ContentView 中,添加一個 NavigationLink,讓它跳轉到第二個 View : SecondView。然后在 SecondView 中添加一個 NavigationLink,讓它跳到第三個 View: ThirdView。
然后我們在 App.swift( 或者 SceneDelegate.swift)中,注入一個環境對象:
ContentView().environmentObject(AppState())
ContentView 是我們的第一個View(根視圖),我們在初始化 ContentView 之后隨即調用它的 environmentObject 方法注入了一個 環境對象,這個環境對象要求必須是一個可觀察對象(ObservableObject):
class AppState: ObservableObject {
@Published var rootViewShowing = false
}
AppState 遵循 ObservableObject 協議,這樣它的值被改變時可以向外界發射通知并被其它對象所感知。AppState 目前只有一個 @Published 的 Bool 屬性。@Published 會將這個屬性的變化通知到其它對象——這點和 @State 類似,但不同的是 @State 只能用在 View 自身,同時自動將屬性綁定到刷新操作。但 @Published 可以用在任意 class(不能用于 struct),同時綁定 View 的刷新操作。
引用環境對象
如同 Java Spring 的 @Autowired 注解,環境對象需要你自己去引用它。假設我們在第三個視圖 ThirdView 中使用到環境對象,那么我們需要在 ThirdView 中引用它:
@EnvironmentObject var appState: AppState
這里 @EnvironmentObject 屬性包裝器類似于 @Autowired 注解,將容器中環境對象取出放入到成員變量中。
這樣,你就可以在 ThridView 中使用這個環境變量了。我們可以在 body 中添加兩個控件分別用來顯示和修改它的值:
Text(appState.rootViewShowing ? "true" : "false")
Button("change rootViewShowing") {
appState.rootViewShowing.toggle()
}
運行 App,跳轉到第 3 頁,Text 上顯示了“false“,但當你點擊 Button,Text 上文字會隨之改變,這說明我們不僅可以修改環境變量,也可以及時感知它的變化并綁定視圖刷新。
同時當環境變量被改變時,所有引用它的視圖都能同時獲得刷新通知,并非僅僅是某個視圖。為了驗證這一點,你可以將上面的代碼同樣拷貝到 ContentView 和 SecondView 中,看在某個 View 中改變 rootViewShowing 是否導致所有的視圖刷新。
返回根視圖
我們設計 rootViewShowing 的目的,其實是為了讓第三個視圖直接返回到根視圖,而不用兩次 back。那么我們可以在第一個視圖 ContentView 中,找到 NavigationLink 的代碼,原來的代碼是這樣:
NavigationLink("Second view") {
SecondView()
}.navigationBarTitle("first view")
修改為:
NavigationLink(destination: SecondView(), isActive: $appState.rootViewShowing) {
Text("second view")
}.navigationBarTitle("first view")
我們使用了 NavigationLink() 的 isActive 參數。這是一個奇特的參數,如果你將這個參數和一個可觀察變量進行雙向綁定,那么表示當用戶點擊按鈕時,這個變量會被同步地改變為 true,同時跳轉到下一頁;如果用代碼將這個變量改變為 false,則 NavigationLink 會返回到此頁(也就是 ContentView)。
運行 app,跳轉到 ThirdView,你會發現此時 Text 顯示為 “true”,說明 isActive 參數已經生效,rootViewShowing 已經由 false 變成了 true。點擊 Button,直接跳轉到 ContentView 頁,同時 Text 顯示為 false,說明rootViewShowing 已經由 true 變成了 false。。
窗體間傳值
當一個窗體切換到另一個窗體(比如一個編輯窗口),那么編輯窗口中所做的修改必須傳回第一個窗口,這就是窗體之間的反向傳值。一般可以用 環境對象來實現,它是全局對象,整個 app 生命周期中都可用,因此它常用于窗口之間傳值,無論是正向還是反向。
首先需要定義一個 BindableObject 的類:
class UserData: BindableObject {
var didChange = PassthroughSubject<UserData,Never>()
var userLandmarks = landmarks {
didSet {
didChange.send(self)
}
}
}
這里,PassthroughSubject 是 Combine 為我們提供的一個 Subject 封裝,包含兩個范型參數,第一個是要存放的數據類型,這里當然就是 UserData 類自己了,第二個是一個 Failure 類型,發生錯誤時可能返回的錯誤類型,這里使用 Never,就是假定永遠不會 send 任何錯誤——因為我們不需要它。簡單地說,didChange 是一個發布者,它會發送兩種類型的數據,因為第二種數據類型是 Never,將被忽略,所以實際上它只會發送一種類型,也就是 UserData。如果你熟悉 Promise 或者 Stream,那么這些概念自然熟悉。
userLandmarks 的 setter 方法執行了一個綁定。一旦 userLandMarks 數組發生了任何改變,則通過 didChange 發送 self 給(所有)訂閱者。
發布者有了,接下來就是訂閱它,在要用到環境對象的類中,引用環境對象:
@EnvironmentObject var userData: UserDatas
環境對象類似于全局對象,它在所有視圖中都可以用,方便我們在窗體之間傳遞數據。同時,它是一個增強版的 @State 變量,一旦它被改變,視圖會被刷新。它不需要顯式地訂閱,直接在視圖中使用就可以了:
ForEach(userData.userLandmarks) { ... }
在編輯頁面,同樣需要聲明這個東西,因為我們需要操作它的數據:
@EnvironmentObject var userData: UserData
...
Button(action: {
let index = self.userData.userLandmarks.irstIndex(where: {$0.id == landmark.id})!
self.userData.userLandmarks[index].isFavorite.toggle()
}){ ... }
然后來實例化(注入)這個 UserData。在LandmarkList 視圖初始化的同時用 environmentObject 方法注入UserData:
LandmarkList().environmentObject(UserData())
然后在 LandMarkDetail 中直接引用 UserData 環境變量(不用初始化):
@EnvironmentObject var userData: UserData
可以看到環境變量具有一次初始化(注入),隨處使用的特點。
注意,@EnvironmentObject 和 @Environment 無關,二者是不同的概念。@Environment 表示從運行時環境中獲取配置,比如獲取 iPhone 是否運行在暗夜模式,屏幕大小等:
@Environment(\.horizontalSizeClass) var horizontalSizeClass
@Environment(\.managedObjectContext) var managedObjectContext
而 @EnvironmentObject 則表示這個屬性是一個環境變量,它的初始化/注入是通過 environmentObject() 方法,一旦你用 environmentObject 注入后,你就可以在任何地方引用和使用它。
視圖編輯狀態
視圖的編輯狀態保存在 App 的環境變量,你可以通過 @Environment 語法糖獲取:
@Environment(\.editMode) var mode
這樣就將環境變量中的 editMode 變量和 mode 綁定。你可以通過 mode 來判斷當前視圖是否處于編輯狀態,并進行不同的界面展示:
if mode?.value == .inactive {
...
}else {
...
}
這里的問題在于,展示數據必須和編輯的數據單獨區分,也就是說,在一開始我們需要從展示數據拷貝一份做為編輯數據,將編輯數據綁定到編輯界面的視圖上,這樣用戶編輯時不會錯誤地修改了顯示的數據,而是在單獨的數據中編輯,如果用戶最終點擊了 Done,那么將編輯數據復制給顯示數據,這樣切換回瀏覽視圖后 UI 回做相應改變,如果用戶點擊了 Cancel,那么拷貝不會進行,切換回瀏覽視圖后 UI 保持原樣。
@State private var profile = User.default
@State private var profileCopy = User.default
這里,編輯數據 profileCopy 也需要是 @State。當 EditButton 被點擊,切換 mode :
Button(action:{
self.mode?.value = .inactive
self.profile = self.profileCopy
}){
Text("Done")
}
EditButton().padding()
注意,mode 是一個封裝對象,它內部封裝了 .editMode 環境變量。所以進行修改操作時,我們不直接修改 mode 對象,而是修改 mode?.value。@Environment 修飾符和 @State 類似,同樣綁定了視圖刷新操作,一旦我們修改了 mode,頁面得到刷新,從編輯狀態恢復到瀏覽狀態。
此外,Done 按鈕是另外一個普通按鈕,并不是 EditButton。這里,當 Done 按鈕被按下,我們拷貝了編輯后的數據,這樣編輯過的數據才會生效。EditButton 不需要做什么,因為我們拋棄了編輯數據。
視圖的生命周期
View 類似于 UI ViewConroller,當然也有生命周期。比如我們需要在編輯視圖消失的時候做一些事情,可以用 onDisappear 修飾符(相當于 viewWillDisappear 方法)中,將編輯數據恢復原狀:
.onDisappear {
self.profileCopy = self.profile
}
動畫屬性
對于動畫屬性,可以在屬性被改變時增加動畫特效,比如 mode 屬性:
self.mode?.animation().value = .inactive
@Binding
@Binding 表示某個屬性/變量從外部傳入一個引用,無論 struct 還是 class。
struct AddView: View {
@Binding var isPresented: Bool
var body: some View {
Button("Dismiss") {
self.isPresented = false
}
}
}
這里 isPresented 相當于一個指針,當你在這個 View 中對它賦值為 false 時,它原來(外部的某個 Bool 值)也就變成 false 了。
當然在外部,初始化 AddView 時,你需要這樣初始化 isPresented 的值:
AddView(isPresented: $showingAddUser)
注意 $ 關鍵字的使用,它將某個類型轉換成 Binding 類型,這里就是將 showingAddUser (Bool 類型)變成了 Binding。
.constant 綁定
@Binding 不僅僅可以綁定變量,也可以綁定常量,比如上面的 AddView.init(isPresented: ),不僅僅可以傳一個 $showingAddUser 變量,也可以直接傳一個 Bool 值給它:
AddView(isPresented: .constant(true))
或者任意類型的常量:
ProfileEditor(profileCopy: .constant(User.default))
初始化 Binding 屬性
在自定義 init 方法中初始化一個 @Binding 屬性需要一點技巧,因為 @Binding 屬性本質上是計算屬性,不能直接對他進行賦值,而是要通過對應的實例變量進行。在每個@Binding 屬性底層,都有一個隱藏的的實例變量,該實例變量的命名規則是在屬性名前加一個$(swift3)或下劃線 _ (swift4)前綴:
struct AmountView : View {
@Binding var amount: Double
private var includeDecimal = false
init(amount: Binding<Double>) {
// self.$amount = amount // swift 3
self._amount = amount // swift 4
self.includeDecimal = round(self.amount)-self.amount > 0
}
}
改變視圖狀態
SwiftUI 規定,View 只能是 struct,而struct 是值而非對象,它潛在地就是一種不可變的“常量”,比如說你無法從結構體內部修改它的屬性(除非 mutating )。而從外部修改一個結構體的屬性,只會產生一個新的結構體實例。
那么如何從外部改變視圖的狀態?首先需要把這個屬性定義為一個@Binding 屬性,這個屬性就會變成一個引用。也就是說這個數據放在了 View 之外的地方,而非和 View 位于同一塊內存空間,它僅僅是記錄了數據的地址。
struct AlertView: View {
@Binding var isShow: Bool
然后需要在外部,比如另一個 View 中,找一塊內存來真正存放這個 Bool 值。也就是在其它 struct 中定義一個Bool 屬性,這樣數據才有地方放:
struct ContentView: View {
@State private var isAlertPresented = false
然后在初始化 AlertView 的時候把 isAlertPresented 的地址傳遞進去,這樣當我們修改 isAlertPresented 的時候,相當于 AlertView 中的 isShow 也就被修改了:
AlertView(isShow: $isAlertPresented, title: "error", detail: "come on")
在 AlertView 中,你可以根據 isShow 的值來顯示、隱藏 View:
var body: some View {
if isShow {
Rectangle()
.fill(Color.black.opacity(0.6))
.edgesIgnoringSafeArea(.all)
}
這也是 SwiftUI 中唯一能夠動態切換視圖顯示/隱藏的方法。因為 hidden() 裝飾器僅能隱藏,不能顯示。
更進一步,我們可以控制視圖中部分子視圖執行某些動畫:
VStack()
.scaleEffect(isShow==true ? 1 : 0)
.animation(.spring())
isShow = true 時顯示原大小,= false 時縮放到 0。這也是 SwiftUI 中唯一控制部分視圖(非全部)狀態的方法。我們不能直接動態地操縱視圖的外觀、顏色、位置、大小,一切都要通過狀態,也就是 ViewModel 來做。
數組分組
要將一個數組分成不同的組,每個組有一個 key,則可以用 Dictionary 的自帶分組功能:
let categories = Dictionary(grouping:landmarks, by: {$0.category})
ForEach(categories.keys.sorted().identified(by: \.self)){categoryName in
let items = categories[categoryName]!
...
}
字符串插值中的日期格式化
通常涉及到日期的格式化,就會想到 DateFormatter 的 string(from:) 方法。但實際上,我們也可以利用字符串插值的 formatter 參數來進行格式化:
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy/MM/dd"
Text("生日:\(date, formatter: dateFormatter)")
讓枚舉值可以被列舉
如果要讓枚舉值可以通過 .allCases 被列舉,需要讓 enum 遵循 CaseIterable 協議。
environment修飾符
environment修飾符用于修改視圖的特殊效果,比如想預覽暗夜模式,可以將 colorScheme 設置為 dark,如果要查看大字體下的效果,則可以修改 sizeCategory:
Group {
Home()
Home().environment(\.colorScheme, .dark)
Home().environment(\.sizeCateory, .accessibilityExtraLarge)
}
引用 SceneDelegate
在 UIKit 的世界,我們可以通過 UIApplication.shared.delegate 來引用 AppDelegate,從而可以使我們訪問一些“全局”的對象,比如 window。在 SwiftUI 中, SceneDelegate 取代了 AppDelegate,因此我們同樣可以通過 UIApplication.shared 單例引用 SceneDelegate:
if let sceneDelegate = UIApplication.shared.connectedScenes.first?.delegate as? SceneDelegate,
let rootVC = sceneDelegate.window?.rootViewController {
rootVC.present(UIAlertController(withError: message),
animated: true, completion: nil)
}
注意,connectedScenes 是一個集合,包含了所有 scene(SwiftUI View),我們從任意一個 scene 的 delegate 都可以拿到 SceneDelegate。進一步可以拿到 app 的 rootViewController 并修改它(切換根視圖控制器)。