-
Notifications
You must be signed in to change notification settings - Fork 314
Creating a macOS App
在创建了一个 watchOS 版本的
Landmarks之后,让我们把目光投向更大的内容:将Landmarks运行在 Mac 上。在你目前为止所学到的基础上,强化你在构建 iOS、watchOS 和 macOS 的 SwiftUI 应用的经验。首先,给项目添加一个 macOS target,然后重用在 iOS app 中创建的共享数据。当所有资源都准备好后,你就可以通过创建 SwiftUI 视图,在 macOS 上显示详细信息和列表视图。
下载项目文件并按照以下步骤操作,也可以打开已完成的项目自行浏览代码。
- 预计完成时间:25 分钟
- 项目文件:下载
首先要给项目添加一个 macOS target。用 Xcode 给 macOS app 添加新的目录和一组初始文件,以及构建和运行该应用程序需要的 scheme。
1.1 选择 File > New > Target。出现模版选单后,选择 macOS 栏目,选中 App 模版然后点击 Next 。
这个模版会添加一个新的 macOS app target 到项目中。
1.2 在选单中,Product Name 输入 MacLandmarks 。把 Language 设置成 Swift ,把 User Interface 设置成 SwiftUI ,然后点击 Finish 。
1.3 将 scheme 设置成 MacLandmarks > My Mac 。
将 scheme 设置成 My Mac 之后,你就可以预览、构建和运行这个 macOS app 了。
接下来要构建的 app 依赖于低版本的 macOS 所不具备的某些功能,因此你需要更改 Deployment Target。
1.4 在项目导航栏中,选择顶部的 Xcode 项目,选择 target 下面的 MacLandmarks ,然后将 Deployment Target 设置成 10.15.3 。
1.5 在 MacLandmarks 目录中,选中 ContentView.swift ,打开 Canvas,然后点击 Resume 来观察预览。
和 iOS app 一样,SwiftUI 提供了默认的主视图及其 preview provider,让我们可以预览 app 的主窗口。
ContentView.swift
import SwiftUI struct ContentView: View { var body: some View { Text("Hello, World!") .frame(maxWidth: .infinity, maxHeight: .infinity) } } struct ContentView_Preview: PreviewProvider { static var previews: some View { ContentView() } }
接下来,我们会从 iOS app 中重用模型和资源文件,并在 macOS target 中共享。
2.1 在项目导航中,打开 Landmarks 目录并选中 Models 和 Resources 中所有的文件。
landmarkData.json 文件包含在这个教程的初始项目中,它给每个 landmark 包含一个新的描述字段,这在以前的教程中是没有的。
2.2 在文件检查器中,将刚才选中文件的 Target Membership 设置成 MacLandmarks 。
在构建视图时,app 需要访问这些共享资源。
为了使用新的描述字段,我们需要给 Landmark 结构体添加一个新的对应字段。
2.3 打开 Landmark.swift 文件,添加一个新的描述属性。
由于基于 Codable 协议来加载数据,因此只需要确保属性名称与 JSON 中用于加载新数据的名称一致就可以了。
Landmark.swift
import SwiftUI import CoreLocation struct Landmark: Hashable, Codable, Identifiable { var id: Int var name: String fileprivate var imageName: String fileprivate var coordinates: Coordinates var state: String var park: String var category: Category var isFavorite: Bool var isFeatured: Bool var description: String // var locationCoordinate: CLLocationCoordinate2D { CLLocationCoordinate2D( latitude: coordinates.latitude, longitude: coordinates.longitude) } var featureImage: Image? { guard isFeatured else { return nil } return Image( ImageStore.loadImage(name: "\(imageName)_feature"), scale: 2, label: Text(name)) } enum Category: String, CaseIterable, Codable, Hashable { case featured = "Featured" case lakes = "Lakes" case rivers = "Rivers" case mountains = "Mountains" } } extension Landmark { var image: Image { ImageStore.shared.image(name: imageName) } } struct Coordinates: Hashable, Codable { var latitude: Double var longitude: Double }
使用 SwiftUI 的时候,通常从下至上构建视图。先创建较小的视图,然后将其组装为较大的视图。
首先,为 macOS 定义列表的单行的布局。该行包含 landmark 的名称、它的位置、一个图片以及表示这个 landmark 是否被收藏的可选标记。
3.1 给 MacLandmarks 目录添加一个新的 SwiftUI 视图,起名叫做 LandmarkRow.swift 。
这个视图和 iOS app 中一个的文件重名,但每个文件都有一个仅包含对应 app 的 target membership,这样就可以避免文件冲突。
LandmarkRow.swift
import SwiftUI struct LandmarkRow: View { var body: some View { Text("Hello, World!") } } struct LandmarkRow_Previews: PreviewProvider { static var previews: some View { LandmarkRow() } }
3.2 给 LandmarkRow 结构体添加一个 landmark 属性,然后给 preview provider 添加一个 landmark 用来显示。
LandmarkRow.swift
import SwiftUI struct LandmarkRow: View { var landmark: Landmark // var body: some View { Text("Hello, World!") } } struct LandmarkRow_Previews: PreviewProvider { static var previews: some View { LandmarkRow(landmark: landmarkData[0]) // } }
3.3 用以一个水平 stack 来替换掉占位符,它用来绘制 landmark 的图片。
LandmarkRow.swift
import SwiftUI struct LandmarkRow: View { var landmark: Landmark var body: some View { // HStack(alignment: .center) { landmark.image .resizable() .aspectRatio(1.0, contentMode: .fit) .frame(width: 32, height: 32) .fixedSize(horizontal: true, vertical: false) .cornerRadius(4.0) } // } } struct LandmarkRow_Previews: PreviewProvider { static var previews: some View { LandmarkRow(landmark: landmarkData[0]) } }
3.4 添加关于 landmark 的文字,然后组合到一个竖直 stack 中。
LandmarkRow.swift
import SwiftUI struct LandmarkRow: View { var landmark: Landmark var body: some View { HStack(alignment: .center) { landmark.image .resizable() .aspectRatio(1.0, contentMode: .fit) .frame(width: 32, height: 32) .fixedSize(horizontal: true, vertical: false) .cornerRadius(4.0) // VStack(alignment: .leading) { Text(landmark.name) .fontWeight(.bold) .truncationMode(.tail) .frame(minWidth: 20) Text(landmark.park) .font(.caption) .opacity(0.625) .truncationMode(.middle) } // } } } struct LandmarkRow_Previews: PreviewProvider { static var previews: some View { LandmarkRow(landmark: landmarkData[0]) } }
3.5 给视图添加一个收藏指示器,然后通过一个 spacer 和已有的内容隔开。
spacer 可以将已有的内容推到左边,但是需要出现在右边的指示器现在还看不到,因为我们还没有把对应的图片资源添加到 app 中。
LandmarkRow.swift
import SwiftUI struct LandmarkRow: View { var landmark: Landmark var body: some View { HStack(alignment: .center) { landmark.image .resizable() .aspectRatio(1.0, contentMode: .fit) .frame(width: 32, height: 32) .fixedSize(horizontal: true, vertical: false) .cornerRadius(4.0) VStack(alignment: .leading) { Text(landmark.name) .fontWeight(.bold) .truncationMode(.tail) .frame(minWidth: 20) Text(landmark.park) .font(.caption) .opacity(0.625) .truncationMode(.middle) } // Spacer() if landmark.isFavorite { Image("star-filled") .resizable() .renderingMode(.template) .foregroundColor(.yellow) .frame(width: 10, height: 10) } // } } } struct LandmarkRow_Previews: PreviewProvider { static var previews: some View { LandmarkRow(landmark: landmarkData[0]) } }
3.6 在下载的项目资源目录中,把 star-filled.pdf 和 star-empty.pdf 文件拖拽到 Mac app 的资源文件夹中。
3.7 给行视图的内容添加一个竖直 padding,用来显示一个星星并填充黄色来表示收藏。
之后将多个行视图放在一个列表中时,padding 能提高可读性。
LandmarkRow.swift
import SwiftUI struct LandmarkRow: View { var landmark: Landmark var body: some View { HStack(alignment: .center) { landmark.image .resizable() .aspectRatio(1.0, contentMode: .fit) .frame(width: 32, height: 32) .fixedSize(horizontal: true, vertical: false) .cornerRadius(4.0) VStack(alignment: .leading) { Text(landmark.name) .fontWeight(.bold) .truncationMode(.tail) .frame(minWidth: 20) Text(landmark.park) .font(.caption) .opacity(0.625) .truncationMode(.middle) } Spacer() if landmark.isFavorite { Image("star-filled") .resizable() .renderingMode(.template) .foregroundColor(.yellow) .frame(width: 10, height: 10) } } .padding(.vertical, 4) // } } struct LandmarkRow_Previews: PreviewProvider { static var previews: some View { LandmarkRow(landmark: landmarkData[0]) } }
通过使用上一步定义的行视图,我们可以创建一个列表来给用户展示所有的已知 landmark。当 UserData 中的 showFavoritesOnly 属性为 true 时,我们限制只显示被收藏的 landmark。
4.1 在构建中添加一个新的 SwiftUI 视图,起名为 LandmarkList.swift 。
LandmarkList.swift
import SwiftUI struct LandmarkList: View { var body: some View { Text("Hello, World!") } } struct LandmarkList_Previews: PreviewProvider { static var previews: some View { LandmarkList() } }
4.2 添加一个 userData 属性作为 environment object,然后更新 preview provider。
这样视图就可以访问描述 landmark 的全局 UserData 了。
LandmarkList.swift
import SwiftUI struct LandmarkList: View { @EnvironmentObject private var userData: UserData // var body: some View { Text("Hello, World!") } } struct LandmarkList_Previews: PreviewProvider { static var previews: some View { LandmarkList() .environmentObject(UserData()) // } }
4.3 创建一个列表,用来持有我们在 LandmarkRow 中创建的行视图。
LandmarkList.swift
import SwiftUI struct LandmarkList: View { @EnvironmentObject private var userData: UserData var body: some View { // List { ForEach(userData.landmarks) { landmark in LandmarkRow(landmark: landmark) } } // } } struct LandmarkList_Previews: PreviewProvider { static var previews: some View { LandmarkList() .environmentObject(UserData()) } }
4.4 为了让行视图可选中,我们需要为列表提供一个可选的选中 landmark 的 binding,并用 landmark 来标记它。
之后,我们会使用选中的 landmark 来驱动详细视图的内容。
LandmarkList.swift
import SwiftUI struct LandmarkList: View { @EnvironmentObject private var userData: UserData @Binding var selectedLandmark: Landmark? // var body: some View { List(selection: $selectedLandmark) { // ForEach(userData.landmarks) { landmark in LandmarkRow(landmark: landmark).tag(landmark) // } } } } struct LandmarkList_Previews: PreviewProvider { static var previews: some View { LandmarkList(selectedLandmark: .constant(landmarkData[0])) // .environmentObject(UserData()) } }
4.5 根据 showFavoritesOnly 属性的状态,以及收藏 landmark 的状态的组合来限制行视图的创建。
LandmarkList.swift
import SwiftUI struct LandmarkList: View { @EnvironmentObject private var userData: UserData @Binding var selectedLandmark: Landmark? var body: some View { List(selection: $selectedLandmark) { ForEach(userData.landmarks) { landmark in // if (!self.userData.showFavoritesOnly || landmark.isFavorite) { LandmarkRow(landmark: landmark).tag(landmark) } // } } } } struct LandmarkList_Previews: PreviewProvider { static var previews: some View { LandmarkList(selectedLandmark: .constant(landmarkData[0])) .environmentObject(UserData()) } }
因为用户可以将一个 landmark 标记为收藏,所以我们需要提供一个方法只显示它们收藏的 landmark。创建一个过滤视图,它使用一个 Toggle 为用户提供一个复选框,用户可以单击该复选框打开或关闭过滤。
为了让用户快速缩小自己收藏的 landmark 列表的范围,我们会添加一个 Picker 来创建一个弹出按钮,用户可以根据自己设置的任何分类来过滤自己的收藏。
5.1 给构建添加一个新的 SwiftUI 视图,起名 Filter.swift 。
Filter.swift
import SwiftUI struct Filter: View { var body: some View { Text("Hello, World!") } } struct Filter_Previews: PreviewProvider { static var previews: some View { Filter() } }
5.2 添加一个 userData 属性作为 environment object,然后更新 preview provider。
Filter.swift
import SwiftUI struct Filter: View { @EnvironmentObject private var userData: UserData // var body: some View { Text("Hello, World!") } } struct Filter_Previews: PreviewProvider { static var previews: some View { Filter() .environmentObject(UserData()) // } }
5.3 将默认的文本替换成绑定到 showFavoritesOnly 布尔值的 toggle,并为其指定适当的 label。
当用户修改这个 toggle,列表视图会自动刷新。因为它绑定到了环境中的相同的 showFavoritesOnly 值。
Filter.swift
import SwiftUI struct Filter: View { @EnvironmentObject private var userData: UserData var body: some View { // HStack { Toggle(isOn: $userData.showFavoritesOnly) { Text("Favorites only") } } // } } struct Filter_Previews: PreviewProvider { static var previews: some View { Filter() .environmentObject(UserData()) } }
我们可以使用 landmark 分类信息来定义其他过滤。
5.4 创建一个 FilterType 来持有一个 landmark 的分类和对应的名字。
保证它符合 Hashable 协议,我们就可以将这个 FilterType 作为一个 picker 的选项,同时用名字作为选项的说明。
Filter.swift
import SwiftUI struct Filter: View { @EnvironmentObject private var userData: UserData var body: some View { HStack { Toggle(isOn: $userData.showFavoritesOnly) { Text("Favorites only") } } } } struct Filter_Previews: PreviewProvider { static var previews: some View { Filter() .environmentObject(UserData()) } } // struct FilterType: Hashable { var name: String var category: Landmark.Category? init(_ category: Landmark.Category) { self.name = category.rawValue self.category = category } } //
5.5 定义一个全部类型来表示不需要过滤。
这个额外类型需要一个新的初始化方法来处理 nil 类别的特殊情况。
Filter.swift
import SwiftUI struct Filter: View { @EnvironmentObject private var userData: UserData var body: some View { HStack { Toggle(isOn: $userData.showFavoritesOnly) { Text("Favorites only") } } } } struct Filter_Previews: PreviewProvider { static var previews: some View { Filter() .environmentObject(UserData()) } } struct FilterType: Hashable { var name: String var category: Landmark.Category? init(_ category: Landmark.Category) { self.name = category.rawValue self.category = category } // init(name: String) { self.name = name self.category = nil } static var all = FilterType(name: "All") // }
遵循 CaseIterable 和 Identifiable 协议可以将 FilterType 结构体作为 ForEach 初始化方法中的数据,这样我们就可以将它添加到后续两步中。
5.6 实现 CaseIterable 协议,提供一个列表来表示所有可能的情况。
Filter.swift
import SwiftUI struct Filter: View { @EnvironmentObject private var userData: UserData var body: some View { HStack { Toggle(isOn: $userData.showFavoritesOnly) { Text("Favorites only") } } } } struct Filter_Previews: PreviewProvider { static var previews: some View { Filter() .environmentObject(UserData()) } } struct FilterType: CaseIterable, Hashable { // var name: String var category: Landmark.Category? init(_ category: Landmark.Category) { self.name = category.rawValue self.category = category } init(name: String) { self.name = name self.category = nil } static var all = FilterType(name: "All") // static var allCases: [FilterType] { return [.all] + Landmark.Category.allCases.map(FilterType.init) } // }
5.7 实现 Identifiable 协议,定义 id 属性。
Filter.swift
import SwiftUI struct Filter: View { @EnvironmentObject private var userData: UserData var body: some View { HStack { Toggle(isOn: $userData.showFavoritesOnly) { Text("Favorites only") } } } } struct Filter_Previews: PreviewProvider { static var previews: some View { Filter() .environmentObject(UserData()) } } struct FilterType: CaseIterable, Hashable, Identifiable { // var name: String var category: Landmark.Category? init(_ category: Landmark.Category) { self.name = category.rawValue self.category = category } init(name: String) { self.name = name self.category = nil } static var all = FilterType(name: "All") static var allCases: [FilterType] { return [.all] + Landmark.Category.allCases.map(FilterType.init) } // var id: FilterType { return self } // }
5.8 在过滤视图中,添加一个 picker,它使用 FilterType 实例的 binding 作为选项,使用 FilterType 的名字作为菜单的选择。
对 FilterType 实例使用 binding,可以让该视图的父视图观察用户的选择。
Filter.swift
import SwiftUI struct Filter: View { @EnvironmentObject private var userData: UserData @Binding var filter: FilterType // var body: some View { HStack { // Picker(selection: $filter, label: EmptyView()) { ForEach(FilterType.allCases) { choice in Text(choice.name).tag(choice) } } Spacer() // Toggle(isOn: $userData.showFavoritesOnly) { Text("Favorites only") } } } } struct Filter_Previews: PreviewProvider { static var previews: some View { Filter(filter: .constant(.all)) // .environmentObject(UserData()) } } struct FilterType: CaseIterable, Hashable, Identifiable { var name: String var category: Landmark.Category? init(_ category: Landmark.Category) { self.name = category.rawValue self.category = category } init(name: String) { self.name = name self.category = nil } static var all = FilterType(name: "All") static var allCases: [FilterType] { return [.all] + Landmark.Category.allCases.map(FilterType.init) } var id: FilterType { return self } }
5.9 返回上一节中的列表视图,添加一个 FilterType 的 binding。
与过滤器视图一样,将会与父视图共享。
LandmarkList.swift
import SwiftUI struct LandmarkList: View { @EnvironmentObject private var userData: UserData @Binding var selectedLandmark: Landmark? @Binding var filter: FilterType // var body: some View { List(selection: $selectedLandmark) { ForEach(userData.landmarks) { landmark in if (!self.userData.showFavoritesOnly || landmark.isFavorite) { LandmarkRow(landmark: landmark).tag(landmark) } } } } } struct LandmarkList_Previews: PreviewProvider { static var previews: some View { // LandmarkList(selectedLandmark: .constant(landmarkData[0]), filter: .constant(.all)) // .environmentObject(UserData()) } }
5.10 更新限制创建行视图的逻辑,加入分类的过滤。
查找 landmark 的分类来匹配选中的分类,或在用户选择特色分类时查找任何特色地标。
LandmarkList.swift
import SwiftUI struct LandmarkList: View { @EnvironmentObject private var userData: UserData @Binding var selectedLandmark: Landmark? @Binding var filter: FilterType var body: some View { List(selection: $selectedLandmark) { ForEach(userData.landmarks) { landmark in // if (!self.userData.showFavoritesOnly || landmark.isFavorite) && (self.filter == .all || self.filter.category == landmark.category || (self.filter.category == .featured && landmark.isFeatured)) { // LandmarkRow(landmark: landmark).tag(landmark) } } } } } struct LandmarkList_Previews: PreviewProvider { static var previews: some View { LandmarkList(selectedLandmark: .constant(landmarkData[0]), filter: .constant(.all)) .environmentObject(UserData()) } }
创建一个组合了过过滤器和列表的主视图。将 landmark 选项绑定到主视图的父视图的同时,为过滤器提供新的状态信息。
6.1 在项目中创建一个新的 SwiftUI 视图,起名 NavigationMaster.swift 。
NavigationMaster.swift
import SwiftUI struct NavigationMaster: View { var body: some View { Text("Hello, World!") } } struct NavigationMaster_Previews: PreviewProvider { static var previews: some View { NavigationMaster() } }
6.2 声明过滤器的状态。
在这添加状态会使此视图成为该信息的真相来源。在接下来的几步中,我们会将此属性绑定到过滤器视图和列表视图。
NavigationMaster.swift
import SwiftUI struct NavigationMaster: View { @State private var filter: FilterType = .all // var body: some View { Text("Hello, World!") } } struct NavigationMaster_Previews: PreviewProvider { static var previews: some View { NavigationMaster() } }
6.3 添加过滤视图,并将它绑定到过滤器状态上。
此时 preview 会构建失败。因为过滤器依赖环境中的用户信息对象,我们会在下一步修复这个问题。
NavigationMaster.swift
import SwiftUI struct NavigationMaster: View { @State private var filter: FilterType = .all var body: some View { // VStack { Filter(filter: $filter) .controlSize(.small) .padding([.top, .leading], 8) .padding(.trailing, 4) } // } } struct NavigationMaster_Previews: PreviewProvider { static var previews: some View { NavigationMaster() } }
6.4 在环境中添加 UserData 对象。
尽管导航主视图不需要直接的 UserData ,但子视图却需要。要启用 preview,需要请将 UserData 作为环境对象提供给导航主视图。
NavigationMaster.swift
import SwiftUI struct NavigationMaster: View { @State private var filter: FilterType = .all var body: some View { VStack { Filter(filter: $filter) .controlSize(.small) .padding([.top, .leading], 8) .padding(.trailing, 4) } } } struct NavigationMaster_Previews: PreviewProvider { static var previews: some View { NavigationMaster() .environmentObject(UserData()) // } }
6.5 给选中的 landmark 添加一个 binding。
NavigationMaster.swift
import SwiftUI struct NavigationMaster: View { @Binding var selectedLandmark: Landmark? // @State private var filter: FilterType = .all var body: some View { VStack { Filter(filter: $filter) .controlSize(.small) .padding([.top, .leading], 8) .padding(.trailing, 4) } } } struct NavigationMaster_Previews: PreviewProvider { static var previews: some View { NavigationMaster(selectedLandmark: .constant(landmarkData[1])) // .environmentObject(UserData()) } }
6.6 添加 landmark 列表视图,将它绑定到选中的 landmark 和过滤器的状态上。
preview 选择了列表中的第二项,因为我们提供了 landmarkData[1] 作为 selectedLandmark 的输入。
NavigationMaster.swift
import SwiftUI struct NavigationMaster: View { @Binding var selectedLandmark: Landmark? @State private var filter: FilterType = .all var body: some View { VStack { Filter(filter: $filter) .controlSize(.small) .padding([.top, .leading], 8) .padding(.trailing, 4) // LandmarkList( selectedLandmark: $selectedLandmark, filter: $filter ) .listStyle(SidebarListStyle()) // } } } struct NavigationMaster_Previews: PreviewProvider { static var previews: some View { NavigationMaster(selectedLandmark: .constant(landmarkData[1])) .environmentObject(UserData()) } }
6.7 约束导航视图的宽度,防止用户让它过宽或过窄。
NavigationMaster.swift
import SwiftUI struct NavigationMaster: View { @Binding var selectedLandmark: Landmark? @State private var filter: FilterType = .all var body: some View { VStack { Filter(filter: $filter) .controlSize(.small) .padding([.top, .leading], 8) .padding(.trailing, 4) LandmarkList( selectedLandmark: $selectedLandmark, filter: $filter ) .listStyle(SidebarListStyle()) } .frame(minWidth: 225, maxWidth: 300) // } } struct NavigationMaster_Previews: PreviewProvider { static var previews: some View { NavigationMaster(selectedLandmark: .constant(landmarkData[1])) .environmentObject(UserData()) } }
有时我们仅需进行少量修改就能跨平台共享视图。在为 macOS 构建 landmark 详细视图时,我们会重用为 iOS 创建的 CircleImage 。为了满足 macOS 的不同布局要求,我们会添加一个参数来控制阴影半径。
7.1 在项目导航中,选择 Landmarks > Supporting Views ,然后选中 CircleImage.swift 文件。
7.2 把 CircleImage.swift 文件添加到 MacLandmarks target 中。
7.3 中 CircleImage.swift 中,修改结构体,添加一个新的阴影半径参数。
通过给新参数提供与以前的常量相同的默认值,可以确保 CircleImage 在现有客户端(如 iOS 和 watchOS app)在不做任何修改的情况下仍能像以前一样运行。
CircleImage.swift
import SwiftUI struct CircleImage: View { var image: Image var shadowRadius: CGFloat = 10 // var body: some View { image .clipShape(Circle()) .overlay(Circle().stroke(Color.white, lineWidth: 4)) .shadow(radius: shadowRadius) // } } struct CircleImage_Previews: PreviewProvider { static var previews: some View { CircleImage(image: Image("turtlerock")) } }
和圆形视图一样,我们会在 macOS 上重用 MapView 。但是, MapView 需要更大量的更新,因为它对 MapKit 的使用依赖于集成 UIKit 框架,在 macOS 中使用 MapKit 则需要集成 AppKit 框架。因此我们会添加一个编译时指令,为给定 target 提供正确的集成。
8.1 在项目导航中,选择 Landmarks > Supporting Views ,然后选中 MapView.swift 文件。
8.2 将 MapView.swift 文件添加到 MacLandmarks target 中。
此时 Xcode 会报错,因为地图视图使用了 UIViewRepresentable ,但它在 macOS SDK 中不支持。在下面但几步中,我们会在合适的时候使用 NSViewRepresentable 来展开这个视图。
8.3 插入可创建平台特定行为区域的编译指令。
我们会使用编译指令的两个分支来区分 UIViewRepresentable 和 NSViewRepresentable 协议。
MapView.swift
import SwiftUI import MapKit struct MapView: UIViewRepresentable { var coordinate: CLLocationCoordinate2D func makeUIView(context: Context) -> MKMapView { MKMapView(frame: .zero) } func updateUIView(_ view: MKMapView, context: Context) { let span = MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) let region = MKCoordinateRegion(center: coordinate, span: span) view.setRegion(region, animated: true) } } // #if os(macOS) #else #endif // struct MapView_Previews: PreviewProvider { static var previews: some View { MapView(coordinate: landmarkData[0].locationCoordinate) } }
8.4 将由 makeUIView 和 updateUIView 方法组成的 UIViewRepresentable 协议移动到适当的编译指令分支中的扩展中,这样就不用修改 MapKit 的实际交互。
此时 Xcode 仍报告使用未声明类型 Context 的错误。我们会在下一步中添加 NSViewRepresentable 协议来解决这个问题。
MapView.swift
import SwiftUI import MapKit struct MapView { // var coordinate: CLLocationCoordinate2D func makeMapView() -> MKMapView { // MKMapView(frame: .zero) } func updateMapView(_ view: MKMapView, context: Context) { // let span = MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) let region = MKCoordinateRegion(center: coordinate, span: span) view.setRegion(region, animated: true) } } #if os(macOS) #else // extension MapView: UIViewRepresentable { func makeUIView(context: Context) -> MKMapView { makeMapView() } func updateUIView(_ uiView: MKMapView, context: Context) { updateMapView(uiView, context: context) } } // #endif struct MapView_Previews: PreviewProvider { static var previews: some View { MapView(coordinate: landmarkData[0].locationCoordinate) } }
8.5 添加与 UIViewRepresentable 对应的 NSViewRepresentable 。
与 UIViewRepresentable 一样, NSViewRepresentable 依赖于完成上一步后剩下的通用功能。
MapView.swift
import SwiftUI import MapKit struct MapView { var coordinate: CLLocationCoordinate2D func makeMapView() -> MKMapView { MKMapView(frame: .zero) } func updateMapView(_ view: MKMapView, context: Context) { let span = MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) let region = MKCoordinateRegion(center: coordinate, span: span) view.setRegion(region, animated: true) } } #if os(macOS) // extension MapView: NSViewRepresentable { func makeNSView(context: Context) -> MKMapView { makeMapView() } func updateNSView(_ nsView: MKMapView, context: Context) { updateMapView(nsView, context: context) } } // #else extension MapView: UIViewRepresentable { func makeUIView(context: Context) -> MKMapView { makeMapView() } func updateUIView(_ uiView: MKMapView, context: Context) { updateMapView(uiView, context: context) } } #endif struct MapView_Previews: PreviewProvider { static var previews: some View { MapView(coordinate: landmarkData[0].locationCoordinate) } }
详情视图显示选中 landmark 的相关信息。我们会创建一个想 iOS app 一样的视图,但是不同平台有不同的数据展示方式。
我们会裁剪详情视图来适配 macOS,并且重用一些前两节准备的视图。
9.1 给项目添加一个新的视图,叫做 NavigationDetail.swift ,并给它添加一个 landmark 属性。
实例化详情视图的视图会使用此属性指明要显示的 landmark。
NavigationDetail.swift
import SwiftUI struct NavigationDetail: View { var landmark: Landmark // var body: some View { Text("Hello, World!") } } struct NavigationDetail_Previews: PreviewProvider { static var previews: some View { NavigationDetail(landmark: landmarkData[0]) // } }
9.2 创建一个包含竖直 stack 的滚动视图,同时又包含一个水平 stack,用于显示 CircleImage 和有关 landmark 的文本。
通过设置垂直 stack 的最大宽度,可以确保其所有内容的宽度保持在合适的阅读范围内。
NavigationDetail.swift
import SwiftUI struct NavigationDetail: View { var landmark: Landmark var body: some View { // ScrollView { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .center, spacing: 24) { CircleImage(image: landmark.image) VStack(alignment: .leading) { Text(landmark.name).font(.title) Text(landmark.park) Text(landmark.state) } .font(.caption) } } .padding() .frame(maxWidth: 700) } // } } struct NavigationDetail_Previews: PreviewProvider { static var previews: some View { NavigationDetail(landmark: landmarkData[0]) } }
尽管跨平台重用视图很方便,但我们仍需要自定义 CircleImage 视图来适配此布局。
9.3 通过让输入的图像可调整大小,并约束视图的 frame,我们可以减小 CircleImage 的大小来匹配关联的文本块。
在这之后,我们就不再需要修改相关的 CircleImage 来。
NavigationDetail.swift
import SwiftUI struct NavigationDetail: View { var landmark: Landmark var body: some View { ScrollView { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .center, spacing: 24) { // CircleImage(image: landmark.image.resizable()) .frame(width: 160, height: 160) // VStack(alignment: .leading) { Text(landmark.name).font(.title) Text(landmark.park) Text(landmark.state) } .font(.caption) } } .padding() .frame(maxWidth: 700) } } } struct NavigationDetail_Previews: PreviewProvider { static var previews: some View { NavigationDetail(landmark: landmarkData[0]) } }
9.4 调整阴影半径来适配更小的图片。
此修改通过 CircleImage 添加到项目时引入的参数来设置。
NavigationDetail.swift
import SwiftUI struct NavigationDetail: View { var landmark: Landmark var body: some View { ScrollView { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .center, spacing: 24) { CircleImage(image: landmark.image.resizable(), shadowRadius: 4) // .frame(width: 160, height: 160) VStack(alignment: .leading) { Text(landmark.name).font(.title) Text(landmark.park) Text(landmark.state) } .font(.caption) } } .padding() .frame(maxWidth: 700) } } } struct NavigationDetail_Previews: PreviewProvider { static var previews: some View { NavigationDetail(landmark: landmarkData[0]) } }
我们可以使用按钮来控制用户是否将 landmark 标记为收藏。如果要修改,必须访问存储在 UserData 对象中的单个实质来源。
9.5 添加 UserData 作为一个环境对象,并基于当前选择的 landmark 在存储的 landmark 中创建索引。
NavigationDetail.swift
import SwiftUI struct NavigationDetail: View { @EnvironmentObject var userData: UserData // var landmark: Landmark // var landmarkIndex: Int { userData.landmarks.firstIndex(where: { 0ドル.id == landmark.id })! } // var body: some View { ScrollView { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .center, spacing: 24) { CircleImage(image: landmark.image.resizable(), shadowRadius: 4) .frame(width: 160, height: 160) VStack(alignment: .leading) { Text(landmark.name).font(.title) Text(landmark.park) Text(landmark.state) } .font(.caption) } } .padding() .frame(maxWidth: 700) } } } struct NavigationDetail_Previews: PreviewProvider { static var previews: some View { NavigationDetail(landmark: landmarkData[0]) .environmentObject(UserData()) } }
9.6 添加一个按钮,与 landmark 名称水平对齐。使用行视图中的同一星形图像,它可以切换 landmark 的 isFavorite 属性。
对 landmark 进行更改后,需要在 UserData 中查找 landmark,并将所做的更改持久保存在数据存储中。
NavigationDetail.swift
import SwiftUI struct NavigationDetail: View { @EnvironmentObject var userData: UserData var landmark: Landmark var landmarkIndex: Int { userData.landmarks.firstIndex(where: { 0ドル.id == landmark.id })! } var body: some View { ScrollView { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .center, spacing: 24) { CircleImage(image: landmark.image.resizable(), shadowRadius: 4) .frame(width: 160, height: 160) VStack(alignment: .leading) { // HStack { Text(landmark.name).font(.title) Button(action: { self.userData.landmarks[self.landmarkIndex] .isFavorite.toggle() }) { if userData.landmarks[self.landmarkIndex].isFavorite { Image("star-filled") .resizable() .renderingMode(.template) .foregroundColor(.yellow) .accessibility(label: Text("Remove from favorites")) } else { Image("star-empty") .resizable() .renderingMode(.template) .foregroundColor(.gray) .accessibility(label: Text("Add to favorites")) } } .frame(width: 20, height: 20) .buttonStyle(PlainButtonStyle()) } // Text(landmark.park) Text(landmark.state) } .font(.caption) } } .padding() .frame(maxWidth: 700) } } } struct NavigationDetail_Previews: PreviewProvider { static var previews: some View { NavigationDetail(landmark: landmarkData[0]) .environmentObject(UserData()) } }
9.7 在分隔线下方,使用新的描述字段添加有关 landmark 的更多信息。
标题栏移向了 preview 的头部,因为新内容使封闭的垂直 stack 变宽,但最多不超过先前指定的最大 frame 的大小。
NavigationDetail.swift
import SwiftUI struct NavigationDetail: View { @EnvironmentObject var userData: UserData var landmark: Landmark var landmarkIndex: Int { userData.landmarks.firstIndex(where: { 0ドル.id == landmark.id })! } var body: some View { ScrollView { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .center, spacing: 24) { CircleImage(image: landmark.image.resizable(), shadowRadius: 4) .frame(width: 160, height: 160) VStack(alignment: .leading) { HStack { Text(landmark.name).font(.title) Button(action: { self.userData.landmarks[self.landmarkIndex] .isFavorite.toggle() }) { if userData.landmarks[self.landmarkIndex].isFavorite { Image("star-filled") .resizable() .renderingMode(.template) .foregroundColor(.yellow) .accessibility(label: Text("Remove from favorites")) } else { Image("star-empty") .resizable() .renderingMode(.template) .foregroundColor(.gray) .accessibility(label: Text("Add to favorites")) } } .frame(width: 20, height: 20) .buttonStyle(PlainButtonStyle()) } Text(landmark.park) Text(landmark.state) } .font(.caption) } // Divider() Text("About \(landmark.name)") .font(.headline) Text(landmark.details) .lineLimit(nil) // } .padding() .frame(maxWidth: 700) } } } struct NavigationDetail_Previews: PreviewProvider { static var previews: some View { NavigationDetail(landmark: landmarkData[0]) .environmentObject(UserData()) } }
9.8 在详情视图的顶部插入地图,然后将其他内容向上偏移到稍微重叠。
占据视图整个宽度的地图将详情文本推到 preview 底部下方,但仍然存在。
NavigationDetail.swift
import SwiftUI struct NavigationDetail: View { @EnvironmentObject var userData: UserData var landmark: Landmark var landmarkIndex: Int { userData.landmarks.firstIndex(where: { 0ドル.id == landmark.id })! } var body: some View { ScrollView { // MapView(coordinate: landmark.locationCoordinate) .frame(height: 250) // VStack(alignment: .leading, spacing: 12) { HStack(alignment: .center, spacing: 24) { CircleImage(image: landmark.image.resizable(), shadowRadius: 4) .frame(width: 160, height: 160) VStack(alignment: .leading) { HStack { Text(landmark.name).font(.title) Button(action: { self.userData.landmarks[self.landmarkIndex] .isFavorite.toggle() }) { if userData.landmarks[self.landmarkIndex].isFavorite { Image("star-filled") .resizable() .renderingMode(.template) .foregroundColor(.yellow) .accessibility(label: Text("Remove from favorites")) } else { Image("star-empty") .resizable() .renderingMode(.template) .foregroundColor(.gray) .accessibility(label: Text("Add to favorites")) } } .frame(width: 20, height: 20) .buttonStyle(PlainButtonStyle()) } Text(landmark.park) Text(landmark.state) } .font(.caption) } Divider() Text("About \(landmark.name)") .font(.headline) Text(landmark.details) .lineLimit(nil) } .padding() .frame(maxWidth: 700) .offset(x: 0, y: -50) // } } struct NavigationDetail_Previews: PreviewProvider { static var previews: some View { NavigationDetail(landmark: landmarkData[0]) .environmentObject(UserData()) } }
9.9 添加一个"Open in Maps"按钮,单击会将 Maps app 打开到该位置。
import SwiftUI struct NavigationDetail: View { @EnvironmentObject var userData: UserData var landmark: Landmark var landmarkIndex: Int { userData.landmarks.firstIndex(where: { 0ドル.id == landmark.id })! } var body: some View { ScrollView { MapView(coordinate: landmark.locationCoordinate) .frame(height: 250) // Button("Open in Maps") { let destination = MKMapItem(placemark: MKPlacemark(coordinate: self.landmark.locationCoordinate)) destination.name = self.landmark.name destination.openInMaps() } // VStack(alignment: .leading, spacing: 12) { HStack(alignment: .center, spacing: 24) { CircleImage(image: landmark.image.resizable(), shadowRadius: 4) .frame(width: 160, height: 160) VStack(alignment: .leading) { HStack { Text(landmark.name).font(.title) Button(action: { self.userData.landmarks[self.landmarkIndex] .isFavorite.toggle() }) { if userData.landmarks[self.landmarkIndex].isFavorite { Image("star-filled") .resizable() .renderingMode(.template) .foregroundColor(.yellow) .accessibility(label: Text("Remove from favorites")) } else { Image("star-empty") .resizable() .renderingMode(.template) .foregroundColor(.gray) .accessibility(label: Text("Add to favorites")) } } .frame(width: 20, height: 20) .buttonStyle(PlainButtonStyle()) } Text(landmark.park) Text(landmark.state) } .font(.caption) } Divider() Text("About \(landmark.name)") .font(.headline) Text(landmark.details) .lineLimit(nil) } .padding() .frame(maxWidth: 700) .offset(x: 0, y: -50) } } struct NavigationDetail_Previews: PreviewProvider { static var previews: some View { NavigationDetail(landmark: landmarkData[0]) .environmentObject(UserData()) } }
9.10 将"Open in Maps"按钮放置在 overlay 中,使其显示在地图的右下角。
NavigationDetail.swift
import SwiftUI import MapKit struct NavigationDetail: View { @EnvironmentObject var userData: UserData var landmark: Landmark var landmarkIndex: Int { userData.landmarks.firstIndex(where: { 0ドル.id == landmark.id })! } var body: some View { ScrollView { MapView(coordinate: landmark.locationCoordinate) .frame(height: 250) // .overlay( GeometryReader { proxy in Button("Open in Maps") { let destination = MKMapItem(placemark: MKPlacemark(coordinate: self.landmark.locationCoordinate)) destination.name = self.landmark.name destination.openInMaps() } .frame(width: proxy.size.width, height: proxy.size.height, alignment: .bottomTrailing) .offset(x: -10, y: -10) } ) // VStack(alignment: .leading, spacing: 12) { HStack(alignment: .center, spacing: 24) { CircleImage(image: landmark.image.resizable(), shadowRadius: 4) .frame(width: 160, height: 160) VStack(alignment: .leading) { HStack { Text(landmark.name).font(.title) Button(action: { self.userData.landmarks[self.landmarkIndex] .isFavorite.toggle() }) { if userData.landmarks[self.landmarkIndex].isFavorite { Image("star-filled") .resizable() .renderingMode(.template) .foregroundColor(.yellow) .accessibility(label: Text("Remove from favorites")) } else { Image("star-empty") .resizable() .renderingMode(.template) .foregroundColor(.gray) .accessibility(label: Text("Add to favorites")) } } .frame(width: 20, height: 20) .buttonStyle(PlainButtonStyle()) } Text(landmark.park) Text(landmark.state) } .font(.caption) } Divider() Text("About \(landmark.name)") .font(.headline) Text(landmark.details) .lineLimit(nil) } .padding() .frame(maxWidth: 700) .offset(x: 0, y: -50) } } } struct NavigationDetail_Previews: PreviewProvider { static var previews: some View { NavigationDetail(landmark: landmarkData[0]) .environmentObject(UserData()) } }
现在我们已经构建了所有组件视图,接下来通过将主视图和详情视图合并到内容视图中来完善 app。
10.1 在 MacLandmarks 目录中,选中 ContentView.swift 文件。
ContentView.swift
import SwiftUI struct ContentView: View { var body: some View { Text("Hello, World!") .frame(maxWidth: .infinity, maxHeight: .infinity) } } struct ContentView_Preview: PreviewProvider { static var previews: some View { ContentView() } }
10.2 给选中的 landmark 添加一个属性并用 @State 修饰。
给选中的 landmark 使用可选值可以避免设置默认值。这意味着 app 的 preview 和初始状态都将在没有选中 landmark 的情况下呈现。
ContentView.swift
import SwiftUI struct ContentView: View { @State private var selectedLandmark: Landmark? // var body: some View { Text("Hello, World!") .frame(maxWidth: .infinity, maxHeight: .infinity) } } struct ContentView_Preview: PreviewProvider { static var previews: some View { ContentView() } }
10.3 添加 UserData 作为环境对象。
内容视图不直接依赖于环境中的 UserData ,但是稍后添加的某些子视图会依赖。为了使 preview 工作和编译成功,内容视图需要获取用户 UserData 。
ContentView.swift
import SwiftUI struct ContentView: View { @State private var selectedLandmark: Landmark? var body: some View { Text("Hello, World!") .frame(maxWidth: .infinity, maxHeight: .infinity) } } struct ContentView_Preview: PreviewProvider { static var previews: some View { ContentView() .environmentObject(UserData()) // } }
10.4 在 AppDelegate.swift 文件中,为内容视图提供环境对象,让添加到内容视图的子视图能正确编译。
AppDelegate.swift
func applicationDidFinishLaunching(_ aNotification: Notification) { let contentView = ContentView().environmentObject(UserData()) // window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 480, height: 300), styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], backing: .buffered, defer: false) window.center() window.setFrameAutosaveName("Main Window") window.contentView = NSHostingView(rootView: contentView) window.makeKeyAndOrderFront(nil) }
10.5 将导航视图添加为内容视图中的顶级项目,并限制为最小大小。
ContentView.swift
import SwiftUI struct ContentView: View { @State private var selectedLandmark: Landmark? var body: some View { NavigationView { Text("Hello, World!") } .frame(minWidth: 700, minHeight: 300) // } } struct ContentView_Preview: PreviewProvider { static var previews: some View { ContentView() .environmentObject(UserData()) } }
10.6 添加绑定到所选 landmark 的主视图。
当用户在列表视图中进行选择时,该选择会传递到此视图中的 selectedLandmark 属性中。
ContentView.swift
import SwiftUI struct ContentView: View { @State private var selectedLandmark: Landmark? var body: some View { NavigationView { NavigationMaster(selectedLandmark: $selectedLandmark) // } .frame(minWidth: 700, minHeight: 300) } } struct ContentView_Preview: PreviewProvider { static var previews: some View { ContentView() .environmentObject(UserData()) } }
10.7 添加详情视图。
详情视图带有一个非可选的 landmark,因此在传递给详情视图之前,必须确保该值不为 nil。在用户进行选择之前,不会显示详情视图,这就是为什么此步骤与上一步中对 preview 看起来一样的原因。
ContentView.swift
import SwiftUI struct ContentView: View { @State private var selectedLandmark: Landmark? var body: some View { NavigationView { NavigationMaster(selectedLandmark: $selectedLandmark) // if selectedLandmark != nil { NavigationDetail(landmark: selectedLandmark!) } // } .frame(minWidth: 700, minHeight: 300) } } struct ContentView_Preview: PreviewProvider { static var previews: some View { ContentView() .environmentObject(UserData()) } }
10.8 构建并运行 app。
尝试更改过滤器设置,或者在详情视图中单击特定 landmark 的收藏指示符,查看内容是如何响应变化。