iOS 17 App 程式設計實戰心法(SwiftUI)

第 14 章
運用地圖

The longer it takes to develop, the less likely it is to launch.

-Jason Fried, Basecamp

MapKit 框架為開發者提供一系列的 API,以便將地圖相關功能合併到他們的 App 中, 這些功能包含顯示地圖、導覽地圖、為特定位置加入標記、在現有地圖上加上覆蓋物等 API。使用該框架,你可以輕鬆將功能齊全的地圖介面嵌入到你的 App 中,而無須撰寫大量的程式碼。

而 SwiftUI 提供原生的 Map 視圖,可供開發者無縫嵌入地圖介面。另外,你可以使用 MapMarker 等內建的註釋視圖(Annotation View )來顯示註釋。

在本章中,我們會為 FoodPin App 加入地圖功能,這個 App 將在細節畫面中顯示一個小的地圖視圖。當使用者點擊該地圖視圖時,FoodPin App 將顯示全螢幕地圖來讓使用者探索位置的細節。透過本章,你將深入了解 MapKit 框架的各個方面,包括:

  • 如何在視圖中嵌入地圖。
  • 如何使用地理編碼器(Geocoder)來將地址轉換為座標。
  • 如何在地圖上加入大頭針(即註釋)。

很酷,對吧?這會很有趣,讓我們開始吧 !

了解 SwitUI 的地圖視圖

首先, 我來快速介紹一下 SwiftUI 中的 Map 視圖, 請參考 Map 的文件( https://developer.apple.com/documentation/mapkit/map ),你應該會找到下列結構的 init 方法:

init(coordinateRegion: Binding<MKCoordinateRegion>, interactionModes: MapInteractionModes = .all, showsUserLocation: Bool = false, userTrackingMode: Binding<MapUserTrackingMode>? = nil) where Content == _DefaultMapContent

要使用 Map,你需要提供 MKCoordinateRegion 的綁定,以追蹤要在地圖上顯示的區域。MKCoordinateRegion 結構可以讓你指定以特定緯度和經度為中心的矩形地理區域。

以下是一個例子:

MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 40.75773, longitude: -73.985708), span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05))

上例中的座標是紐約時代廣場的 GPS 座標,span 值用來定義所需的地圖縮放等級,其值越小,則縮放等級越高。

一般來說,要在 SwiftUI 中嵌入地圖,需要先匯入 MapKit 框架:

import MapKit

然後,只需實例化 Map 視圖就可以建立地圖,如下所示:

var body: some View {
    Map()
}

這將建立一個帶有預設位置的全螢幕地圖,但是 Map 視圖並沒有顯示預設位置,而是提供一個額外的 init 方法來讓你自訂地圖的初始位置:

init(
    initialPosition: MapCameraPosition,
    bounds: MapCameraBounds? = nil,
    interactionModes: MapInteractionModes = .all,
    scope: Namespace.ID? = nil
) where Content == MapContentView<Never, EmptyMapContent>

你可以建立一個 MapCameraPosition 的實例作為地圖的初始位置。MapCameraPosition 包含可用來控制要顯示那個地方或區域的各種屬性,包括:

  • automatic
  • item(MKMapItem) – 用來顯示特定的地圖項目。
  • region(MKCoordinateRegion) – 用來顯示特定的區域。
  • rect(MKMapRect) – 用來顯示特定的地圖邊界。
  • camera(MapCamera) – 用來顯示目前的相機位置。
  • userLocation() – 用來顯示使用者的位置。

舉例來說,你可以使用 .region(MKCoordinateRegion) 指示地圖顯示特定的區域:

Map(initialPosition: 
      .region(MKCoordinateRegion(
                center: CLLocationCoordinate2D(latitude: 40.75773, longitude: -73.985708), 
                span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05)
      )))

上例中的座標是紐約時代廣場的 GPS 座標,span 值是用來定義所需的地圖縮放等級, 其值越小,則縮放等級越高。

建立自己的地圖視圖

到目前為止,你應該對 SwiftUI 中的 Map 元件有充分了解,我們的下一步是將非互動式地圖整合到餐廳細節視圖的頁尾中。當使用者點擊地圖時,App 將轉換到地圖視圖控制器來顯示具餐廳位置的全螢幕地圖,圖14.1 顯示了App 的最終使用者介面。

