Integrating MapKit with SwiftUI, 續篇(2)
在本系列的前兩篇文章中,我們已經在 app 中加入了地圖,而且當我們改變地圖的中心點位置時,該點座標也能正確地傳遞給 SwiftUI。接下來,我們要挑戰在地圖上面放上記號(即使用大頭針標註喜愛地點),並同時為它命名以及加上簡短的描述。
在進入正題前,我們必須了解地圖記號(annotations)是由以下兩個部分所組成:
- annotation objects:指標註地點本身。必須符合
MKAnnotation
protocol,本文中將使用MKPointAnnotation
class來完成,它具有三個屬性 - coordinate、title、subtitle,除了coordinate 為必要之外,其他兩項用於 annotation view 的 callout 則是 optional。(細節可參考文末的官方文件連結) - view objects:
MKAnnotationView
class 繼承自UIView
,用於展示標註地點,它的 subclass 有MKPinAnnotationView
和MKMarkerAnnotationView
。此外,由於 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 彈出視窗中的 "i" 按鈕時,則是使用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()
來呈現使用者點按喜愛地點的彈出視窗的 "i" 按鈕即可。
// 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 作為參數值,因此得先針對 MKPointAnnotation
的 title 和 subtitle 屬性預作處理。我們將利用 extension
,並在其中宣告兩個 computed property 來替代原本的屬性,作法如下:
- 新增一個 Swift 檔案,並取名為 "MKPointAnnotation-ObservableObject.swift"。
- 將預設的
import Foundation
(此處用不到該函式庫)改為import MapKit
。 - 讓
MKPointAnnotation
遵守ObservableObject
protocol,如此便能在任意 View 中使用。 - 新增兩個 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!)
}
}
}
}
經過以上較為繁雜的程序後,現在我們的地圖已經能讓使用者新增多個喜愛地點,並且自訂這些地點的相關資訊囉。
Comments