(SwiftUI) SpeedTracker — Resolving [SwiftUI] Modifying state during view update issue with MapKit

Photo by CHUTTERSNAP on Unsplash

目的:獲取用戶位置並與速度儀表一起顯示當前速度、經度和緯度,並解決使用 MapKit 產生的錯誤。

製作時速表畫面

建立一個 SwiftUI View SpeedViewModel

import SwiftUI

struct SpeedViewModel: View {

// 設定視圖元素的高度
var frameHeight: CGFloat = 150.0

// 設定指針的旋轉角度
let angle: Double

// 設定顯示的速度文字
let speedText: String

// 設定指針的顏色
var color: Color = Color.black

var body: some View {
ZStack {

// 繪製細刻度
TickMarks()
.stroke(color, lineWidth: 3)
.rotationEffect(Angle(degrees: -70))
.frame(width: frameHeight)

// 繪製粗刻度
TickMarks(test: 40, tickLength: 20.0)
.stroke(color, lineWidth: 5)
.rotationEffect(Angle(degrees: -70))
.frame(width: frameHeight)

// 製作時速標籤
GeometryReader { geometry in
ForEach(0..<8) { index in
SpeedLabel(speed: index * 20, radius: (frameHeight / (returnRadius(for: frameHeight))), size: geometry.size)
}
}

// 製作指針(三角形)
Triangle(height: frameHeight)
.fill(Color.red)
.frame(width: 10, height: 100) // 調整指針的高度和寬度
.rotationEffect(Angle(degrees: angle))
// 加入彈性動畫,使指針具有上下擺動的效果
.animation(.spring(dampingFraction: 0.5))

// 繪製圓點,表指針的底部
Circle()
.fill(Color.gray)
.frame(width: 20, height: 20)

// 顯示速度文字
VStack {
Spacer()
.frame(height: frameHeight - 50)
Text(speedText)
.font(.title3)
}
}
}

// 根據不同的視圖元素高度回傳不同的值,用於計算標籤距離中心的距離
func returnRadius(for frame: Double) -> Double {
let frameValues: [Double] = [150, 200, 300]
let heightValues: [Double] = [4, 3, 2.7]

// 根據視圖元素高度找到對應的半徑值
for i in 0..<frameValues.count - 1 {
if frame >= frameValues[i] && frame <= frameValues[i + 1] {
let x0 = frameValues[i]
let x1 = frameValues[i + 1]
let y0 = heightValues[i]
let y1 = heightValues[i + 1]

// 進行線性插值計算
let height = y0 + (y1 - y0) * (frame - x0) / (x1 - x0)
return height
}
}

// 如果高度超出 300,一律回傳 2.7
return 2.7
}
}

// 刻度 Shape
struct TickMarks: Shape {

var test: Double = 10.0
var tickLength: CGFloat = 10.0

func path(in rect: CGRect) -> Path {
var path = Path()
let radius = rect.width / 2.0
let center = CGPoint(x: rect.midX, y: rect.midY) // 將圓心設置在畫面的中心

// 在圓的周圍繪製刻度
for angle in stride(from: -140.0, through: 140.0, by: test) {
let tickAngle = angleToRadian(angle - 20)
let startPoint = CGPoint(
x: center.x + (radius - tickLength) * cos(tickAngle),
y: center.y + (radius - tickLength) * sin(tickAngle)
)
let endPoint = CGPoint(
x: center.x + radius * cos(tickAngle),
y: center.y + radius * sin(tickAngle)
)
path.move(to: startPoint)
path.addLine(to: endPoint)
}

return path
}

private func angleToRadian(_ angle: Double) -> CGFloat {
return CGFloat(angle) * .pi / 180.0
}
}