圖 14.1. 將地圖視圖加到細節視圖中
圖 14.1. 將地圖視圖加到細節視圖中

在細節視圖中嵌入地圖視圖之前,我們先從實作自己的 MapView 版本開始。雖然我們可以使用內建的 Map 視圖來顯示地圖,但是它需要提供準確的經緯度座標。我們自訂的 MapView 實作將會改良 Map 內建版本,呼叫者只需傳送實際地址,MapView 就會將該地址轉換為座標,並在地圖上顯示相應的位置。

在專案導覽器中的「View」資料夾上按右鍵,並選擇「New File...」,然後選取「SwiftUI View」模板,將檔案命名為「MapView」。插入下列程式碼來匯入 MapKit 框架:

import MapKit

然後替換MapView 結構如下:

struct MapView: View {
    var location: String = ""

    @State private var region: MKCoordinateRegion = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 51.510357, longitude: -0.116773), span: MKCoordinateSpan(latitudeDelta: 1.0, longitudeDelta: 1.0))

    var body: some View {
        Map(initialPosition: .region(region))
    }
}

MapView 使用內建的 Map 元件來顯示地圖介面,但它需要一個名為「location」的附加參數(即該位置的地址)。

預設上,region 狀態變數設定為倫敦的座標,在 Xcode 預覽中,它應該會顯示一個倫敦地圖,如圖 14.2 所示。

圖 14.2. 顯示地圖
圖 14.2. 顯示地圖

使用地理編碼器來將地址轉換為座標

現在你已經了解如何使用經度與緯度來顯示地圖,接下來我們將探討如何使用實際地址在地圖上標記位置。在我們於地圖上定位餐廳位置之前,先了解如何使用地圖上的位置是非常重要的。

要在地圖上突出顯示位置,你不能只使用實際地址,MapKit 框架不能這樣運作,地圖需要知道的是對應地球上特定「點」的經緯度地理座標。

這個框架提供一個 Geocoder 類別,使開發者將文字地址(即地標)轉換為全球座標, 這個過程稱為「前向地理編碼」(Forward Geocoding );反之,你也可以使用地理編碼器將經緯度值轉回地標,這個過程稱為「反向地理編碼」(Reverse Geocoding )。

要使用 CLGeocoder 類別初始化一個前向地理編碼請求,你只需要建立一個 CLGeocoder 的實例,然後使用地址參數的 geocodeAddressString 方法。以下是一個例子:

let geoCoder = CLGeocoder()
geoCoder.geocodeAddressString("524 Ct St, Brooklyn, NY 11231", completionHandler: { placemarks, error in

// Process the placemark

})

地址字串沒有指定的格式。這個方法非同步傳送指定的位置資料到地理編碼伺服器, 然後伺服器會解析地址,並回傳一個placemark 物件的陣列。而回傳的 placemark 物件的數量,很大程度取決於你提供的地址,當你提供的地址資訊越具體,結果就越好;如果你的地址不夠具體,則可能會得到多個 placemark 物件。

透過 placemark 物件(即 CLPlacemark 類別的實例),你可以使用下列的程式碼輕鬆取得地址的地理座標:

let coordinate = placemark.location?.coordinate

完成處理器(Completion Handler)是在前向地理編碼請求完成後要執行的程式碼區塊, 諸如註釋地標的操作會在這個程式碼區塊中完成。

你還記得問號是做什麼的嗎?如果你學習過第2 章,我希望你可以回答這個問題。地標的 location 屬性在 Swift 中稱為「可選型別」(Optional ),可選型別是 Swift 中導入的一個新型別,表示「有值」或「空值」,換句話說,location 屬性可能包含一個值。要存取可選型別,則使用問號。在本例中,Swift 會檢查location 屬性是否有值,如果有的話,我們可以進一步取得coordinate。

以上對於背景資訊的說明已經足夠了,我們來繼續編寫將實際地址轉換為座標的程式碼。在MapView 結構中,插入一個新方法來執行地址轉換,如下所示:

private func convertAddress(location: String) {

    // 取得位置
    let geoCoder = CLGeocoder()

    geoCoder.geocodeAddressString(location, completionHandler: { placemarks, error in
        if let error = error {
            print(error.localizedDescription)
            return
        }

        guard let placemarks = placemarks,
              let location = placemarks[0].location else {
            return
        }

        let region = MKCoordinateRegion(center: location.coordinate, span: MKCoordinateSpan(latitudeDelta: 0.0015, longitudeDelta: 0.0015))

        self.position = .region(region)

    })
}

