2024-07-01|閱讀時間 ‧ 約 45 分鐘

技術筆記-iOS實戰005-加入地圖和即時位置,開始好玩起來了

查景點,美食,導航,這些功能已經深深融入我們的生活了,背後重要的技術支柱就是科技巨頭所提供的龐大全球地圖資料庫,和隨身手機上的 GPS 定位功能,這是 App 的強項,非玩不可。

需求情境:

在陌生的城市探索,最常用到的地圖功能是什麼?找星巴克是我的第一名,第二是享受更多功能的便利商店,再其次就是,當開車時可能要找加油站。雖然現有的地圖 App 功能已經太強大,但若能由自己手刻出來,不僅會得到掌握技術的成就感,也可以做出許多變化式,說不定真能催生商業價值。

解決方案:

搞懂 Apple 官方提供的 MapKit,實作地圖的呈現,平移放大縮小,在不同位置的搜尋,搜尋結果的標示。然後運用手機 GPS 定位功能,標出自己的位置,找到最近的目標,畫出路徑規劃,算出車程,最後用語音把重要資訊念出來。

實作步驟:

與 Map 相見歡,最好從「官方文件」下手,因為版本更新太快,問 ChatGPT 或從網路搜尋技術文章,大多已不適用了,浪費了我一些時間。首先來個 Hello World,要讓地圖從 App 顯示出來,只要一行:Map(),當然開頭需要 import MapKit:

原來顯示地圖這麼簡單!那就趕快進行下一步,就是搜尋,假設是搜尋星巴克,怎麼做呢?

先做一個按鈕,按鈕觸發 search(),結果存在 searchResults,searchResults 必須是「狀態變數」以便直接連動 UI,所以 Map 內容必須可顯示 searchResults 的內容,使用 Marker 元件。這樣一下子增加好多東西,詳述如下:

import SwiftUI
import MapKit

extension CLLocationCoordinate2D {
    static let chunan = CLLocationCoordinate2D(latitude: 24.686793691851307, longitude: 120.88055196685008)
}

struct MapView: View {
    @State var queryString: String = ""
    @State var searchResults: [MKMapItem] = []
    @State var visibleRegion: MKCoordinateRegion?

    func search(for query: String) {
        queryString = query
        let request = MKLocalSearch.Request()
        request.naturalLanguageQuery = query
        request.resultTypes = .pointOfInterest
        request.region = visibleRegion ?? MKCoordinateRegion(
            center: .chunan,
            span: MKCoordinateSpan(latitudeDelta: 0.001, longitudeDelta: 0.001)
        )
        Task {
            let search = MKLocalSearch(request: request)
            let response = try? await search.start()
            searchResults = response?.mapItems ?? []
        }
    }
    
    var body: some View {
        Map() {
            ForEach(searchResults, id: \.self) {result in
                Marker(item: result)
            }
            .annotationTitles(.hidden)   
        }
        .safeAreaInset(edge: .bottom) {
            Button {
                search(for: "星巴克")
            } label: {
                Label("星巴克", systemImage: "cup.and.saucer.fill")
            }
            .buttonStyle(.borderedProminent)
        }
    }
}

執行搜尋必須有一個特定範圍,所以先做一個 CLLocationCoordinate2D 的 extension,加入一個靜態欄位 chunan,型別等同於它自己。這個型態是用來表示經緯度的,很重要的基本資料結構。理想情況下,地圖移到哪裡,就該搜尋哪裡;但那等一下再做,因為技術的複雜性,所以用分解動作,按部就班來。

在 search 函示中,引用了 MKLocalSearch 物件的功能,此物件可以接受一段自然語言如「星巴克」這樣的搜尋語句,加上一個描述「搜尋範圍」的參數,就是剛剛定義的 以 chunan 為中心,長寬的度量為經緯度 0.00001。搜尋必須以非同步語法啟動之,結果傳回狀態變數 searchResults,是個陣列 [MKMapItem]。在 Map() 內容中,用一段 ForEach 迴圈產生 Marker 元件,呈現效果如下:

事情進行的很順利,然後呢?當我把地圖位置移到台北,按下按鈕,一樣搜尋竹南,這就不對了,如何讓搜尋範圍自動指定為「當前位置」?當前位置可能是在導航情境中,自動定位而得的;也可能是當使用者因特定目的而將地圖移到任何地方,此時不能強行將範圍又改回當前位置,直覺操作的背後其運作機制卻不簡單,而強大的 MapKit 又把它變簡單:

.onMapCameraChange {
context invisibleRegion =
context.region}

關鍵就是 Map 的 onMapCameraChange 事件,此事件在上面兩種情境都會觸發,加入這行就搞定了。





