MapKit
使用 SwiftUI MapKit 和现代 CoreLocation 异步 API 构建面向 iOS 17+ 的地图和基于位置的功能。使用带有 MapContentBuilder 的 Map 构建视图,使用 CLLocationUpdate.liveUpdates() 进行流式位置更新,使用 CLMonitor 进行地理围栏。
有关扩展的 MapKit 模式,请参阅 references/mapkit-patterns.md;有关 CoreLocation 模式,请参阅 references/mapkit-corelocation-patterns.md。
目录
工作流程
1. 添加带有标记或标注的地图
- 导入
MapKit。 - 创建一个
Map视图,可选择绑定MapCameraPosition。 - 在
MapContentBuilder闭包内添加Marker、Annotation、MapPolyline、MapPolygon或MapCircle。 - 使用
.mapStyle()配置地图样式。 - 使用
.mapControls { }添加地图控件。 - 使用
selection:绑定处理选择事件。
2. 跟踪用户位置
- 在 Info.plist 中添加
NSLocationWhenInUseUsageDescription。 - 在 iOS 18+ 上,创建一个
CLServiceSession来管理授权。 - 在
Task中迭代CLLocationUpdate.liveUpdates()。 - 在更新 UI 之前,根据距离或精度过滤更新。
- 当不再需要位置跟踪时停止任务。
3. 搜索地点
- 配置
MKLocalSearchCompleter用于自动补全建议。 - 在设置查询之前,对用户输入进行防抖处理(至少 300 毫秒)。
- 将选中的补全项转换为
MKLocalSearch.Request以获取完整结果。 - 将结果显示为标记或列表。
4. 获取导航并显示路线
- 创建一个包含源和目标
MKMapItem的MKDirections.Request。 - 设置
transportType(.automobile、.walking、.transit、.cycling)。 - 等待
MKDirections.calculate()。 - 使用
MapPolyline(route.polyline)绘制路线。
5. 审查现有的地图/位置代码
运行本文件末尾的审查清单。
SwiftUI Map 视图 (iOS 17+)
import MapKit
import SwiftUI
struct PlaceMap: View {
@State private var position: MapCameraPosition = .automatic
var body: some View {
Map(position: $position) {
Marker("Apple Park", coordinate: applePark)
Marker("Infinite Loop", systemImage: "building.2",
coordinate: infiniteLoop)
}
.mapStyle(.standard(elevation: .realistic))
.mapControls {
MapUserLocationButton()
MapCompass()
MapScaleView()
}
}
}
标记和标注
// 气球标记 —— 定位地点的最简单方式
Marker("Cafe", systemImage: "cup.and.saucer.fill", coordinate: cafeCoord)
.tint(.brown)
// 标注 —— 在坐标上自定义 SwiftUI 视图
Annotation("You", coordinate: userCoord, anchor: .bottom) {
Image(systemName: "figure.wave")
.padding(6)
.background(.blue.gradient, in: .circle)
.foregroundStyle(.white)
}
叠加层:折线、多边形、圆形
Map {
// 根据坐标绘制折线
MapPolyline(coordinates: routeCoords)
.stroke(.blue, lineWidth: 4)
// 多边形(区域高亮)
MapPolygon(coordinates: parkBoundary)
.foregroundStyle(.green.opacity(0.3))
.stroke(.green, lineWidth: 2)
// 圆形(围绕点的一定半径范围)
MapCircle(center: storeCoord, radius: 500)
.foregroundStyle(.red.opacity(0.15))
.stroke(.red, lineWidth: 1)
}
相机位置
MapCameraPosition 控制地图显示的内容。绑定它可以让用户交互或以编程方式移动相机。
// 中心到某个区域
@State private var position: MapCameraPosition = .region(
MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 37.334, longitude: -122.009),
span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05)
)
)
// 跟随用户位置
@State private var position: MapCameraPosition = .userLocation(fallback: .automatic)
// 特定相机角度(3D 透视)
@State private var position: MapCameraPosition = .camera(
MapCamera(centerCoordinate: applePark, distance: 1000, heading: 90, pitch: 60)
)
// 框选特定项
position = .item(MKMapItem.forCurrentLocation())
position = .rect(MKMapRect(...))
地图样式
.mapStyle(.standard) // 默认道路地图
.mapStyle(.standard(elevation: .realistic, showsTraffic: true))
.mapStyle(.imagery) // 卫星
.mapStyle(.imagery(elevation: .realistic)) // 3D 卫星
.mapStyle(.hybrid) // 卫星 + 标签
.mapStyle(.hybrid(elevation: .realistic, showsTraffic: true))
地图交互模式
.mapInteractionModes(.all) // 默认:平移、缩放、旋转、倾斜
.mapInteractionModes(.pan) // 仅平移
.mapInteractionModes([.pan, .zoom]) // 平移和缩放
.mapInteractionModes([]) // 静态地图(无交互)
地图选择
@State private var selectedMarker: MKMapItem?
Map(selection: $selectedMarker) {
ForEach(places) { place in
Marker(place.name, coordinate: place.coordinate)
.tag(place.mapItem) // 标签类型必须匹配选择类型
}
}
.onChange(of: selectedMarker) { _, newValue in
guard let item = newValue else { return }
// 响应选择
}
CoreLocation 现代 API
CLLocationUpdate.liveUpdates() (iOS 17+)
用单个异步序列替换 CLLocationManagerDelegate 回调。每次迭代产生一个包含可选 CLLocation 的 CLLocationUpdate。
import CoreLocation
@Observable
final class LocationTracker: @unchecked Sendable {
var currentLocation: CLLocation?
private var updateTask: Task<Void, Never>?
func startTracking() {
updateTask = Task {
let updates = CLLocationUpdate.liveUpdates()
for try await update in updates {
guard let location = update.location else { continue }
// 根据水平精度过滤
guard location.horizontalAccuracy < 50 else { continue }
await MainActor.run {
self.currentLocation = location
}
}
}
}
func stopTracking() {
updateTask?.cancel()
updateTask = nil
}
}
CLServiceSession (iOS 18+)
声明某个功能生命周期内的授权要求。在需要位置服务期间持有对该会话的引用。
// 使用中授权,首选完全精度
let session = CLServiceSession(
authorization: .whenInUse,
fullAccuracyPurposeKey: "NearbySearchPurpose"
)
// 将 `session` 作为存储属性持有;完成后释放它。
在 iOS 18+ 上,如果你没有显式创建 CLServiceSession,CLLocationUpdate.liveUpdates() 和 CLMonitor 会使用隐式 CLServiceSession。当你需要 .always 授权或完全精度时,请显式创建一个。
授权流程
// Info.plist 键(必需):
// NSLocationWhenInUseUsageDescription
// NSLocationAlwaysAndWhenInUseUsageDescription(仅当需要 .always 时)
// 检查授权并在被拒绝时引导用户前往设置
struct LocationPermissionView: View {
@Environment(\.openURL) private var openURL
var body: some View {
ContentUnavailableView {
Label("位置访问被拒绝", systemImage: "location.slash")
} description: {
Text("请在设置中启用位置访问以使用此功能。")
} actions: {
Button("打开设置") {
if let url = URL(string: UIApplication.openSettingsURLString) {
openURL(url)
}
}
}
}
}
地理编码
CLGeocoder (iOS 8+)
let geocoder = CLGeocoder()
// 正向地理编码:地址字符串 -> 坐标
let placemarks = try await geocoder.geocodeAddressString("1 Apple Park Way, Cupertino")
if let location = placemarks.first?.location {
print(location.coordinate) // CLLocationCoordinate2D
}
// 反向地理编码:坐标 -> 地标
let location = CLLocation(latitude: 37.3349, longitude: -122.0090)
let placemarks = try await geocoder.reverseGeocodeLocation(location)
if let placemark = placemarks.first {
let address = [placemark.name, placemark.locality, placemark.administrativeArea]
.compactMap { $0 }
.joined(separator: ", ")
}
MKGeocodingRequest 和 MKReverseGeocodingRequest (iOS 26+)
新的 MapKit 原生地理编码,返回包含更丰富数据的 MKMapItem,并支持 MKAddress / MKAddressRepresentations 以灵活地格式化地址。
@available(iOS 26, *)
func reverseGeocode(location: CLLocation) async throws -> MKMapItem? {
guard let request = MKReverseGeocodingRequest(location: location) else {
return nil
}
let mapItems = try await request.mapItems
return mapItems.first
}
@available(iOS 26, *)
func forwardGeocode(address: String) async throws -> [MKMapItem] {
guard let request = MKGeocodingRequest(addressString: address) else { return [] }
return try await request.mapItems
}
搜索
MKLocalSearchCompleter (自动补全)
@Observable
final class SearchCompleter: NSObject, MKLocalSearchCompleterDelegate {
var results: [MKLocalSearchCompletion] = []
var query: String = "" { didSet { completer.queryFragment = query } }
private let completer = MKLocalSearchCompleter()
override init() {
super.init()
completer.delegate = self
completer.resultTypes = [.address, .pointOfInterest]
}
func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
results = completer.results
}
func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) {
results = []
}
}
MKLocalSearch (完整搜索)
func search(for completion: MKLocalSearchCompletion) async throws -> [MKMapItem] {
let request = MKLocalSearch.Request(completion: completion)
request.resultTypes = [.pointOfInterest, .address]
let search = MKLocalSearch(request: request)
let response = try await search.start()
return response.mapItems
}
// 在区域内根据自然语言查询搜索
func searchNearby(query: String, region: MKCoordinateRegion) async throws -> [MKMapItem] {
let request = MKLocalSearch.Request()
request.naturalLanguageQuery = query
request.region = region
let search = MKLocalSearch(request: request)
let response = try await search.start()
return response.mapItems
}
导航
func getDirections(from source: MKMapItem, to destination: MKMapItem,
transport: MKDirectionsTransportType = .automobile) async throws -> MKRoute? {
let request = MKDirections.Request()
request.source = source
request.destination = destination
request.transportType = transport
let directions = MKDirections(request: request)
let response = try await directions.calculate()
return response.routes.first
}
在地图上显示路线
@State private var route: MKRoute?
Map {
if let route {
MapPolyline(route.polyline)
.stroke(.blue, lineWidth: 5)
}
Marker("起点", coordinate: startCoord)
Marker("终点", coordinate: endCoord)
}
.task {
route = try? await getDirections(from: startItem, to: endItem)
}
ETA 计算
func getETA(from source: MKMapItem, to destination: MKMapItem) async throws -> TimeInterval {
let request = MKDirections.Request()
request.source = source
request.destination = destination
let directions = MKDirections(request: request)
let response = try await directions.calculateETA()
return response.expectedTravelTime
}
骑行导航 (iOS 26+)
@available(iOS 26, *)
func getCyclingDirections(to destination: MKMapItem) async throws -> MKRoute? {
let request = MKDirections.Request()
request.source = MKMapItem.forCurrentLocation()
request.destination = destination
request.transportType = .cycling
let directions = MKDirections(request: request)
let response = try await directions.calculate()
return response.routes.first
}
PlaceDescriptor (iOS 26+)
无需地点 ID,即可从坐标或地址创建丰富的地点引用。需要 import GeoToolbox。
@available(iOS 26, *)
func lookupPlace(name: String, coordinate: CLLocationCoordinate2D) async throws -> MKMapItem {
let descriptor = PlaceDescriptor(
representations: [.coordinate(coordinate)],
commonName: name
)
let request = MKMapItemRequest(placeDescriptor: descriptor)
return try await request.mapItem
}
常见错误
切勿: 一开始就请求 .authorizedAlways——用户不信任过宽的权限。
应该: 从 .requestWhenInUseAuthorization() 开始,仅当用户启用了后台功能时才升级到 .always。
切勿: 在 iOS 17+ 上为简单的位置获取使用 CLLocationManagerDelegate。
应该: 使用 CLLocationUpdate.liveUpdates() 异步流以获得更清晰、更简洁的代码。
切勿: 在地图/视图不可见时保持位置更新运行(会消耗电池)。
应该: 在 SwiftUI 中使用 .task { },这样更新会在视图消失时自动取消。
切勿: 强制解包 CLPlacemark 属性——它们都是可选的。
应该: 使用 nil 合并:placemark.locality ?? "Unknown"。
切勿: 在用户每次按键时都触发 MKLocalSearchCompleter 查询——会导致速率限制和糟糕的 UX。
[文件被截断以完成翻译]