第70天 项目 14 第三部分
现在是时候将我们所学的所有技巧付诸实践了,也就是说,我们要构建一个地图视图,在这个视图中可以添加标注并与之交互。在推进过程中,我希望你能稍微思考一下,我们的应用如何从iOS自带的所有标准设计功能中获益,以及这对用户意味着什么——用户已经知道如何使用地图,也知道如何点击标记来激活相关功能。
多年前,史蒂夫·乔布斯曾说过:“设计不只是看起来和摸起来的样子,设计是它的工作方式。”用户之所以“懂”我们的地图如何使用,是因为它的工作方式和iOS上其他所有地图都一样。这意味着用户能快速上手我们的应用,而我们则可以专注于将用户引导到应用中独特且有趣的部分。
今天你需要完成三个主题的学习,在这些主题中,我们将深入探讨如何将MapKit与SwiftUI集成。
- 为地图添加用户位置
- 改进我们的地图标注
- 选择和编辑地图标注
为地图添加用户位置
作者:Paul Hudson 2024年5月7日
本项目将围绕一个地图视图展开,让用户在地图上添加他们想去的地方。要实现这一点,我们需要放置一个占据整个视图的Map,跟踪其标注,还要知道用户是否正在查看地点详情。
我们先从一个全屏的Map视图开始,为它设置初始位置,显示英国——当然,你也可以自行更改这个位置!
首先,添加一行额外的import代码,以便我们能使用MapKit的数据类型:
import MapKit其次,在ContentView内部添加一个属性,用于存储地图的初始位置:
let startPosition = MapCameraPosition.region(
MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 56, longitude: -3),
span: MKCoordinateSpan(latitudeDelta: 10, longitudeDelta: 10)
)
)现在,我们可以填充body属性了:
Map(initialPosition: startPosition)如果现在运行应用,你会发现可以自由移动地图——如果你想更改地图样式,现在正是尝试使用.mapStyle(.hybrid)之类样式的好时机。
单是这些操作本身并没有太大意思,所以下一步是让用户点击地图来添加地标。之前我们用按钮来处理屏幕点击,但在这种情况下,我们需要一种不同的方式,叫做“轻击手势”(tap gesture)——这是一个新的修饰符,可以添加到任何视图上,当用户轻击该视图时触发代码。
重要提示: 许多SwiftUI开发者过度使用轻击手势,这给依赖屏幕阅读器的用户带来了各种各样的问题。如果可能,使用按钮或其他内置控件总是比添加轻击手势更好的选择。在这种情况下,我们别无选择,只能使用轻击手势,因为它能告诉我们用户在地图上的哪个位置进行了点击。
接下来,将Map修改为以下代码:
Map(initialPosition: startPosition)
.onTapGesture { position in
print("点击位置:\(position)")
}如果再次运行应用,你会发现轻击手势不会干扰地图默认的手势操作——你仍然可以平移地图、捏合缩放等等。
然而,点击位置的信息并不理想,因为它给出的是屏幕坐标,而不是地图坐标。要解决这个问题,我们需要在地图周围包裹一个MapReader视图,这样就能在两种坐标类型之间进行转换了。
将代码修改为:
MapReader { proxy in
Map(initialPosition: startPosition)
.onTapGesture { position in
if let coordinate = proxy.convert(position, from: .local) {
print("点击的地图坐标:\(coordinate)")
}
}
}真正有趣的地方在于如何在地图上放置位置标记。我们已经将地图的位置与ContentView中的一个属性绑定,但现在还需要传入一个我们想要显示的位置数组。
这需要几个步骤,首先要定义我们应用中创建的位置类型。这个类型需要遵循几个协议:
Identifiable,这样我们就能在地图中创建多个位置标记。Codable,这样我们就能轻松地加载和保存地图数据。Equatable,这样我们就能在位置数组中找到某个特定的位置。
在数据内容方面,每个位置将包含名称、描述,以及纬度和经度。我们还需要添加一个唯一标识符,这样SwiftUI才能愉快地从动态数据中创建位置标记。
因此,创建一个名为Location.swift的新Swift文件,并添加以下代码:
struct Location: Codable, Equatable, Identifiable {
let id: UUID
var name: String
var description: String
var latitude: Double
var longitude: Double
}将纬度和经度分开存储,能让我们直接获得Codable一致性,这总是很方便的。我们很快会为这个结构体添加更多内容,但目前这些已经足够让我们继续推进了。
现在我们有了一个可以存储单个位置数据的类型,接下来需要一个该类型的数组,用于存储用户想去的所有地方。为了方便推进,我们暂时将这个数组放在ContentView中,但很快也会回来对其进行补充。
所以,首先在ContentView中添加以下属性:
@State private var locations = [Location]()接下来,我们希望每当onTapGesture()触发时,就向这个数组中添加一个位置,因此将当前的onTapGesture()代码替换为:
if let coordinate = proxy.convert(position, from: .local) {
let newLocation = Location(id: UUID(), name: "新位置", description: "", latitude: coordinate.latitude, longitude: coordinate.longitude)
locations.append(newLocation)
}最后,更新ContentView,以便从数组中的每个位置创建标记:
Map(initialPosition: startPosition) {
ForEach(locations) { location in
Marker(location.name, coordinate: CLLocationCoordinate2D(latitude: location.latitude, longitude: location.longitude))
}
}目前关于地图的操作就先到这里,现在运行应用吧——你可以随意移动地图,然后在任何你想添加位置的地方添加标记。
我知道设置这些需要不少工作,但至少你能看到应用的基本框架已经逐渐成型了!
改进我们的地图标注
作者:Paul Hudson 2024年1月7日
目前,我们使用Marker在Map视图中放置位置标记,但SwiftUI允许我们在地图上方放置任何类型的视图,这样我们就能实现完全的自定义。因此,我们将使用这种方式来显示一个包含自定义图标的SwiftUI视图,然后再看看底层的数据类型,想想可以对其进行哪些改进。
多亏了SwiftUI的出色设计,实现这一点几乎不需要多少代码——将现有的Marker代码替换为以下内容:
Annotation(location.name, coordinate: CLLocationCoordinate2D(latitude: location.latitude, longitude: location.longitude)) {
Image(systemName: "star.circle")
.resizable()
.foregroundStyle(.red)
.frame(width: 44, height: 44)
.background(.white)
.clipShape(.circle)
}这样能让我们的位置标记在地图上更加显眼。不过,我想不只是关注SwiftUI视图,还想看看Location结构体本身,并对其进行一些改进,让它变得更好。
首先,我不太喜欢在SwiftUI视图中手动创建CLLocationCoordinate2D,我更希望将这类逻辑移到Location结构体内部。因此,我们可以将其转换为一个计算属性,以简化代码。首先,在Location.swift中导入MapKit,然后向Location中添加以下代码:
var coordinate: CLLocationCoordinate2D {
CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
}现在,ContentView中的代码就更简洁了:
Annotation(location.name, coordinate: location.coordinate) {我想做的第二个改进,是我鼓励所有为SwiftUI构建自定义数据类型的开发者都去做的:添加一个示例!这会让预览变得容易得多,所以在可能的情况下,我建议你为自己的类型添加一个静态的example属性,其中包含一些便于预览的示例数据。
因此,现在就向Location中添加第二个属性:
static let example = Location(id: UUID(), name: "白金汉宫", description: "由超过4万个灯泡照明。", latitude: 51.501, longitude: -0.141)提示: 如果你愿意,可以用#if DEBUG和#endif将static let example这行代码包裹起来,这样在App Store版本的应用中就不会包含这段代码了。
我想在这里做的最后一个改进,是为结构体添加一个自定义的==函数。我们已经让Location遵循了Equatable协议,这意味着我们已经可以使用==来比较两个位置了。在幕后,Swift会通过比较每个属性来为我们自动生成这个函数,但这样做效率很低——我们所有的位置都已经有了唯一标识符,所以如果两个位置的标识符相同,我们就可以确定它们是同一个位置,而无需再检查其他属性。
因此,我们可以为Location编写一个自定义的==函数,只比较两个位置的标识符,这样能节省大量不必要的操作:
static func ==(lhs: Location, rhs: Location) -> Bool {
lhs.id == rhs.id
}我非常支持让结构体默认遵循Equatable协议,即使你不能像上面那样使用优化的比较函数——结构体就像字符串和整数一样,是简单的值类型,我认为我们也应该将这种特性延伸到自己创建的自定义结构体上。
完成这些设置后,项目的下一步就完成了,现在运行应用吧——你应该能够添加标记,并看到我们的自定义标注,而且现在你也知道,在幕后我们的代码也变得更整洁了!
选择和编辑地图标注
作者:Paul Hudson 2024年1月7日
现在用户可以在我们的SwiftUIMap上添加标记了,但他们无法对这些标记进行任何操作——不能为其添加自定义的名称和描述。要解决这个问题,需要几个步骤,同时还要学习一些相关知识,但正如你将看到的,这会让整个应用更加完整。
首先,我们希望当用户选择一个地图标注时,能显示某种表单(sheet),让他们有机会查看或编辑该位置的详情。
之前我们处理表单的方式,是创建一个布尔值来确定表单是否可见,然后传入一些其他数据供表单显示或编辑。不过这次,我们要采用一种不同的方法:只用一个属性来处理所有事情。
因此,现在就在ContentView中添加以下属性:
@State private var selectedPlace: Location?我们的意思是,可能有一个被选中的位置,也可能没有——而这就是SwiftUI显示表单所需知道的全部信息。只要我们给这个可选类型赋值,就相当于告诉SwiftUI显示表单;当表单被关闭时,这个值会自动设回nil。更棒的是,SwiftUI会自动对这个可选类型进行解包,所以当我们创建表单内容时,可以确定自己拥有一个真实可用的值。
要试用这个功能,将以下修饰符附加到Map上:
.sheet(item: $selectedPlace) { place in
Text(place.name)
}如你所见,它接收一个可选类型的绑定,同时还接收一个函数,当可选类型有值时,该函数会接收解包后的可选值。因此,在这个闭包内部,我们的表单可以直接引用place.name,而无需解包可选类型或使用空合运算符。
现在,要让整个功能正常工作,只需通过为标注添加另一个手势来给selectedPlace赋值即可。不过,这里需要注意一点:虽然理论上在这里添加另一个轻击手势应该能很好地工作,但实际上,Map视图经常会在选择现有标注和创建新标注之间产生混淆。因此,我们不添加轻击手势,而是添加一个“长按手势”(long press gesture)。
长按手势的作用正如其名:当用户按住一个视图时,会触发我们指定的代码,这非常适合用于选择位置。
因此,在.clipShape(.circle)这行代码的正下方添加以下内容:
.onLongPressGesture {
selectedPlace = location
}这样就完成了!现在我们可以显示一个表单,展示被选中位置的名称,而且只需要很少的代码。这种可选类型绑定的方式并非在所有情况下都可行,但我认为在可行的情况下,它能让代码更加自然——SwiftUI自动解包可选类型的特性确实非常实用。
当然,只显示位置名称并没有太大用处,所以下一步是创建一个详情视图,让用户可以查看并修改位置的名称和描述。这个视图需要接收一个待编辑的位置,允许用户调整该位置的两个属性值(名称和描述),然后返回一个包含修改后数据的新位置——它的工作方式类似于一个函数,接收数据并返回转换后的数据。
和往常一样,我们从简单的部分开始,逐步推进。创建一个名为“EditView”的新SwiftUI视图,并添加以下代码:
struct EditView: View {
@Environment(\.dismiss) var dismiss
var location: Location
@State private var name: String
@State private var description: String
var body: some View {
NavigationStack {
Form {
Section {
TextField("地点名称", text: $name)
TextField("描述", text: $description)
}
}
.navigationTitle("地点详情")
.toolbar {
Button("保存") {
dismiss()
}
}
}
}
}这段代码无法编译,因为我们面临一个难题:name和description属性应该使用什么初始值?之前我们给@State属性设置了初始值,但在这里无法这样做——它们的初始值应该来自传入的位置数据,这样用户才能看到已保存的数据。
解决方法是创建一个新的初始化器,接收一个位置参数,并使用该位置的数据来创建State结构体。这与我们在初始化器内部创建SwiftData查询时使用的下划线方法相同,通过这种方法,我们可以创建属性包装器的实例,而不是包装器内部的数据实例。
因此,要解决这个问题,我们需要向EditView添加以下初始化器:
init(location: Location) {
self.location = location
_name = State(initialValue: location.name)
_description = State(initialValue: location.description)
}你需要修改预览代码,使其使用这个初始化器:
#Preview {
EditView(location: .example)
}这样代码就能编译了,但我们还有第二个问题:当编辑完位置后,如何将新的位置数据传回去?我们可以使用类似@Binding的方式传入一个外部值,但这会给ContentView中的可选类型带来问题——我们希望EditView绑定到一个真实的值,而不是可选值,否则会变得很混乱。
我们将采用最简单的解决方案:要求传入一个函数,我们可以通过这个函数传回任何新的位置数据。这意味着其他任何SwiftUI视图都可以向我们发送一些数据,并获取回一些新数据,然后按自己的需求进行处理。
首先,向EditView添加以下属性:
var onSave: (Location) -> Void这个属性要求传入一个接收单个位置参数且无返回值的函数,这非常符合我们的使用场景。我们需要在初始化器中接收这个函数,如下所示:
init(location: Location, onSave: @escaping (Location) -> Void) {
self.location = location
self.onSave = onSave
_name = State(initialValue: location.name)
_description = State(initialValue: location.description)
}其中的@escaping很重要,它表示这个函数会被存储起来供后续使用,而不是立即调用。在这里需要使用@escaping,因为onSave函数只有在用户点击“保存”按钮时才会被调用。
说到保存按钮,我们需要更新这个按钮的代码,创建一个包含修改后详情的新位置,并通过onSave()将其传回去:
Button("保存") {
var newLocation = location
newLocation.name = name
newLocation.description = description
onSave(newLocation)
dismiss()
}通过创建原始位置的可变副本,我们可以访问其现有的数据——包括标识符、纬度和经度。
别忘了也要更新预览代码——在这里传入一个占位闭包就可以了:
EditView(location: .example) { _ in }至此,EditView的创建暂时完成了,但在ContentView中还有一些工作要做,因为我们需要在表单中展示新的UI,传入被选中的位置,同时还要处理更新后的变化。
不过,得益于我们之前的代码架构,这只需要几行代码就能完成——将以下代码放入ContentView的sheet()修饰符中:
EditView(location: place) { newLocation in
if let index = locations.firstIndex(of: place) {
locations[index] = newLocation
}
}这段代码将位置传入EditView,同时传入一个闭包,当点击“保存”按钮时,这个闭包就会执行。闭包接收新的位置,然后查找当前位置在数组中的索引,并将其替换为新位置。这样一来,地图就会立即根据新数据进行更新。
现在运行应用试试——看看你能否发现代码中的问题。希望这个问题足够明显:重命名功能实际上无法正常工作!
问题在于,我们之前告诉SwiftUI,如果两个位置的ID相同,它们就是相同的位置,但现在情况不同了——当我们修改一个标记的名称时,SwiftUI会比较旧标记和新标记,发现它们的ID相同,因此不会费心去更新地图。
解决方法是将id属性设为可变的,如下所示:
var id: UUID现在,我们可以在创建新位置时修改这个ID:
var newLocation = location
newLocation.id = UUID()
newLocation.name = name
newLocation.description = description关于是从头创建一个全新的对象更好,还是像我们现在这样复制现有对象并修改需要更改的部分,并没有绝对的规则;我建议你多尝试,找到自己喜欢的方法。
无论如何,完成这些修改后,再次运行代码吧。当然,目前它还不能保存任何数据,但你现在已经可以添加任意数量的位置,并给它们起有意义的名称了。