(SwiftUI) SpeedTracker — Resolving [SwiftUI] Modifying state during view update issue with MapKit
目的:獲取用戶位置並與速度儀表一起顯示當前速度、經度和緯度,並解決使用 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()
}
}
使用模擬器來設定位置
點選右上角 Features -> Location -> Custom Location…
輸入經度與緯度後點選 OK
經度緯度可以使用 GoogleMap 查詢
使用 City Run 測試時速表
使用 City Bicycle Ride 測試時速表
使用 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.
這時候必須對我們的程式碼進行修改
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 進行構建,可能會較不易出現這些問題。