愿望清单 介绍
在这个项目中,我们将构建一个应用程序,使用户可以在地图上构建他们打算一日游的地点的私人列表,添加对该地点的描述,查找附近的有趣地点,然后保存全部存储到iOS内存中以备后用。
要完成所有工作,就意味着要利用您已经遇到的一些技能,例如表单,表单,Codable
和URLSession
,还要教给您一些新技能:如何将地图嵌入SwiftUI应用程序,如何安全存储私有数据,只有经过身份验证的用户才能访问它,以及如何在UserDefaults
之外加载和保存数据,等等。
因此,有很多东西可以学习,还有另一个很棒的应用程序可以制作!不过,还有一个坏消息:将地图嵌入SwiftUI意味着要使用协调器。是的,使用协调器管理SwiftUI视图控制器。
无论如何,让我们开始使用我们的技术:使用Single View App模板创建一个新的iOS项目,并将其命名为 BucketList。现在我们先开始学习技术点...
- 使自定义类型符合 Comparable 协议
- 将数据写入文档目录
- 用枚举切换视图状态
- 与 MapKit 协调器通信
- 集成 MapKit
- 使用 Touch ID 和 Face ID
项目开始
更高级的 MKMapView
该项目将以地图视图为基础,要求用户将要访问的地方添加到地图中。为了完成这项工作,我们不能只是在SwiftUI中嵌入一个简单的MKMapView
,希望做到最好:我们需要跟踪中心坐标,用户是否在查看地点详细信息,他们拥有哪些标注等等。
因此,我们将从具有协调器的基本MKMapView
包装器开始,然后在其上快速添加一些附加功能,以使其变得更加有用。
创建一个名为“ MapView”的新SwiftUI视图,为 MapKit 添加导入,然后为其提供以下代码:
struct MapView: UIViewRepresentable {
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
mapView.delegate = context.coordinator
return mapView
}
func updateUIView(_ view: MKMapView, context: Context) {
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, MKMapViewDelegate {
var parent: MapView
init(_ parent: MapView) {
self.parent = parent
}
}
}
那里没有什么特别的,所以让我们跟踪地图的中心坐标来立即进行更改。正如我们之前所看的,这意味着在我们的协调器中实现mapViewDidChangeVisibleRegion()
方法,但是这次我们将把数据传递给 MapView
结构,以便可以使用@Binding
将值存储在其他位置。因此,协调器将从 MapKit 接收值并将其传递给MapView
,该MapView
将值放在@Binding
属性中,这意味着它实际上存储在其他位置——我们将MKMapView
连接到了任何嵌入了地图的 SwiftUI 视图对象。
首先将此属性添加到MapView
:
@Binding var centerCoordinate: CLLocationCoordinate2D
这将立即破坏MapView_Previews
结构体,因为它需要提供绑定。该预览并不是真正有用的,因为MKMapView
在模拟器之外无法运行,因此,如果您删除了它,我也不会怪您。但是,如果您真的想使其工作,则应向MKPointAnnotation
添加一些示例数据,以便于参考:
extension MKPointAnnotation {
static var example: MKPointAnnotation {
let annotation = MKPointAnnotation()
annotation.title = "London"
annotation.subtitle = "Home to the 2012 Summer Olympics."
annotation.coordinate = CLLocationCoordinate2D(latitude: 51.5, longitude: -0.13)
return annotation
}
}
将其放置在适当的位置即可轻松修复MapView_Previews
,因为我们可以使用该示例注释:
struct MapView_Previews: PreviewProvider {
static var previews: some View {
MapView(centerCoordinate: .constant(MKPointAnnotation.example.coordinate))
}
}
我们将在短时间内添加更多内容,但是首先我想将其放入ContentView
。在这个应用程序中,用户将要向地图上添加他们想要访问的地点,我们将通过全屏MapView
和顶部的半透明圆来表示中心点来表示这些地点。尽管此视图将具有绑定来跟踪中心坐标,但是我们不需要使用该绑定来放置圆——简单的ZStack
可以确保圆始终位于地图的中心。
首先,添加一条额外的导入行,以便我们可以访问MapKit的数据类型:
import MapKit
其次,在ContentView
内部添加一个属性,该属性将存储地图的当前中心坐标。稍后,我们将使用它添加一个地标:
@State private var centerCoordinate = CLLocationCoordinate2D()
现在我们可以填写body
属性
ZStack {
MapView(centerCoordinate: $centerCoordinate)
.edgesIgnoringSafeArea(.all)
Circle()
.fill(Color.blue)
.opacity(0.3)
.frame(width: 32, height: 32)
}
如果您现在运行该应用程序,您会看到可以自由移动地图,但是始终会有一个蓝色圆圈显示中心位置。
尽管我们的蓝点将始终固定在地图的中心,但是我们仍然希望ContentView
在地图移动时更新其centerCoordinate
属性。我们已经将其连接到MapView
,但是仍然需要在地图视图的协调器中实现mapViewDidChangeVisibleRegion()
方法,以启动整个链。
因此,现在将此方法添加到MapView
的Coordinator
类中:
func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) {
parent.centerCoordinate = mapView.centerCoordinate
}
所有这些工作本身并不十分有趣,因此下一步是在右下角添加一个按钮,使我们可以在地图上添加地标。我们已经在ZStack
中,因此最简单的对齐此按钮的方法是将其放置在VStack
和HStack
中,每次放置之前都带有间隔(spacers)。这两个间隔物最终都占据了剩下的全部垂直和水平空间,从而使结尾处的所有内容都舒适地位于右下角。
我们将很快为该按钮添加一些功能,但首先让它安装到位并添加一些基本样式以使其看起来不错。
请将此VStack
添加到Circle
下方:
VStack {
Spacer()
HStack {
Spacer()
Button(action: {
// 创建新的位置
}, label: {
Image(systemName: "plus")
})
.padding()
.background(Color.black.opacity(0.75))
.foregroundColor(.white)
.font(.title)
.clipShape(Circle())
.padding(.trailing)
}
}
PS: 此处Button
创建的闭包使用了Swift 5 修改后的尾随闭包样式,如果拷贝编译不了,请自己手动敲一下就好
请注意,我是如何在其中两次添加padding()
修饰符的——一次是在添加背景色之前确保按钮更大,然后是第二次是设置整个按钮的对边缘增加一些距离。
有趣的是我们如何在地图上放置图钉。我们已将地图的中心坐标绑定到地图视图中的属性,但是现在我们需要以其他方式发送数据——我们需要在ContentView
中创建位置数组,然后将其发送到MKMapView
进行显示。
解决此问题的最佳方法是将问题分解为几个更小,更简单的部分。第一部分很明显:我们在ContentView
中需要一个位置数组,用于存储用户要访问的所有位置。
因此,首先将此属性添加到ContentView
:
@State private var locations = [MKPointAnnotation]()
接下来,我们想在每当点击 + 按钮时添加一个位置。我们还不会添加标题和副标题,因此,现在这就像使用centerCoordinate
的当前值创建MKPointAnnotation
一样简单。
替换// 创建新的位置
注释:
let newLocation = MKPointAnnotation()
newLocation.coordinate = self.centerCoordinate
self.locations.append(newLocation)
现在是具有挑战性的部分:我们如何将其与地图视图同步?请记住,我们甚至不希望ContentView
知道正在使用 MapKit,而是希望将所有功能隔离在MapView
中,以便我们的SwiftUI代码保持干净。
这是updateUIView()
出现的地方:当发送到UIViewRepresentable
结构体的任何值发生更改时,SwiftUI都会自动调用它。然后,此方法负责将视图及其协调器与父视图中的最新配置同步。
在我们的例子中,我们将centerCoordinate
绑定发送到MapView
中,这意味着每当用户移动地图时,值都会更改,这又意味着始终在调用updateUIView()
。由于updateUIView()
为空,所以一直在悄悄发生这种情况,但是如果您在其中添加一个简单的print()
调用,就会发现它栩栩如生:
func updateUIView(_ view: MKMapView, context: Context) {
print("Updating")
}
现在,在四处移动地图时,您会一次又一次看到“Updating”打印。
无论如何,所有这些都很重要,因为我们还可以将刚才创建的locations
数组传递到MapView
中,并使用该数组为我们插入标注。
因此,首先将此新属性添加到MapView
中,以保存我们将传递给它的所有位置:
var annotations: [MKPointAnnotation]
其次,我们需要更新 MapView_Previews
,以便它发送我们的示例标注,尽管如果您已经删除了预览,我也不会怪您,因为这实际上并没有用!无论如何,如果您仍然拥有它,则将其调整为:
MapView(centerCoordinate: .constant(MKPointAnnotation.example.coordinate), annotations: [MKPointAnnotation.example])
第三,我们需要在MapView
中实现updateUIView()
,以便将当前标注与最新标注进行比较,如果它们不相同,则将其替换。现在,我们可以比较标注中的每个项目以查看它们是否相同,但是没有任何意义——我们不能同时添加和删除项目,因此我们要做的就是检查这两个项目是否相同数组包含相同数量的项目,如果它们不同,则删除所有现有的标注并再次添加它们。
将此替换为当前的updateUIView()
方法:
func updateUIView(_ view: MKMapView, context: Context) {
if annotations.count != view.annotations.count {
view.removeAnnotations(view.annotations)
view.addAnnotations(annotations)
}
}
最后,更新ContentView
,使其发送locations
数组以将其转换为标注:
MapView(centerCoordinate: $centerCoordinate, annotations: locations)
到目前为止,地图工作已经足够了,因此请继续运行您的应用——您应该可以根据需要进行任意移动,然后按+按钮添加图钉。
您可能会注意到的一件事是,当iOS针紧密放置时,它们如何自动合并。例如,如果将一些图钉放在一公里的区域中然后缩小,则iOS将隐藏其中的一些图钉,以避免使地图难以阅读。
自定义 MKMapView 标注
将标注添加到MKMapView
只是在正确位置放置标注的问题,但是在此应用中,我们希望用户能够点按位置以获取更多信息,然后再次点按以编辑该位置。要实现所有这些,需要一点 ,一点和一点 ,它们都融合在一起——这是一个有趣的挑战!
第一步是实现mapView(_:viewFor :)
方法,如果我们要提供自定义视图来表示地图标注,则会调用该方法。我们之前已经看过此内容,但是这次我们将使用更高级的解决方案,该解决方案可重复使用视图以提高性能,并添加一个可点击以获取更多信息的按钮。MapKit 以一种奇怪的方式来处理按钮的按下,但这并不难,刚开始时有点奇怪。
无论如何,这里最主要的是重用视图以提高性能。请记住,创建视图的成本很高,因此最好创建少量视图并根据需要对其进行回收——只需更改文本标签,而不是每次都销毁并重新创建它们。
MapKit为我们提供了一个很好而简单的API,用于处理视图重用:我们创建所选择的字符串标识符,然后在地图视图上调用dequeueReusableAnnotationView(withIdentifier :)
,并传入该标识符。如果有等待回收的视图,我们将其取回并可以根据需要进行重新配置;如果没有,我们将返回nil
并需要自己创建视图。
如果确实返回nil
,则意味着我们需要创建视图,这意味着实例化一个新的MKPinAnnotationView
并将其显示出来。但是,我们还将设置一个名为rightCalloutAccessoryView
的属性,在该属性中,我们将放置一个按钮以显示更多信息。
我们不在 SwiftUI 中,这意味着我们无法使用Button
视图。相反,我们需要使用UIKit中的UIButton
。我可以花几个小时来教您有关使用UIButton
的复杂性,但是幸运的是,我不需要:与MapKit一起使用时,它只有一行代码,因为我们可以使用称为.detailDisclosure
的内置按钮样式——看起来像是一个带有圆圈的 “i”。
像UIKit和MapKit中的所有委托方法一样,下一个名称很长。因此,最好的办法是进入MapView
的Coordinator
类,然后键入“viewfor” 以弹出Xcode的代码来完成。希望会弹出正确的MKMapView
方法,您可以按回车键以使其填写完整方法。
完成后,对其进行编辑:
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
// 这是我们视图重用的唯一标识符
let identifier = "Placemark"
// 试图找到一个我们可以回收的视图
var annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier)
if annotationView == nil {
// 我们找不到一个;创建一个新的
annotationView = MKPinAnnotationView(annotation: annotation, reuseIdentifier: identifier)
// 允许它显示弹出信息
annotationView?.canShowCallout = true
// 将信息按钮附加到视图
annotationView?.rightCalloutAccessoryView = UIButton(type: .detailDisclosure)
} else {
// 我们有一个重用的视图,所以给它新的标注
annotationView?.annotation = annotation
}
// 无论是新视图还是复用视图,都将其返回
return annotationView
}
那还行不通,因为即使我们将canShowCallout
设置为true
,MapKit 也不会显示没有标题的注释的标注。我们还没有一种输入标题的方法,所以现在我们只对其中的一个进行硬编码——回到ContentView.swift
并添加以下行,我们可以为locations
数组创建MKPointAnnotation
:
newLocation.title = "Example location"
如果您现在运行该应用程序,则会发现您可以通过按 + 按钮放置大头针,然后点击大头针以显示标题——以及右侧的小“i”按钮。使按钮发挥作用是乐趣所在,尽管对“乐趣”有非常特定的定义。
事情一开始就很简单:我们将在MapView
中添加两个属性,以跟踪我们是否应该显示地点详细信息以及实际选择的地点。它们将构成MKMapView
和 SwiftUI 之间的另一座桥梁,因此我们将用@Binding
标记它们。
@Binding var selectedPlace: MKPointAnnotation?
@Binding var showingPlaceDetails: Bool
我更喜欢将所有@Binding
属性放在一起,但是,这取决于您自己,这会影响 Swift 创建其成员初始化器的方式。
拥有这些额外的属性意味着我们需要调整MapView_Previews
结构体以包括它们,如下所示:
MapView(
centerCoordinate: .constant(MKPointAnnotation.example.coordinate),
selectedPlace: .constant(MKPointAnnotation.example),
showingPlaceDetails: .constant(false),
annotations: [MKPointAnnotation.example]
)
请记住要根据您在MapView
中排列属性的方式来调整这些参数的顺序!
在 ContentView.swift 中,我们需要做很多相同的事情,首先需要传递@State
属性。因此,首先将它们添加到ContentView
:
@State private var selectedPlace: MKPointAnnotation?
@State private var showingPlaceDetails = false
现在,我们可以更新其MapView
行以在以下位置传递这些值:
MapView(centerCoordinate: $centerCoordinate,
selectedPlace: $selectedPlace,
showingPlaceDetails: $showingPlaceDetails,
annotations: locations)
.edgesIgnoringSafeArea(.all)
当showingPlaceDetails
布尔值变为true
时,我们希望显示一个带有当前所选位置的标题和副标题的警报Alert
,以及一个允许用户编辑该位置的按钮。我们还没有准备好进行编辑,但是我们至少可以显示警报并将其连接到 MapKit。
首先将以下alert()
修饰符添加到ContentView
的ZStack
中:
.alert(isPresented: $showingPlaceDetails) {
Alert(title: Text(selectedPlace?.title ?? "Unknown"),
message: Text(selectedPlace?.subtitle ?? "Missinplace information."),
primaryButton: .default(Text("OK")),
secondaryButton: .default(Text("Edit")) {
// edit this place
})
}
最后,我们需要更新·MapView·,以便为标注点击“i”按钮来设置selectedPlace
和showingPlaceDetails
属性。这是通过实现一个名称比以前更长的方法来完成的,所以最好的办法是进入Coordinator
类,然后键入“ mapviewcall” – Xcode的会提供建议,您可以按回车键将其填写。
该方法的重要部分称为calloutAccessoryControlTapped
,在点击按钮时会调用此方法,这取决于我们来决定应该发生什么。在这种情况下,我们将首先检查我们是否有一个MKAnnotationView
,如果有的话,使用它来设置父MapView
的selectedPlace
属性。然后,我们还可以将showingPlaceDetails
设置为true
,这将依次触发ContentView
中的警报——这是另一个链接,这次将地图标注接头连接到我们的警报。
现在将此方法添加到Coordinator
类中:
func mapView(_ mapView: MKMapView,
annotationView view: MKAnnotationView,
calloutAccessoryControlTapped control: UIControl) {
guard let placemark = view.annotation as? MKPointAnnotation else { return }
parent.selectedPlace = placemark
parent.showingPlaceDetails = true
}
完成此步骤后,我们的项目的下一步已完成,因此请立即运行,您应该可以放置图钉,点击它以显示更多信息,然后按``i''按钮以显示警报Alert
。这就将他们联系在一起了!
译自
Bucket List: Introduction
Advanced MKMapView with SwiftUI
Customizing MKMapView annotations