譯自 Switching view states with enums
更多內容歡迎關注公眾號 「Swift花園」
用 enums 切換檢視狀態
你可能已經見過常規的 Swift 條件語句,用於區分兩種檢視呈現,程式碼像下面這樣:
Group {
if Bool.random() {
Rectangle()
} else {
Circle()
}
}複製程式碼
條件檢視在我們需要展示不同狀態的時候非常有用,如果我們計劃有序,保持程式碼規模足夠小,可以確保程式碼清爽整潔。但是狀態多了怎麼辦呢?保持檢視程式碼的簡短,訓練自己設計 SwiftUI 應用架構的能力。
解決方案分兩部分,第一部分是為各種檢視狀態定義列舉。舉個例子,你可以利用一個巢狀的 enum 定義幾種狀態:
enum LoadingState {
case loading, success, failed
}複製程式碼
接下來,為每種狀態建立單獨的檢視,簡單起見,我這裡就寫文字檢視。但是,在工程實踐中,如果檢視比較複雜,拆分就顯得非常有必要了。
struct LoadingView: View {
var body: some View {
Text("Loading...")
}
}
struct SuccessView: View {
var body: some View {
Text("Success!")
}
}
struct FailedView: View {
var body: some View {
Text("Failed.")
}
}複製程式碼
這些巢狀的檢視是否有存在的必要,完全取決於你的 app 的大小,以及你是否打算複用檢視。
有了這兩部分,我們現在可以在 ContentView
中用一個簡單的 wrapper 來追蹤當前應用的狀態,然後展示相應的子檢視。
var loadingState = LoadingState.loading複製程式碼
然後根據狀態顯示不同檢視,完成 body
屬性,像下面這樣:
Group {
if loadingState == .loading {
LoadingView()
} else if loadingState == .success {
SuccessView()
} else if loadingState == .failed {
FailedView()
}
}複製程式碼
採用上面這種方法,我們的 body 屬性的程式碼就不會隨著新增的程式碼不斷膨脹,因為它不需要去關係載入,成功,失敗幾種狀態下具體的檢視外觀,它們由巢狀的子檢視負責。
譯自 Integrating MapKit with SwiftUI】
將 MapKit 整合進 SwiftUI 應用
從 2007 年的第一個代 iPhone 開始,地圖就是一個核心的特性,其支撐的 framework 也從那個開始就對開發者開放。這個框架被稱為 MapKit。正如我們可以在 SwiftUI 中使用 UIKit ,我們同樣可以在 SwiftUI 中使用 MapKit ,只要我們不介意稍稍引入一些額外的工作。是的,這意味著要引入 coordinator 。
讓我們先從一些簡單的工作開始吧。建立一個新的 SwiftUI 檢視,名字叫 “MapView”,然後新增一句 MapKit 的 import 。這一次我們不會用到 UIViewControllerRepresentable 協議,因為 MapKit 並不使用檢視控制器。
有一種經典的構建軟體的模式叫 “MVC”,它把我們的程式碼分成三類物件,即 Model (我們的資料), View (我們的佈局) 和 Controller (連線 Model 和 View 的程式碼)。 Apple 在 UIKit 和它的其他框架中採用了 MVC ,包括 MapKit,但是增加了一些有趣的改變:view controllers 。它們究竟是 views,controllers,兩者都是,或者兩者都不是呢? Apple 官方並沒有給出答案,這也是為什麼你會在 iOS 開發中看到大量 MVC 變體的原因。
當我開始教授 UIKit 時,我是通過向大家解釋一個檢視是一塊佈局,注入文字,按鈕,影像,而一個檢視控制器是一屏內容這樣的方式開始的。隨著你對 UIKit 知識的掌握,你會知道你其實可以在一屏上擁有許多檢視控制器,不過我的方式對於剛開始學習的你是一種有助益的心智模型。
這些之所以很重要,因為我們用到過 UIImagePickerController ,它被設計用來展示一整屏的資訊 —— 我們不會檢視給它新增功能,因為它被設計時是預期以一種自包含單位的方式運作的。 作為對比,MapKit 提供了MKMapView,從名字上你也知道它是一個檢視而不是一個檢視控制器,這意味著它只做展示內容這件事。
這也是為什麼我們在處理 MapKit 時不用 UIViewControllerRepresentable 的原因: MKMapView 用檢視,所以我們需要用 UIViewRepresentable 。不過運作方式基本一致:我們需要實現 makeUIView() 方法和 updateUIView() 方法,這兩個方法處理例項化地圖檢視和 SwiftUI 狀態變化時地圖檢視的更新。不過, update 方法在檢視中相比檢視控制器充當了更重要的角色,因為 SwiftUI 程式碼和 UIView 物件之間需要有更多的互動程式碼 —— 因此我們在 view controller 裡放空這個方法,但 view 裡則很常用。
我們稍晚一些再來解決更新方法,現在我們解決 make 方法,這個方法會建立一個新的 MKMapView 並且返回它。
把 MapView 結構體改造成下面這樣:
struct MapView: UIViewRepresentable {
func makeUIView(context: UIViewRepresentableContext<MapView>) -> MKMapView {
let mapView = MKMapView()
return mapView
}
func updateUIView(_ view: MKMapView, context: UIViewRepresentableContext<MapView>) {
}
}複製程式碼
在繼續之前,我想想你展示一個 Swift 的小魔法,在前面的專案中,我介紹 UIViewControllerRepresentable 協議的時候,我們簡單實用了 typealias 。這是 Swift 中給已經存在的型別起別名的方式,這麼做可以讓它們更容易記憶。
UIViewControllerRepresentable 和 UIViewRepresentable 都內建了別名。如果你右鍵跳轉 UIViewRepresentable的定義,你會在這個協議的定義裡面看到這樣一行程式碼:
typealias Context = UIViewRepresentableContext<Self>複製程式碼
這行程式碼建立了一個叫 “Context” 的別名,每當 Swift 看見 Context的時候,它會視為 UIViewRepresentableContext<Self>,其中 Self代表我們正在處理的型別。實踐中,這表明我們只需要寫 Context 而不是UIViewRepresentableContext<MapView>,兩者的含義一模一樣。
回到 ContentView.swift 替換文字檢視成下面的程式碼:
MapView()
.edgesIgnoringSafeArea(.all)複製程式碼
在 Xcode 中預覽地圖的體驗目前還不是很好,所以我建議你在模擬器中執行 app 以檢視效果。你會發現你可以在地圖點選和拖曳,如果你按住 Option 鍵,你會看到第二個虛擬的手指,以便你可以做縮放和旋轉的操作。一行程式碼就能得到這些,還不賴哦。
當然,我們真正想要的是讓地圖來到生活實際中的某處地表,我們會在下一節解決這個問題。
譯自 Communicating with a MapKit coordinator
用 MapKit coordinator 和 MapView 互動
把一個空的 MKMapView 嵌入 SwiftUI 只是小試牛刀,如果你真的想用地圖幹掉有用的事,那你需要引入一個 coordinator —— 這個類充當你的地圖檢視的委託,負責和 SwiftUI 之間交換資料。
就像使用 UIImagePickerController 一樣,我們需要建立一個繼承自NSObject的巢狀類,令它遵循我們的檢視或者檢視控制器要求的委託協議,並讓它持有父親結構體的引用以便回傳資料給 SwiftUI 。
對於地圖檢視,我們關心的協議是 MKMapViewDelegate,因此我們可以立即著手寫一個 coordinator 類。在MapView 類新增下面這樣一個巢狀類:
class Coordinator: NSObject, MKMapViewDelegate {
var parent: MapView
init(_ parent: MapView) {
self.parent = parent
}
}複製程式碼
和 UIViewControllerRepresentable 協議相似,我們需要新增一個方法 makeCoordinator() ,它返回一個配置好的Coordinator 例項。下面的程式碼需要新增在 MapView 結構體這一級,它把自己傳入 coordinator 的構造器,以便後者可以報告發生的事情。
func makeCoordinator() -> Coordinator {
Coordinator(self)
}複製程式碼
然後,在 makeUIView() 方法中構造地圖檢視的地方,把 MKMapView 和我們的 coordinator 連線起來:
mapView.delegate = context.coordinator複製程式碼
這樣一來的我們的配置就完成了,接下來我們需要給 coordinator 新增方法,以響應地圖檢視的活動。記住,coordinator 是地圖檢視的委託,這意味著只要有事發生,它被會通知 —— 比如地圖發生移動的時候,地圖開始載入或者完成載入的時候,或者當使用者在地圖上被定位的時候,又或者當地圖被縮放的時候,等等。
MapKit 會自動檢查 coordinator 類,以瞭解我們關心哪些通知。這個動作是通過函式簽名實現的:用精確的函式名和引數列表來匹配,並呼叫它們。
為了演示這一點,我們將新增一個叫 mapViewDidChangeVisibleRegion() 的方法,它接收單一的 MKMapView 引數。是的,這個方法名非常長。不過,相信我, UIKit 裡比這長的多的是。我個人最愛的 API (現在已經廢棄了) ,叫做willAnimateSecondHalfOfRotationFromInterfaceOrientation()!
言歸正傳,把下面這個方法新增到 Coordinator 類中:
func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) {
print(mapView.centerCoordinate)
}複製程式碼
上面這個方法會被每一次地圖改變可見區域的時候被呼叫,所以移動,放大或者旋轉都會觸發。我們只是簡單的把中心座標列印出來,如果你是在模擬器裡執行 app ,那你會在 Xcode 的輸出視窗看到大量的座標資訊。
地圖檢視的 coordinator 還負責提供更多地圖需要的資訊。舉個例子,我們可以向地圖新增標註,作為想要互動的興趣點。作為 model 資料,相對於資料的視覺呈現,它只有標題和座標,因此地圖檢視想要渲染我們的標註時,它會向 coordinator 要這些資料。
為了演示這一點,我們需要修改 makeUIView() 方法,以便我們向地圖傳送一個倫敦市的標註,就像下面這樣:
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
mapView.delegate = context.coordinator
let annotation = MKPointAnnotation()
annotation.title = "London"
annotation.subtitle = "Capital of England"
annotation.coordinate = CLLocationCoordinate2D(latitude: 51.5, longitude: 0.13)
mapView.addAnnotation(annotation)
return mapView
}複製程式碼
MKPointAnnotation 是一個遵循 MKAnnotation 協議的類,它被 MapKit 用於顯示標註。如果你需要,可以建立自己的標註型別,不過在這裡MKPointAnnotation已經夠用了,它讓你提供標題,副標題和座標。如果你好奇的話,CLLocationCoordinate2D 之所以以 “CL” 開頭是因為它來自另一個 Apple 的框架,叫做 Core Location 。
加好標註後,你不用再做什麼 app 就已經可以執行了,找一找倫敦在哪,你會看到一個標記,點選它能夠展示我們的副標題。
如果你想自定義標註的外觀,我們又需要回到 coordinator 。地圖檢視會在我們的 coordinator 裡查詢一個特定的方法,叫 mapView(_:viewFor:),如果存在就會呼叫它。這個方法建立一個自定義的標註檢視,不過 Apple 還是給了我們優雅的實現,叫 MKPinAnnotationView。
往 Coordinator 類中新增一下程式碼:
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
let view = MKPinAnnotationView(annotation: annotation, reuseIdentifier: nil)
view.canShowCallout = true
return view
}複製程式碼
如你所見,我們需要傳給 MKPinAnnotationView 標記例項,然後設定 canShowCallout為真,以便點選這個標記時顯示資訊。
在結束地圖介紹之前,我想簡單提一下 reuseIdentifier 屬性。建立檢視的開銷可能是昂貴的。因此在 SwiftUI 中有 Identifiable 協議,如果它能唯一標識檢視,那麼它就能知道哪些檢視發生變化,哪些沒有發生變化,以便最小化需要的工作。
像 UIKit 和 MapKit 這樣的框架有類似概率的簡單版本,叫做 reuse identifiers。這些標識可以是我們想用的任何字串,被框架用來重用一個陣列裡的檢視。我們可以用特定的 ID 來向框架請求一個檢視,如果存在,就不需要重新建立。
上面我們指定了 nil作為 reuse identifier ,這意味著我們不想要重用這個檢視。鑑於我們正在學習,這樣做沒關係,不過之後我會向你展示更高效的方式,即重用檢視。
擴充套件
接下來我們對傳給 MapView 結構體的資料採用 @Binding ,以儲存這個值。 Coordinator 會從 MapKit 接收到這個值,然後傳給 MapView,然後 MapView把這個值放進一個 @Binding 屬性,也就是把它另存起來了。
先往 MapView 裡新增一個屬性:
@Binding var centerCoordinate: CLLocationCoordinate2D複製程式碼
這個操作會立刻破壞 MapView_Previews 結構體,因為這時它也需要提供這個繫結了。這裡的預覽實際沒有用處,因為 MKMapView 在預覽上不起作用,所以即使刪了也沒關係。或者,你可以新增一些樣例資料,以便修正預覽的程式碼:
extension MKPointAnnotation {
static var example: MKPointAnnotation {
let annotation = MKPointAnnotation()
annotation.title = "London"
annotation.subtitle = "Home to the 2012 Summer Olympics."
annotation.coordinate = CLLocationCoordinate2D(latitude: 51.5, longitude: -0.13)
return annotation
}
}複製程式碼
有了上面的程式碼,修正 MapView_Previews:
struct MapView_Previews: PreviewProvider {
static var previews: some View {
MapView(centerCoordinate: .constant(MKPointAnnotation.example.coordinate))
}
}複製程式碼
稍後我們會新增更多東西,在這之前我們先把它放進 ContentView。如果使用者想要把他們想去的地點新增到地圖上,我們需要在地圖上用一個半透明的圓表示。這裡簡單採用一個ZStack 確保目標點總是在地圖中央。
在 ContentView 裡新增一個儲存當前地圖的中央座標的屬性。稍後我們會用它來新增地點標記:
@State private var centerCoordinate = CLLocationCoordinate2D()複製程式碼
接下來填充 body 屬性:
ZStack {
MapView(centerCoordinate: $centerCoordinate)
.edgesIgnoringSafeArea(.all)
Circle()
.fill(Color.blue)
.opacity(0.3)
.frame(width: 32, height: 32)
}複製程式碼
當你執行 app ,你會發現你可以自由地在地圖上移動,但是中央失蹤有一個藍色半透明的圓浮在中央。
如果我們需要藍圈保持在中央的話,我們需要centerCoordinate 屬性隨著地圖的移動而更新。 在 mapViewDidChangeVisibleRegion() 方法中改造:
func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) {
parent.centerCoordinate = mapView.centerCoordinate
}複製程式碼
接下來我們要在右下角增加一個按鈕,以便我們在地圖新增地點標記。因為我們已經處於 ZStack 中,最便捷的方式是利用一個 VStack 加一個 HStack以及 spacer 將這個按鈕做對齊。Spacer 會吃掉豎向或者橫向剩餘的所有空間,使得按鈕處於右下角。
按鈕的功能我們稍後新增,我們先處理一下它的樣式:
下面的程式碼新增到剛才的圓下面:
VStack {
Spacer()
HStack {
Spacer()
Button(action: {
// create a new location
}) {
Image(systemName: "plus")
}
.padding()
.background(Color.black.opacity(0.75))
.foregroundColor(.white)
.font(.title)
.clipShape(Circle())
.padding(.trailing)
}
}複製程式碼
注意這裡我們新增了兩次 padding() modifier —— 第一次用在新增背景色之前增大按鈕,第二次用來把按鈕從角落裡推出來一些。
接下來比較有趣,我們在地圖上放置大頭針。前面我們已經繫結了一個屬性到地圖檢視,但傳送座標需要用別的方式。
第一部分很顯然,我們需要一個位置的陣列,存放所有使用者想去的地點。
往ContentView里加上這個屬性:
@State private var locations = [MKPointAnnotation]()複製程式碼
接下來,每當按鈕被點選時新增一個按鈕。我們先不管地點的標題和副標題,只需要簡單建立一個 MKPointAnnotation 然後利用centerCoordinate作為座標。
let newLocation = MKPointAnnotation()
newLocation.coordinate = self.centerCoordinate
self.locations.append(newLocation)複製程式碼
接下來是挑戰的部分:我們怎麼同步地圖檢視呢?
updateUIView() 在這裡派上用場了:SwiftUI 會在有任何資料被傳入 UIViewRepresentable 結構體導致它改變時自動呼叫這個方法,這個方法負責同步檢視的最新狀態。
在我們的案例中,我們傳送了 centerCoordinate 繫結給 MapView,這意味著每當使用者在地圖中移動導致這個值改變,會一直觸發 updateUIView() 。 之前這個過程一直安靜地發生,因為 updateUIView() 那時候還是空的。簡單新增一句 print()呼叫:
func updateUIView(_ view: MKMapView, context: Context) {
print("Updating")
}複製程式碼
在移動地圖,你會看到 “Updating” 不斷地被列印。
我們把 locations 陣列傳給 MapView ,讓它利用陣列裡的點為我們插入標記。
因此,MapView裡也需要宣告持有所有地點的標記:
var annotations: [MKPointAnnotation]複製程式碼
MapView_Previews 也需要更新一遍傳送樣例標記點。
MapView(centerCoordinate: .constant(MKPointAnnotation.example.coordinate), annotations: [MKPointAnnotation.example])複製程式碼
然後,我們還需要實現updateUIView() 它把當前標記和上一次標記做比較,如果不一樣則替換。當然,我們可以逐一比較兩個陣列裡的元素,但其實可以不用這麼做。因為我們不可能同時新增或者刪除標記,所以我們只需要簡單比較前後兩次陣列的長度就知道變化是否發生。如果發生了,清空舊陣列,重新填滿就行。
修改 updateUIView()方法如下:
func updateUIView(_ view: MKMapView, context: Context) {
if annotations.count != view.annotations.count {
view.removeAnnotations(view.annotations)
view.addAnnotations(annotations)
}
}複製程式碼
最後,更新 ContentView,讓它把 locations 陣列發給地圖:
MapView(centerCoordinate: $centerCoordinate, annotations: locations)複製程式碼
地圖的介紹就到此為止。現在你可以在地圖上隨便移動,點選按鈕來新增地點標記了。
你可以留意一下,當兩個標記很近的時候,iOS 會自動合併這些大頭針。舉個例子,如果在 1 公里以內的範圍放置了好幾枚標記, iOS 會將其中的一些隱藏以便影響地圖的閱讀。
相關文章:
[SwiftUI 100 天] Bucket List - part1
[SwiftUI 100 天] Bucket List - part3
[SwiftUI 100 天] Bucket List - part4
我的公眾號 這裡有Swift及計算機程式設計的相關文章,以及優秀國外文章翻譯,歡迎關注~