
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 框架的各個方面,包括:
很酷,對吧?這會很有趣,讓我們開始吧 !
首先, 我來快速介紹一下 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 包含可用來控制要顯示那個地方或區域的各種屬性,包括:
automaticitem(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 的最終使用者介面。

在細節視圖中嵌入地圖視圖之前,我們先從實作自己的 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 所示。

現在你已經了解如何使用經度與緯度來顯示地圖,接下來我們將探討如何使用實際地址在地圖上標記位置。在我們於地圖上定位餐廳位置之前,先了解如何使用地圖上的位置是非常重要的。
要在地圖上突出顯示位置,你不能只使用實際地址,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 所示。

目前的地圖視圖存在一個問題:它不能準確地在地圖上指出餐廳的確切位置,然而 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 所示。

如果你不需要這個「Here」標籤,則可將標記的標籤設定為空白:
Map(position: $position) {
Marker("", coordinate: markerLocation.coordinate)
.tint(.red)
}
現在 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)
}
透過將導覽列設定為隱藏,你將不再於細節視圖中看見它。

我們還沒有完成 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 所示。

預設上,內建地圖可以讓使用者平移及縮放。在某些情況下,你可能希望完全禁止使用者與地圖進行互動,你可以指定 interactionModes 參數來實例化 Map 視圖:
Map(position: $position, interactionModes: [])
透過將 Map 視圖設定為空集,你可以禁用使用者互動。這個參數接受五個選項:
我們的自訂 MapView 並不支援 interactionModes,你的任務是修改 MapView.swift 來新增該功能。細節視圖中的嵌入地圖目前允許使用者與其互動,你的任務是更新程式來禁用使用者互動。
你是否注意到目前應用程式中的 Bug 呢?如果你開啟 RestaurantListView 並導覽至細節視圖,你可能會注意到導覽列比不是完全透明的,你的任務是修正導覽列中的透明度問題。作為提示,你可以使用修飾器來設定工具列的背景,請花些點時間來尋找 API 文件並找到適合的修飾器來以解決這個問題。

在本章中,我介紹了 MapKit 框架的基本知識。到目前為止,你應該充分了解如何將地圖整合到你的 App 中,並加入註釋,然而這只是一個開始而已,還有很多東西值得探索。你可以進一步研究 MKDirection MKDirections 此類別使你能夠從自 Apple 伺服器取得基於路徑的方向資料,允許你存取旅行時間的資訊或行車路線或步行路線。讓你的 App 更邁進一步,你可以結合顯示方向的功能來增強使用者體驗。
在本章所準備的範例檔中,有最後完整的 Xcode 專案可供你下載參考: http://www.appcoda.com/resources/swift59/swiftui-foodpin-maps.zip 。