[SwiftUI 100天] Bucket List - part2

貓克杯發表於2020-03-02
譯自 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及計算機程式設計的相關文章,以及優秀國外文章翻譯,歡迎關注~

[SwiftUI 100天] Bucket List - part2


相關文章