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

閱讀時間約 24 分鐘

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

需求情境:

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

解決方案:

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

實作步驟:

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

raw-image

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

先做一個按鈕,按鈕觸發 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 元件,呈現效果如下:

raw-image

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

.onMapCameraChange {
context invisibleRegion =
context.region}

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

raw-image





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

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

raw-image









首先要加入選取功能,必須在 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)
}
raw-image


畫出路徑了,讚吧!

繼續乘勝追擊,還有程式要做,就是「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()
}
}
}


raw-image

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

確認經緯度資料到手後,改寫先前的 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()
            }
    }
}

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

raw-image

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

// 將搜尋結果的整個陣列傳入,找最近的地點,並唸出重要資訊​
// 掛在 .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)
}
}

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

raw-image

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

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

Newman 2024/7/1

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







avatar-img
22會員
106內容數
漫步是一種境界。
留言0
查看全部
avatar-img
發表第一個留言支持創作者!
newman的沙龍 的其他內容
需求情境: 為了讓多人使用 App,必須有驗證程序,以識別特定使用者,存取各自擁有的資源。 解決方案: 引用 google 所提供的雲端服務平台 Firebase,其中有多種驗證功能可選用。基於個人對 google 的偏愛,決定先採用 google signin 的方法,實作 login lo
需求情境: 一般的看盤軟體,雖然都能針對一籃子自選股票,列出其即時行情和當天漲幅,但若要看「五日漲幅」呢?那就少見了,但這對我很重要。因為小部位的波段性價差交易是個好策略,這時候若能排序好一整排看下來,可以節省大量點來點去的成本,很有價值,所以就來自己刻。 解決方案: 從大處著眼,UI 最外層
需求情境: 在設計畫面時,資料來源是後台的 api,每一次畫面細節的修修改改,都會觸發 Xcode Preview 程序,導致不斷呼叫後台。此時若資料結構和大小都具有一定規模,就會導致效率低落,不斷等待,且消耗伺服器資源甚鉅。 解決方案: 將後台傳回的資料以檔案形式暫存在本地端,每次 pr
Part.1 搞定基本的 UI 開始開發 iOS App。 首先準備一台 Mac,然後安裝 Xcode,新增專案,系統即刻生成基本的專案結構。coding 的起點在檔案 ContentView.swift: import SwiftUI struct ContentView: View {  
技術筆記-用 python 操作 google firestore 的基本方法 (2023/12/26) 技術筆記-以 nodejs 為後台,以 google sheet 充當資料庫 (2022/11/29)
firestore 是 google 所提供的雲端文件式資料庫服務,為各種開發工具提供了方便使用的 sdk,python 的套件名稱為 firebase-admin,用 pip 安裝後就可操作了。 pip install firebase-admin
需求情境: 為了讓多人使用 App,必須有驗證程序,以識別特定使用者,存取各自擁有的資源。 解決方案: 引用 google 所提供的雲端服務平台 Firebase,其中有多種驗證功能可選用。基於個人對 google 的偏愛,決定先採用 google signin 的方法,實作 login lo
需求情境: 一般的看盤軟體,雖然都能針對一籃子自選股票,列出其即時行情和當天漲幅,但若要看「五日漲幅」呢?那就少見了,但這對我很重要。因為小部位的波段性價差交易是個好策略,這時候若能排序好一整排看下來,可以節省大量點來點去的成本,很有價值,所以就來自己刻。 解決方案: 從大處著眼,UI 最外層
需求情境: 在設計畫面時,資料來源是後台的 api,每一次畫面細節的修修改改,都會觸發 Xcode Preview 程序,導致不斷呼叫後台。此時若資料結構和大小都具有一定規模,就會導致效率低落,不斷等待,且消耗伺服器資源甚鉅。 解決方案: 將後台傳回的資料以檔案形式暫存在本地端,每次 pr
Part.1 搞定基本的 UI 開始開發 iOS App。 首先準備一台 Mac,然後安裝 Xcode,新增專案,系統即刻生成基本的專案結構。coding 的起點在檔案 ContentView.swift: import SwiftUI struct ContentView: View {  
技術筆記-用 python 操作 google firestore 的基本方法 (2023/12/26) 技術筆記-以 nodejs 為後台,以 google sheet 充當資料庫 (2022/11/29)
firestore 是 google 所提供的雲端文件式資料庫服務,為各種開發工具提供了方便使用的 sdk,python 的套件名稱為 firebase-admin,用 pip 安裝後就可操作了。 pip install firebase-admin
你可能也想看
Google News 追蹤
Google地圖對現代商業至關重要,尤其在行動裝置普及的時代,良好的地圖能見度能直接帶動客流。設置商家資料需完整並精確,並利用SEO策略提升排名。分析競爭對手表現有助於制定差異化策略。此外,定期維護和更新商家資訊、管理評論和上傳新圖片,能長期保持商家在地搜尋中的能見度和競爭優勢。
Thumbnail
地圖控的樂趣:喜歡地圖的人可以利用地圖來探索世界,不僅是在旅行中,就連在日常生活中也可以體驗到地圖的魅力。
Thumbnail
現在的智慧型手機都已經具有定位功能,可在Google地圖知道自己的所在位置,也能夠傳送座標給朋友輕鬆找到會面地點。 其實連手機拍出的照片,也能夠自動儲存拍照地點的定位資訊!
Thumbnail
初體驗 到達馬祖打開google地圖為何景點在海上?遊客中心的服務員說,Apple地圖會好一點,啊!~我不是蘋果家族成員!
Thumbnail
iPhone定位不準、導航會亂飄偏離實際路徑, 好困擾!找不到想去的店,回家的路變得好遙遠, 想用Facebook打卡卻得不到準確位置資訊…… iPhone定位功能是什麼? iPhone內建定位功能是一種能夠透過手機上的感應器和衛星訊號, 定位使用者所在位置的技術,可以幫助使用者尋
Thumbnail
近幾年 Apple Maps 功能突飛猛進,蘋果也在 iOS 17 替 Apple地圖加入離線地圖功能,能夠類似 Google離線地圖功能一樣,能夠讓 iPhone 在沒有行動網路環境下, 也能夠利用 iOS 17 離線地圖進行導航和規劃路線。 要如何在 iOS 17 上使用 Apple Map
Thumbnail
您有開店嗎? 如果有,本篇文章會一口氣讓您了解什麼是Google地圖廣告? 讓你了解Google廣告到底能幫助您什麼? Google地圖廣告又是如何成為店家們一定要有的武器!
Thumbnail
在今天快速發展的應用程式(App)市場中,UX(User Experience,使用者體驗)設計已成為決定App開發成功與否的關鍵因素。那麼,當我們透過地圖搜尋美食時,又是哪些UX設計的細節讓某些App特別突出呢?
Thumbnail
當智慧型手機還不是那麼普遍的時候 ,我的汽車就曾備有一大册〔全台319個鄉鎮地圖集〕,要去陌生的地方,這本地圖馬上派上用場。GPS定位系統、Google導航、各式各樣的手機導航系統出現之後,導航App取代了地圖的功能 ,成為生活中不可或缺的工具。
Thumbnail
最近在閱讀《巴黎哲學藝術地圖》一書時,時常因為內文提及許多巴黎的地標、文明的建築物、巴黎第一間咖啡館等地而回想起四年前在歐洲交換、旅遊的日子。
Google地圖對現代商業至關重要,尤其在行動裝置普及的時代,良好的地圖能見度能直接帶動客流。設置商家資料需完整並精確,並利用SEO策略提升排名。分析競爭對手表現有助於制定差異化策略。此外,定期維護和更新商家資訊、管理評論和上傳新圖片,能長期保持商家在地搜尋中的能見度和競爭優勢。
Thumbnail
地圖控的樂趣:喜歡地圖的人可以利用地圖來探索世界,不僅是在旅行中,就連在日常生活中也可以體驗到地圖的魅力。
Thumbnail
現在的智慧型手機都已經具有定位功能,可在Google地圖知道自己的所在位置,也能夠傳送座標給朋友輕鬆找到會面地點。 其實連手機拍出的照片,也能夠自動儲存拍照地點的定位資訊!
Thumbnail
初體驗 到達馬祖打開google地圖為何景點在海上?遊客中心的服務員說,Apple地圖會好一點,啊!~我不是蘋果家族成員!
Thumbnail
iPhone定位不準、導航會亂飄偏離實際路徑, 好困擾!找不到想去的店,回家的路變得好遙遠, 想用Facebook打卡卻得不到準確位置資訊…… iPhone定位功能是什麼? iPhone內建定位功能是一種能夠透過手機上的感應器和衛星訊號, 定位使用者所在位置的技術,可以幫助使用者尋
Thumbnail
近幾年 Apple Maps 功能突飛猛進,蘋果也在 iOS 17 替 Apple地圖加入離線地圖功能,能夠類似 Google離線地圖功能一樣,能夠讓 iPhone 在沒有行動網路環境下, 也能夠利用 iOS 17 離線地圖進行導航和規劃路線。 要如何在 iOS 17 上使用 Apple Map
Thumbnail
您有開店嗎? 如果有,本篇文章會一口氣讓您了解什麼是Google地圖廣告? 讓你了解Google廣告到底能幫助您什麼? Google地圖廣告又是如何成為店家們一定要有的武器!
Thumbnail
在今天快速發展的應用程式(App)市場中,UX(User Experience,使用者體驗)設計已成為決定App開發成功與否的關鍵因素。那麼,當我們透過地圖搜尋美食時,又是哪些UX設計的細節讓某些App特別突出呢?
Thumbnail
當智慧型手機還不是那麼普遍的時候 ,我的汽車就曾備有一大册〔全台319個鄉鎮地圖集〕,要去陌生的地方,這本地圖馬上派上用場。GPS定位系統、Google導航、各式各樣的手機導航系統出現之後,導航App取代了地圖的功能 ,成為生活中不可或缺的工具。
Thumbnail
最近在閱讀《巴黎哲學藝術地圖》一書時,時常因為內文提及許多巴黎的地標、文明的建築物、巴黎第一間咖啡館等地而回想起四年前在歐洲交換、旅遊的日子。