Last Updated:
Photo by henry perks on Unsplash

Integrating MapKit with SwiftUI, 續篇(2)

在本系列的前兩篇文章中,我們已經在 app 中加入了地圖,而且當我們改變地圖的中心點位置時,該點座標也能正確地傳遞給 SwiftUI。接下來,我們要挑戰在地圖上面放上記號(即使用大頭針標註喜愛地點),並同時為它命名以及加上簡短的描述

在進入正題前,我們必須了解地圖記號(annotations)是由以下兩個部分所組成:

  • annotation objects:指標註地點本身。必須符合MKAnnotation protocol,本文中將使用MKPointAnnotation class來完成,它具有三個屬性 - coordinate、title、subtitle,除了coordinate 為必要之外,其他兩項用於 annotation view 的 callout 則是 optional。(細節可參考文末的官方文件連結)
  • view objectsMKAnnotationView class 繼承自UIView,用於展示標註地點,它的 subclass 有MKPinAnnotationViewMKMarkerAnnotationView。此外,由於 annotation view 被設計為可重複使用,因此在初始化物件時必須賦予 reuse identifier,這個 identifier 即是用來尋找是否已有可用的 annotation view。(細節可參考文末的官方文件連結)

首先,得讓使用者能在地圖上標註(多個)喜愛的地點。因此,需在介面上新增 button,當使用者按下 button 便會保存目前桌面的中心位置到喜愛地點;當然,還得準備一個陣列來儲存地點資料。另外,為了方便使用者明白桌面的中心點在哪,還會加入一個圓形的圖標來示意。

// MapView.swift

import SwiftUI
import MapKit

// 修改原本的MapView,添加新屬性annotations,並在updateView()新增執行動作
struct MapView: UIViewRepresentable {
    @Binding var centerCoordinate: CLLocationCoordinate2D
    
    // 1.宣告一個陣列,準備接收從SwiftUI傳遞過來的喜愛地點
    var annotations: [MKPointAnnotation]
    
    // ...略過程式碼
    
    // 2.修改updateUIView的內容,當annotations改變時,會自動呼叫此方法執行所需動作
    func updateUIView(_ uiView: MKMapView, context: Context) {        
        // 3.使用者新增地點時,因參數改變會自動觸發updateView執行以下檢查
        if annotations.count != uiView.annotations.count {
            uiView.removeAnnotations(uiView.annotations)
            uiView.addAnnotations(annotations)
        }
    }
}

完成 MapView.swift 的部分後,接著完成 ContentView.swift。

// ContentView.swift

import SwiftUI
import MapKit

struct ContentView: View {
    @State private var centerCoordinate = CLLocationCoordinate2D()
    
    // 1.新增用來儲存喜愛地點的陣列
    @State private var locations = [MKPointAnnotation]()
    
    var body: some View {
        ZStack {
            // 2.將剛才在MapView新增的annotations參數補上
            MapView(centerCoordinate: $centerCoordinate, annotations: locations)
                .edgesIgnoringSafeArea(.all)
            
            // 3.新增一個圓形圖標示意中心點(預設位置會自動在正中央,故無需設定)
            Circle()
                .fill(Color.red)
                .opacity(0.3)
                .frame(width: 35, height: 35)
            
            // 4.加入按鈕以便使用者新增喜愛地點(擺放於桌面右下角)
            VStack {
                Spacer()
                HStack{
                    Spacer()
                    Button(action: {
                        // 5.按下新增地點後,要執行的動作內容
                        let newLocation = MKPointAnnotation()
                        newLocation.coordinate = self.centerCoordinate
                        newLocation.title = "Temporary Location Title"
                        newLocation.subtitle = "Temporary Description"
                        self.locations.append(newLocation)
                    }) {
                        Image(systemName: "plus")
                    }
                    .padding()
                    .background(Color.secondary.opacity(0.9))
                    .foregroundColor(.white)
                    .font(.title)
                    .clipShape(Circle())
                    .padding(.trailing)
                }
            }
        }
    }
}

透過以上步驟,現在使用者已經可以利用桌面右下角的 "" 按鈕來新增喜愛的地點。


接著,由於尚未在MapView 的 Coordinator 裡完成對應的方法,因此,當我們點按 app 裡剛新增的地點大頭針時,並不會帶出任何視窗 —— MKAnnotationView。根據 MKMapViewDelegate 的說明(詳見文末的官方文件連結),要依指定的 annotation object 傳回對應的 annotation view 時,必須完成mapView(_:viewFor:);記得,此處要使用 reuse identifier 搭配 dequeueReusableAnnotationView(withIdentifier:)來完成。而當使用者點擊 annotation view 彈出視窗中的 "" 按鈕時,則是使用mapView(_:annotationView:calloutAccessoryControlTapped:)。實作方法如下:

// MapView.swift

import SwiftUI
import MapKit

struct MapView: UIViewRepresentable {
    @Binding var centerCoordinate: CLLocationCoordinate2D
    var annotations: [MKPointAnnotation]
    
    class Coordinator: NSObject, MKMapViewDelegate {
        // ...略過程式碼