// 時速標籤 View
struct SpeedLabel: View {
let speed: Int
var radius: CGFloat = 100.0 // 調整標籤距離中心的距離
let size: CGSize // 父視圖的大小

var body: some View {
// 計算標籤在圓圈上的位置
let angle = Double(speed) / 160.0 * 320.0 + 130 // 修改弧度範圍為 0 到 280 度
let center = CGPoint(x: size.width / 2, y: size.height / 2) // 將圓心設置在畫面的中間

let labelAngle = angleToRadian(angle)
let x = center.x + radius * cos(labelAngle)
let y = center.y + radius * sin(labelAngle)

// 顯示速度文字標籤在指定位置
return Text("\(speed)")
.position(CGPoint(x: x, y: y))
}

private func angleToRadian(_ angle: Double) -> CGFloat {
return CGFloat(angle) * .pi / 180.0
}
}

// 指針
struct Triangle: Shape {

var height = 350.0

func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.midX, y: rect.minY - ((height / 5) - 10.0)))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - 50))
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY - 50))
path.closeSubpath()
return path
}

}


struct SpeedViewModel_Previews: PreviewProvider {
static var previews: some View {
SpeedViewModel(angle: 220.0, speedText: "0")
}
}

frameHeight 能夠調整時速表大小,且會透過 function returnRadius 來回傳不同指針所需的長度

angle 會控制指針的旋轉角度

新增一個 Swift File LocationManager

import Foundation
import CoreLocation

class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate {

// 創建 CLLocationManager 實例以進行位置信息管理
private var locationManager = CLLocationManager()

// 使用 @Published 屬性監聽位置信息的改變,這些屬性將在視圖中自動更新
@Published var location: CLLocation?
@Published var speedInMetersPerSecond: Double = 0.0
@Published var speedInKmPerHour: Double = 0.0
@Published var latitude: String = ""
@Published var longitude: String = ""

override init() {
super.init()

// 設置 CLLocationManager 的屬性和委派
locationManager.delegate = self
// 設定位置精度,這將影響定位的準確程度
locationManager.desiredAccuracy = kCLLocationAccuracyBest
// 設定定位活動類型,以便系統更好地適應定位的需要
locationManager.activityType = .automotiveNavigation
// 設置位置更新的觸發距離,每 3 米更新一次
locationManager.distanceFilter = 3.0
}

// 要求使用者授予應用程式在使用時的位置權限
func requestAuthorization() {
locationManager.requestWhenInUseAuthorization()
}

// 開始監聽位置信息的更新
func startUpdatingLocation() {
locationManager.startUpdatingLocation()
}

// 停止監聽位置信息的更新
func stopUpdatingLocation() {
locationManager.stopUpdatingLocation()
}

// CLLocationManagerDelegate 方法,當位置信息更新時呼叫
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {

// 更新最新的位置信息
location = locations.first
latitude = "\(locations[0].coordinate.latitude)"
longitude = "\(locations[0].coordinate.longitude)"
print(locations[0].coordinate.latitude)
print(locations[0].coordinate.longitude)

// 獲取最新位置的速度信息
if let location = locations.last {
// 獲取速度(以米/秒為單位)
let speed = location.speed
self.speedInMetersPerSecond = speed
self.speedInKmPerHour = speed * 3.6 // 將速度轉換為千米/小時
}
}
}

製作主畫面

在 ContentView 中新增以下程式碼

import SwiftUI
import MapKit
import CoreLocation


struct ContentView: View {

// 地圖區域的設定,初始位置位於瑞士茵特拉根的座標
@State var region: MKCoordinateRegion = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 47.3769, longitude: 8.5417), latitudinalMeters: 2000, longitudinalMeters: 2000)
// 地圖的追蹤模式,初始設定為跟蹤使用者
@State var tracking = MapUserTrackingMode.follow
// 創建 LocationManager 的狀態物件,用於處理位置資訊
@StateObject private var locationManager = LocationManager()


// 格式化後的速度字符串,從 LocationManager 中取得速度資訊
var speed: String {
// 用 max 函數將速度限制為最小為 0
let clampedSpeed = max(locationManager.speedInKmPerHour, 0)
return String(format: "%.0f", clampedSpeed)
}
// 從 LocationManager 中取得的緯度和經度的字串表示
var latitude: String {
locationManager.latitude
}
var longitude: String {
locationManager.longitude
}