移到台北,一按按鈕,真的搜尋出台北的點了,很好。

然後呢?點一下這些地點圖示,沒有反應,直覺的期望應該是,點下去顯示該地點的重點摘要說明,然後規劃路徑並畫出來,以下繼續實作。









首先要加入選取功能,必須在 Map 的建構式中傳入 selection 參數,並綁定到狀態變數:

// MapView 開頭處加入這行​
@State var selectedResult: MKMapItem?

// ​Map 初始化時,加入 selection 參數
Map(selection: $selectedResult)

這樣一加,點下任一個地點,它就會「亮起來」,變大顆,這一切的設計都非常直覺。

接下來,我們希望它不只亮起來,也要執行「路徑規劃」,我們把此動作包裝成 getDirection(),裡面再把 getRoute() 切開成另一包,這是有回傳值得 async 函數,之後會單獨用到。路徑規劃用到 MKDirection 物件,source 先固定成 .chunan,之後抓到自己位置之後,再替換掉。


// MapView 開頭處再加入此變數
@State var route: MKRoute?

// 這兩個函數也放在 MapView 裡面​
func getRoute(dest: MKMapItem) async -> MKRoute? {
route = nil
  let request = MKDirections.Request()
  request.source = MKMapItem(placemark: MKPlacemark(coordinate: .chunan))
  request.destination = dest
  let directions = MKDirections(request: request)
  let resp = try? await directions.calculate()
  return resp?.routes.first
}

func getDirection() {
guard let selectedResult else { return }
  Task {
  route = await getRoute(dest: selectedResult)
  }
}

// Map builder 裡面畫出 route
if let route {
MapPolyline(route)
.stroke(.blue, lineWidth: 5)
}


畫出路徑了,讚吧!

繼續乘勝追擊,還有程式要做,就是「Where I am 」。因為移動中的使用者,必須動態的以當時位置為出發點,才有實用價值。

首先我們把地圖上系統內建的三個視覺元件叫出來:

.mapControls {
MapUserLocationButton() // 目前位置
MapCompass() // 指北針
MapScaleView() // 比例尺
}

這樣點一下地圖右上角的箭頭,就會把地圖定位在自己所在位置,但為了計算路徑,這樣還不夠,必須用程式抓出內部的經緯度資料,才有辦法客製化出即時的路徑,需要動用到 LocationManager,請看以下解析:


class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate {
  private var locationManager: CLLocationManager
@Published var currentLocation: CLLocation?
@Published var isUpdatingLocation: Bool = false

override init() {
locationManager = CLLocationManager()
super.init()
locationManager.delegate = self
locationManager.requestWhenInUseAuthorization()
locationManager.desiredAccuracy = kCLLocationAccuracyBest
locationManager.requestWhenInUseAuthorization()
}

func startUpdating() {
locationManager.startUpdatingLocation()
isUpdatingLocation = true
}
func stopUpdating() {
locationManager.stopUpdatingLocation()
isUpdatingLocation = false
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let xx = locations.last else { return }
self.currentLocation = xx
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
print("Failed to find user's location: \(error.localizedDescription)")
}
}

此類別可控制啟動與暫停抓取位置,啟動狀態時最多一秒一筆資料,當靜止時則自動降低更新頻率。此資料對於後續變化應用很重要,因此設計讓它經常性顯示在畫面上,就放在底部:

// 在 MapView 開頭宣告狀態物件,確保在整個畫面的生命週期,都可取得物件內容 
@StateObject var locationManager = LocationManager()