        func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) {
            parent.centerCoordinate = mapView.centerCoordinate
        }
        
        // 3.完成mapView(_:viewFor:),在其中使用reuse identifier找annotation view
        func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
            // 自訂一個identifier常數,準備用來尋找可重複使用的annotation view
            let identifier = "Placemark"
            var annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier)
            
            // 如果有找到annotation view,就將地點pass過去;若沒有,就創建一個MKPinAnnotationView
            if annotationView == nil {
                annotationView = MKPinAnnotationView(annotation: annotation, reuseIdentifier: identifier)
                // 允許彈出視窗,並為其加上一個靠右的UIButton
                annotationView?.canShowCallout = true
                annotationView?.rightCalloutAccessoryView = UIButton(type: .detailDisclosure)
            } else {
                annotationView?.annotation = annotation
            }
            
            return annotationView
        }
        
        // 4.完成使用者點擊彈出視窗按鈕所需的mapView(_:annotaionView:calloutAccessoryControlTapped)
        func mapView(_ mapView: MKMapView, annotationView view: MKAnnotationView, calloutAccessoryControlTapped control: UIControl) {
            // 先確認傳入的annotation view裡,是否有使用者點按的annotation
            guard let placemark = view.annotation as? MKPointAnnotation else { return }
            
            // 如果有,就往上傳遞給MapView的selectedPlace參數
            parent.selectedPlace = placemark
            parent.showingPlaceDetails = true
        }
    }
    
    // ...略過程式碼
}

補足了 Coordinator 裡的兩個 mapView() 方法後,接著在 MapView 裡新增兩個 Binding 變數,分別用來存放使用者選定的地點、以及是否顯示地點的詳細資訊(彈出視窗)

// MapView.swift

// ...略過程式碼

struct MapView: UIViewRepresentable {
    @Binding var centerCoordinate: CLLocationCoordinate2D
    var annotations: [MKPointAnnotation]
    
    // 1.新增MKPointAnnotation變數,用來跟SwiftUI底下的綁定
    // 使用者不一定有選定地點,因此必須在型別後方冠上"?",表示可能為nil
    @Binding var selectedPlace: MKPointAnnotation?
    // 2.新增Bool變數,用來確認annotation view彈出視窗是否顯示
    @Binding var showingPlaceDetails: Bool
    
    // ...略過程式碼
}

以上兩個變數是立即準備用來跟 SwiftUI 底下對應的 State 變數綁定使用的。

//  ContentView.swift

// ...略過程式碼

struct ContentView: View {
    @State private var centerCoordinate = CLLocationCoordinate2D()
    @State private var locations = [MKPointAnnotation]()
    
    // 新增兩個State變數用來跟MapView的binding變數綁定
    @State private var selectedPlace: MKPointAnnotation?
    @State private var showingPlaceDetails = false
    
    // ...略過程式碼
}

再來,我們只要添加 ContentView 裡呼叫MapView()所使用的參數,同時利用alert()來呈現使用者點按喜愛地點的彈出視窗的 "" 按鈕即可。

//  ContentView.swift

// 略過程式碼

struct ContentView: View {
    @State private var centerCoordinate = CLLocationCoordinate2D()
    @State private var locations = [MKPointAnnotation]()
    @State private var selectedPlace: MKPointAnnotation?
    @State private var showingPlaceDetails = false
    
    var body: some View {
        ZStack {
            // 1.在呼叫裡添加新增的兩個參數
            MapView(centerCoordinate: $centerCoordinate, annotations: locations, selectedPlace: $selectedPlace, showingPlaceDetails: $showingPlaceDetails)
                .edgesIgnoringSafeArea(.all)
            
            // ...略過程式碼
        }
        // 2.利用alert來顯示選定地點的詳細資訊,並預先加入Edit按鈕
        .alert(isPresented: $showingPlaceDetails) {
            Alert(title: Text(self.selectedPlace?.title ?? "Unknown"), message: Text(self.selectedPlace?.subtitle ?? "Unknown"), primaryButton: .default(Text("OK")), secondaryButton: .default(Text("Edit")) {
                // add action for edit button
            })
        }
    }
}

至此,使用者已能點按所新增的喜愛地點,並且會彈出地點的詳細視窗資訊。只不過,目前詳細資訊裡只是無用的內容,所以接著我們要讓使用者可以自行編輯它們(即 title 和 subtitle )。


使用者可以編輯喜愛地點資訊的情境會發生在以下兩種情況,其一是當使用者新增喜愛的地點時(也就是點按右下角"+"按鈕時),其二是當使用者點選某個(已新增完成的)地點圖標,並按下彈出視窗( callout )裡的更多資訊圖示裡的 "Edit" 按鈕。至於編輯地點資訊的畫面,可以透過新增 SwiftUI View 來完成,所以請在 app 專案裡再新增一個 SwiftUI View 檔案,並取名為 "EditView.swift",由於會使用到 MKPointAnnotation,因此必須導入 MapKit

// EditView.swift

import SwiftUI
import MapKit