var body: some View {
VStack {
// 顯示地圖,啟動位置更新並跟蹤使用者位置
Map(coordinateRegion: $region, interactionModes: .all, showsUserLocation: true, userTrackingMode: $tracking)
.task {
// 要求使用者授權使用位置資訊
locationManager.requestAuthorization()
// 開始更新位置
locationManager.startUpdatingLocation()
// 設定地圖追蹤模式為跟蹤使用者位置
tracking = .follow
}
.ignoresSafeArea()

HStack {
// 顯示自定義的速度計視圖,角度和速度資訊根據速度計算
SpeedViewModel(frameHeight: 150, angle: 220.0 + Double(2 * (Double(speed) ?? 0.0)) - 0.0, speedText: speed)
.frame(height: 150)

VStack(alignment: .leading, spacing: 20) {
// 顯示緯度和經度的資訊
Text("latitude : \n\(latitude)")
Text("longitude : \n\(longitude)")
}
.frame(maxWidth: 400, alignment: .leading)
.font(.title3)
}
}
}
}

struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
latitude=25.041818, longitude=121.534896

使用模擬器來設定位置

點選右上角 Features -> Location -> Custom Location…

輸入經度與緯度後點選 OK

經度緯度可以使用 GoogleMap 查詢

使用 City Run 測試時速表

City Run

使用 City Bicycle Ride 測試時速表

City Bicycle Ride

使用 Freeway Drive 測試時速表

Freeway Drive

在主畫面新增一個 location 按鈕

讓你能夠隨時拉回現在的位置,點選 ContentView 中的 Map 新增 ZStack

            ZStack {
// 顯示地圖,啟動位置更新並跟蹤使用者位置
Map(coordinateRegion: $region, interactionModes: .all, showsUserLocation: true, userTrackingMode: $tracking)
.task {
// 要求使用者授權使用位置資訊
locationManager.requestAuthorization()
// 開始更新位置
locationManager.startUpdatingLocation()
// 設定地圖追蹤模式為跟蹤使用者位置
tracking = .follow
}
.ignoresSafeArea()


}

接下來新增以下的程式碼

                ...
.ignoresSafeArea()
// 顯示定位的 Button
VStack {
Spacer()
HStack {
Spacer()
.frame(width: 15)
Button {
tracking = .follow
} label: {
Image(systemName: tracking == .follow ? "location.fill" : "location")
}
.font(.headline)
.padding(8)
.background(Color.white)
.cornerRadius(10)
Spacer()
}
Spacer()
.frame(height: 30)
}

Button 顯示的圖示,會透過 tracking 來判斷你目前是否在現在的位置,若不在現在的位置,點選 Button 即會跳回目前的位置

目前程式運行看起來蠻正常的,但是有一個錯誤是在初次開啟程式與按下 location Button 時會產生的:

Modifying state during view update, this will cause undefined behavior.

初次開啟程式 產生 5 次
每按下 location Button 產生 2 次

這時候必須對我們的程式碼進行修改

LocationManager

import Foundation
import SwiftUI
import MapKit
import CoreLocation

class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate {

// 創建 CLLocationManager 實例以進行位置信息管理
private var locationManager = CLLocationManager()

// 使用 @Published 屬性監聽位置信息的改變,這些屬性將在視圖中自動更新
@Published var location: CLLocation?
@Published var speedInMetersPerSecond: Double = 0.0
@Published var speedInKmPerHour: Double = 0.0
@Published var latitude: String = ""
@Published var longitude: String = ""

// 地圖區域的設定,初始位置位於瑞士茵特拉根的座標
@Published var region: MKCoordinateRegion = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 47.3769, longitude: 8.5417), latitudinalMeters: 2000, longitudinalMeters: 2000)
// 地圖的追蹤模式,初始設定為跟蹤使用者
@Published var tracking = MapUserTrackingMode.follow

override init() {
super.init()

// 設置 CLLocationManager 的屬性和委派
locationManager.delegate = self
// 設定位置精度,這將影響定位的準確程度
locationManager.desiredAccuracy = kCLLocationAccuracyBest
// 設定定位活動類型,以便系統更好地適應定位的需要
locationManager.activityType = .automotiveNavigation
// 設置位置更新的觸發距離,每 3 米更新一次
locationManager.distanceFilter = 3.0
}