// 下列視覺元件,顯示在地圖下方:
HStack {
if locationManager.isUpdatingLocation {
Button {
locationManager.stopUpdating()
} label: {
Label("更新中..", systemImage: "play.fill")
}
.buttonStyle(.borderedProminent)
} else {
Button {
locationManager.startUpdating()
} label: {
Label("停止更新", systemImage: "stop.fill")
}
.buttonStyle(.bordered)
}
if let xx = locationManager.currentLocation {
VStack {
HStack {
Text("更新時間:\(getCurrentTime())")
Spacer()
}
HStack {
Text("\(xx.coordinate.latitude) \(xx.coordinate.longitude)")
Spacer()
}
}
}


以上程式首先為一個按鈕以啟動或停止偵測位置,旁邊簡單顯示出資料時間和抓到的經緯度。

確認經緯度資料到手後,改寫先前的 getRoute() 函式,判斷當 locationManager 有取得位置時,以它為起點來規劃路徑:

func getRoute(dest: MKMapItem) async -> MKRoute? {
route = nil
let request = MKDirections.Request()
if let xx = locationManager.currentLocation  {
request.source =  MKMapItem(placemark: MKPlacemark(coordinate: xx.coordinate))
} else {
request.source = MKMapItem(placemark: MKPlacemark(coordinate: .chunan))
}
request.destination = dest
let directions = MKDirections(request: request)
let resp = try? await directions.calculate()
return resp?.routes.first
}

呈現效果如左,至此功能架構已經完成大部分,最後稍加捏角捏邊,就要完成此專案了。



錦上添花的部分,可加入 MapKit 提供很強大的 LookAround 功能,觀看目標地點的 3D 實景,我們另做一個 View 的檔案 ItemInfoView,接受兩個參數,一樣放置在地圖下方:

if let selectedResult {
ItemInfoView(selectedResult: selectedResult, route: route)
.frame(height: 128)
.clipShape(RoundedRectangle(cornerRadius: 10))
.padding([.top, .horizontal])
}

ItemInfoView.swift:

import SwiftUI
import MapKit

struct ItemInfoView: View {
    @State private var lookAroundScene: MKLookAroundScene?
    var selectedResult: MKMapItem
    var route: MKRoute?

    private var travelTime: String? {
        guard let route else { return nil}
        let formatter = DateComponentsFormatter()
        formatter.unitsStyle = .abbreviated
        formatter.allowedUnits = [.hour, .minute]
        return formatter.string(from: route.expectedTravelTime)
    }

    func getLookAroundScene() {
        lookAroundScene = nil
        Task {
            let request = MKLookAroundSceneRequest(mapItem: selectedResult)
            lookAroundScene = try? await request.scene
        }
    }

    var body: some View {
        LookAroundPreview(initialScene: lookAroundScene)
            .overlay(alignment: .bottomTrailing) {
                HStack {
                    Text("\(selectedResult.name ?? "")")
                    if let travelTime {
                        Text(travelTime)
                    }
                }
                .font(.caption)
                .foregroundStyle(.white)
                .padding(10)
            }
            .onAppear {
                getLookAroundScene()
            }
            .onChange(of: selectedResult) {
                getLookAroundScene()
            }
    }
}

呈現效果如下,質感整個提升起來。

最後最後再加一個小小的變化,既然我們已經抓到本身位置,可以針對搜尋後的一系列結果,分別計算距離,找出最近的地點,用語音播放出來,這樣走在路上就方便多了。便利商店和加油站都比照辦理,三個按鈕放下去。

// 將搜尋結果的整個陣列傳入,找最近的地點,並唸出重要資訊​
// 掛在 .onChange(of: searchResults) 裡面執行
func findNearestItem(items: [MKMapItem]) {
guard let xx = locationManager.currentLocation
else {return}
if let nearestItem = items.min(
by: {$0.placemark.location!.distance(
from: locationManager.currentLocation!)
< $1.placemark.location!.distance(
from: locationManager.currentLocation!) })
{
let trimedTitle = trimLeadingNumber(s: nearestItem.placemark.title!)
let itemName = nearestItem.placemark.name!
let line1 = "最近的\(queryString) 是 \(itemName) 位於 \(trimedTitle) "
let line2 = "距離 \(String(format: "%.1f", nearestItem.placemark.location!.distance(from: xx)/1000.0)) 公里 "
Task {route = await getRoute(dest: nearestItem)
let line3 = "車程 \(String(format: "%.1f", route!.expectedTravelTime/60.0)) 分鐘"
let msg = "\(line1)\(line2)\(line3)"
print(msg)
speak(text: msg)
}
}

// 為了消除地址之前的郵遞區號,讓念出的語音減少累贅
func trimLeadingNumber(s: String) -> String {
var numberOfDigit: Int = 0
let sArray = Array(s)
for i in 0..<sArray.count {
if !sArray[i].isNumber {
numberOfDigit = ibreak
}
}
return String(s.dropFirst(numberOfDigit))
}

// 念出中文語音​,注意要執行在 main thread
func speak(text: String) {
DispatchQueue.main.async {
let utterance = AVSpeechUtterance(string: text)
utterance.voice = AVSpeechSynthesisVoice(language: "zh-TW")
utterance.rate = AVSpeechUtteranceDefaultSpeechRate
utterance.pitchMultiplier = 1.0
synthesizer.speak(utterance)
}
}

最後貼出在手機上的實際執行畫面:

搜尋完會播放以下形式的語音:「最近的便利商店 是 全家 位於 台灣苗栗縣竹南鎮中山路166號2樓 距離 0.0 公里 車程 0.1 分鐘」。

以上,終於大功告成,真的可以拿著手機到處玩了。乾貨很多吧?誠心誠意,完整分享,敬請笑納。

Newman 2024/7/1

導覽頁:紐曼的技術筆記-索引







分享至
成為作者繼續創作的動力吧!
© 2024 vocus All rights reserved.