struct EditView: View {
    var body: some View {
        // more code
    }
}

在完成 EditView 的內容之前,還必須先完成另一個重要動作。

為了讓使用者輸入自訂的地點標題(title)和描述(subtitle),必須在 EditView 裡使用 TextField。不過,TextField 不允許使用 optional String 作為參數值,因此得先針對 MKPointAnnotationtitlesubtitle 屬性預作處理。我們將利用 extension,並在其中宣告兩個 computed property 來替代原本的屬性,作法如下:

  1. 新增一個 Swift 檔案,並取名為 "MKPointAnnotation-ObservableObject.swift"。
  2. 將預設的import Foundation(此處用不到該函式庫)改為import MapKit
  3. MKPointAnnotation 遵守 ObservableObject protocol,如此便能在任意 View 中使用。
  4. 新增兩個 computed property,並使用 getter 和 setter 來完成所需的 optional unwrapping。
// MKPointAnnotation-ObservableObject.swift

import MapKit

// 透過在extension裡宣告computed properties來完成optional unwrapping
extension MKPointAnnotation: ObservableObject {
    public var wrappedTitle: String {
        get {
            self.title ?? "Unknown Place Title"
        }
        set {
            self.title = newValue
        }
    }
    
    public var wrappedSubtitle: String {
        get {
            self.subtitle ?? "Add some description."
        }
        set {
            self.subtitle = newValue
        }
    }
}

接著,繼續完成剛剛所建立的 EditView。

// EditView.swift

import SwiftUI
import MapKit

struct EditView: View {
    // 用來控制EditView顯示狀態的環境變數
    @Environment(\.presentationMode) var presentationMode
    // 宣告一個MKPointAnnotation變數,用來接受傳入的地點
    @ObservedObject var selectedPlace: MKPointAnnotation
    
    var body: some View {
        NavigationView {
            // 建立讓使用者輸入title和subtitle的表單
            Form {
                section {
                    TextField("Place Title", text: $selectedPlace.wrappedTitle)
                    TextField("Description", text: $selectedPlace.wrappedSubtitle)
                }
            }
            .navigationBarTitle("Edit")
            .navigationBarItems(trailing: Button("Done") {
                self.presentationMode.wrappedValue.dismiss()
            })
        }
    }
}

建立完使用者編輯地點資訊所需的 EditView 後,只要再完成進入編輯畫面的兩種情境就OK啦。

  • 情境1:當使用者新增喜愛的地點時(也就是點按右下角"+"按鈕時)。完成該情境只需在 ContentView 裡新增一個存放 EditView 顯示狀態的 Bool 變數,然後在 "" 按鈕的 action 裡修改 EditView 的顯示狀態變數為 true,最後利用 Sheet() 來帶入 EditView 即可。
  • 情境2:當使用者點選某個(已新增完成的)地點圖標,並按下彈出視窗( callout )裡的更多資訊圖示裡的 "Edit" 按鈕。此情境則需要在alert()底下的 "Edit" 按鈕的 action 裡改變 EditView 的顯示狀態變數為 true,只需一行程式碼即可。
// ContentView.swift

// 略過程式碼

struct ContentView: View {
    @State private var centerCoordinate = CLLocationCoordinate2D()
    @State private var locations = [MKPointAnnotation]()
    @State private var selectedPlace: MKPointAnnotation?
    
    // 1.新增用來存放EditView顯示狀態的Bool變數
    @State private var showingEditScreen = false
    
    var body: some View {
        ZStack {
            // 略過程式碼
            
            VStack {
                Spacer()
                HStack{
                    Spacer()
                    Button(action: {
                        let newLocation = MKPointAnnotation()
                        newLocation.coordinate = self.centerCoordinate
                        newLocation.title = "Temporary Location Title"
                        newLocation.subtitle = "Temporary Description"
                        self.locations.append(newLocation)
                        
                        // 2.將使用者新增的喜愛地點指定給selectedPlace
                        // ,並修改EditView顯示狀態為ture
                        self.selectedPlace = newLocation
                        self.showingEditScreen = true
                    })
                    // 略過程式碼
                }
            }
        }
        .alert(isPresented: $showingPlaceDetails) {
            Alert(title: Text(self.selectedPlace?.title ?? "Unknown"), message: Text(self.selectedPlace?.subtitle ?? "Missing place information."), primaryButton: .default(Text("OK")), secondaryButton: .default(Text("Edit")) {
                // 4.當使用者按下喜愛地點彈出視窗的"i"按鈕後,並接著按下"Edit"
                self.showingEditScreen = true
            })
        }
        // 3.透過sheet()方法來顯示EditView供使用者編輯資訊
        .sheet(isPresented: $showingEditScreen) {
            // 若selectedPlace不是nil,就產生EditView
            if selectedPlace != nil {
                EditView(selectedPlace: self.selectedPlace!)
            }
        }
    }
}

經過以上較為繁雜的程序後,現在我們的地圖已經能讓使用者新增多個喜愛地點,並且自訂這些地點的相關資訊囉。


[ 補充資料 ] Apple Developer Documentation

Comments