這個方法接受一個地址,並試著發出一個前向地理編碼請求來找出該位置的經緯度, 如果請求成功,我們將狀態變數 region 更新為該座標。

guard 敘述對你來說可能比較陌生,之前我提到可使用問號來檢查 location 屬性是否有值。在上列的程式碼中,我介紹另一種執行檢查的方式,placemarks 與 placemarks[0]. location 都是選項,guard 敘述檢查 placemarks 與 placemarks[0].location 是否有值,如果有值的話,則該值將分別儲存到 placemarks 與 location。

由於 location 變數必須要有值,因此我們可以直接取得 coordinate 值,並使用它來實例化一個區域,透過 MKCoordinateRegion 物件,我們可以用指定的區域來建立一個相機位置。

我還沒有解釋 position 變數的用途,由於地址轉換的過程需要一些時間,因此建立地圖時我們要確定初始位置就變得具有挑戰性。為了解決這個問題,Map 視圖提供一個額外的 init 方法,該方法接受對 MapCameraPosition 的綁定。當建立有文字地址的 Map 視圖時, 更適合使用這個 init 方法:

@State private var position: MapCameraPosition = .automatic

Map(position: $position) {
   .
   .
   .
}

因此,請新增一行程式碼來建立 position 變數,你也可以刪除 region 屬性。

現在我們已經建立了地址轉換的方法,並設定了必要的屬性,那麼問題來了:「我們在什麼時候應該呼叫該方法?」理想情況下,此轉換過程應該在載入 MapView 時開始,幸運的是 SwiftUI 提供一個名為「task」的修飾器,允許我們在視圖載入時執行操作。

在 body 變數中,將task 修飾器加到 Map 視圖,如下所示:

Map(position: $position)
    .task {
        convertAddress(location: location)
    }

當 Map 載入完成後,我們呼叫 convertAddress 方法來轉換地址並更新位置。要測試上述的變更,則編輯 #Preview 程式碼區塊如下:

#Preview {
    MapView(location: "54 Frith Street London W1D 4SL United Kingdom")
}

我們以實際地址初始化 MapView。在預覽窗格中,地圖視圖應該會顯示該地址的位置,如圖 14.3 所示。

圖 14.3.  地圖視圖現在可以轉換地址並顯示其位置
圖 14.3.  地圖視圖現在可以轉換地址並顯示其位置

新增標記至地圖

目前的地圖視圖存在一個問題:它不能準確地在地圖上指出餐廳的確切位置,然而 Map 結構實際上提供了額外的 init 方法,允許開發者在閉包中加入標記,如下所示:

Map(position: $position) {
    Marker("Here", coordinate: markerLocation.coordinate)
}

Marker 是一個氣球形狀的註譯,用來標記地圖位置。

現在我們使用 Marker 物件來精確定位地圖上的位置。首先,加入一個新的狀態變數至 MapView 中,以存放標註者的位置:

@State private var markerLocation = CLLocation()

在 convertAddress 方法中,於 geocodeAddressString 閉包內插入下列這行程式碼:

self.markerLocation = location

最後,將 MapView 的 body 更新為下列的程式碼片段:

Map(position: $position) {
    Marker("Here", coordinate: markerLocation.coordinate)
        .tint(.red)
}
.task {
    convertAddress(location: location)
}

我們使用 Marker 來顯示註釋。看一下預覽畫布,這次地圖應該會顯示一個標記來精確地指出帶有「Here」標籤的餐廳位置,如圖 14.4 所示。

圖 14.4 地圖視圖顯示該位置的標記
圖 14.4 地圖視圖顯示該位置的標記

如果你不需要這個「Here」標籤,則可將標記的標籤設定為空白:

Map(position: $position) {
    Marker("", coordinate: markerLocation.coordinate)
        .tint(.red)
}

嵌入 MapView

現在 MapView 的自訂版本已經可以使用了,是時候切換到 RestaurantDetailView.swift 並嵌入地圖視圖了。在 RestaurantDetailView 的 VStack 末尾插入下列的程式碼來嵌入地圖視圖:

MapView(location: restaurant.location)
    .frame(height: 200)
    .clipShape(RoundedRectangle(cornerRadius: 20))
    .padding()

自訂 MapView 就像 SwiftUI 中的任何其他視圖一樣, 因此我們可以加上 frame 與 cornerRadius 修飾器來限制其大小與設定圓角。進行變更後,你應該會在預覽窗格看到地圖,如圖 14.5 所示。

不幸的是,還有一個小 Bug,即導覽列不完全透明,要解決這個問題,你可以加上 toolbarBackground 修飾器來更新 #Preview 程式碼:

#Preview {
    NavigationStack {
        RestaurantDetailView(restaurant: Restaurant(name: "Cafe Deadend", type: "Coffee & Tea Shop", location: "G/F, 72 Po Hing Fong, Sheung Wan, Hong Kong", phone: "232-923423", description: "Searching for great breakfast eateries and coffee? This place is for you. We open at 6:30 every morning, and close at 9 PM. We offer espresso and espresso based drink, such as capuccino, cafe latte, piccolo and many more. Come over and enjoy a great meal.", image: "cafedeadend", isFavorite: true))

            .toolbarBackground(.hidden, for: .navigationBar)
    }
    .tint(.white)
}

透過將導覽列設定為隱藏,你將不再於細節視圖中看見它。

圖 14.5. 在細節視圖中嵌入地圖
圖 14.5. 在細節視圖中嵌入地圖

顯示全螢幕地圖

我們還沒有完成 UI 的實作,當使用者點擊細節視圖中的地圖時,它應該會導覽到另一個顯示全螢幕地圖的畫面。要達成這個變更,你需要做的是使用 NavigationLink 包裹 MapView 的實例,如下所示:

NavigationLink(
    destination:
        MapView(location: restaurant.location)
            .toolbarBackground(.hidden, for: .navigationBar)
            .edgesIgnoringSafeArea(.all)

) {
    MapView(location: restaurant.location)
        .frame(height: 200)
        .clipShape(RoundedRectangle(cornerRadius: 20))
        .padding()
}

目的是設定為顯示全螢幕的 MapView。如果你所做的變更正確,則應該會獲得所需的結果,如圖 14.6 所示。

圖 14.6. 顯示全螢幕地圖
圖 14.6. 顯示全螢幕地圖

禁用使用者互動

預設上,內建地圖可以讓使用者平移及縮放。在某些情況下,你可能希望完全禁止使用者與地圖進行互動,你可以指定 interactionModes 參數來實例化 Map 視圖:

Map(position: $position, interactionModes: [])

透過將 Map 視圖設定為空集,你可以禁用使用者互動。這個參數接受五個選項:

  • .all - 允許所有類型的使用者互動。
  • .pan - 允許使用者平移。
  • .zoom - 允許使用者縮放。
  • .rotate - 允許使用者旋轉。
  • .pitch - 允許使用者俯仰。

作業①:禁用使用者互動

我們的自訂 MapView 並不支援 interactionModes,你的任務是修改 MapView.swift 來新增該功能。細節視圖中的嵌入地圖目前允許使用者與其互動,你的任務是更新程式來禁用使用者互動。

作業②

你是否注意到目前應用程式中的 Bug 呢?如果你開啟 RestaurantListView 並導覽至細節視圖,你可能會注意到導覽列比不是完全透明的,你的任務是修正導覽列中的透明度問題。作為提示,你可以使用修飾器來設定工具列的背景,請花些點時間來尋找 API 文件並找到適合的修飾器來以解決這個問題。

圖 14.7. 導覽列部分透明
圖 14.7. 導覽列部分透明

本章小結

在本章中,我介紹了 MapKit 框架的基本知識。到目前為止,你應該充分了解如何將地圖整合到你的 App 中,並加入註釋,然而這只是一個開始而已,還有很多東西值得探索。你可以進一步研究 MKDirection MKDirections 此類別使你能夠從自 Apple 伺服器取得基於路徑的方向資料,允許你存取旅行時間的資訊或行車路線或步行路線。讓你的 App 更邁進一步,你可以結合顯示方向的功能來增強使用者體驗。

在本章所準備的範例檔中,有最後完整的 Xcode 專案可供你下載參考: http://www.appcoda.com/resources/swift59/swiftui-foodpin-maps.zip