// 要求使用者授予應用程式在使用時的位置權限
func requestAuthorization() {
locationManager.requestWhenInUseAuthorization()
}

// 開始監聽位置信息的更新
func startUpdatingLocation() {
locationManager.startUpdatingLocation()
}

// 停止監聽位置信息的更新
func stopUpdatingLocation() {
locationManager.stopUpdatingLocation()
}

// CLLocationManagerDelegate 方法,當位置信息更新時呼叫
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {

// 更新最新的位置信息
location = locations.first
latitude = "\(locations[0].coordinate.latitude)"
longitude = "\(locations[0].coordinate.longitude)"
print(locations[0].coordinate.latitude)
print(locations[0].coordinate.longitude)

// 獲取最新位置的速度信息
if let location = locations.last {
// 獲取速度(以米/秒為單位)
let speed = location.speed
self.speedInMetersPerSecond = speed
self.speedInKmPerHour = speed * 3.6 // 將速度轉換為千米/小時
}
}
}

ContentView

import SwiftUI
import MapKit
import CoreLocation


struct ContentView: View {

// 創建 LocationManager 的狀態物件,用於處理位置資訊
@StateObject private var locationManager = LocationManager()

private var region: Binding<MKCoordinateRegion> {
Binding {
locationManager.region
} set: { region in
DispatchQueue.main.async {
locationManager.region = region
}
}
}

private var tracking: Binding<MapUserTrackingMode> {
Binding {
locationManager.tracking
} set: { tracking in
DispatchQueue.main.async {
locationManager.tracking = tracking
}
}
}


// 格式化後的速度字符串,從 LocationManager 中取得速度資訊
var speed: String {
// 用 max 函數將速度限制為最小為 0
let clampedSpeed = max(locationManager.speedInKmPerHour, 0)
return String(format: "%.0f", clampedSpeed)
}
// 從 LocationManager 中取得的緯度和經度的字串表示
var latitude: String {
locationManager.latitude
}
var longitude: String {
locationManager.longitude
}

var body: some View {
VStack {
ZStack {
// 顯示地圖,啟動位置更新並跟蹤使用者位置
Map(coordinateRegion: region, interactionModes: .all, showsUserLocation: true, userTrackingMode: tracking)
.task {
// 要求使用者授權使用位置資訊
locationManager.requestAuthorization()
// 開始更新位置
locationManager.startUpdatingLocation()
// 設定地圖追蹤模式為跟蹤使用者位置
DispatchQueue.main.async {
locationManager.tracking = .follow
}
}
.ignoresSafeArea()
// 顯示定位的 Button
VStack {
Spacer()
HStack {
Spacer()
.frame(width: 15)
Button {
DispatchQueue.main.async {
locationManager.tracking = .follow
}
} label: {
Image(systemName: locationManager.tracking == .follow ? "location.fill" : "location")
}
.font(.headline)
.padding(8)
.background(Color.white)
.cornerRadius(10)
Spacer()
}
Spacer()
.frame(height: 30)
}

}

HStack {
// 顯示自定義的速度計視圖,角度和速度資訊根據速度計算
SpeedViewModel(frameHeight: 150, angle: 220.0 + Double(2 * (Double(speed) ?? 0.0)) - 0.0, speedText: speed)
.frame(height: 150)

VStack(alignment: .leading, spacing: 20) {
// 顯示緯度和經度的資訊
Text("latitude : \n\(latitude)")
Text("longitude : \n\(longitude)")
}
.frame(maxWidth: 400, alignment: .leading)
.font(.title3)
}
}
}
}



struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

在重新啟動程式後,您會發現之前的錯誤已經消失了。當您在使用 SwiftUI 配合 MapKit 時,可能會出現某些問題。相對而言,如果使用 StoryBoard 進行構建,可能會較不易出現這些問題。

執行結果

GitHub

Reference

--

--