在为 watchOS 创建了 Landmarks 应用的一个版本之后,是时候将目光投向更大的目标:将 Landmarks 带到 Mac。你将在此基础上继续学习,以完善为 iOS、watchOS 和 macOS 构建 SwiftUI 应用的体验。
你将首先向项目中添加一个 macOS 目标,然后重用之前创建的视图和数据。有了基础之后,你将添加一些针对 macOS 量身定制的新视图,并对其他视图进行修改,使其在不同平台上表现更好。
请按照步骤构建此项目,或下载已完成的项目以自行探索。
第 1 节
为项目添加一个 macOS 目标 首先为项目添加一个 macOS 目标。Xcode 会添加一个新的组和一套启动文件,以及构建和运行应用程序所需的方案。然后,您将添加一些现有的文件到新的目标。
为了能够预览和运行应用程序,请确保您的 Mac 正在运行 macOS Sonoma 或更高版本。
步骤 1
选择文件 > 新建 > 目标。当出现模板表单时,选择 macOS 选项卡,选择应用程序模板,然后点击下一步。
该模板将一个新的 macOS 应用程序目标添加到项目中。
第 2 步
在表格中,将产品名称输入为 MacLandmarks 。将界面设置为 SwiftUI,语言设置为 Swift,然后点击完成。
第 3 步
将方案设置为 MacLandmarks > My Mac。
通过将方案设置为 My Mac,您可以预览、构建和运行 macOS 应用程序。随着您继续教程,您将使用其他方案来查看其他目标在共享文件发生变化时的响应情况。
第 4 步
在 MacLandmarks 组中,选择 ContentView ,打开画布以查看预览。
SwiftUI 提供了一个默认的主要视图和预览视图,就像 iOS 应用一样,使您可以预览应用的主要窗口。
第 5 步
在项目导航器中,从 MacLandmarks 组中删除 MacLandmarksApp 文件。当被询问时,请选择移到废纸篓。
就像 watchOS 应用一样,您不需要默认的应用结构,因为您将重用已经有的结构。
接下来,您将与 macOS 目标共享 iOS 应用的视图、模型和资源文件。
第 6 步
在项目导航器中,Command-click 选择以下文件: LandmarksApp , LandmarkList , LandmarkRow , CircleImage , MapView ,和 FavoriteButton 。
这些中的第一个是共享的应用程序定义。其他的是可以在 macOS 上工作的视图。
第 7 步
继续 Command-click 以选择 Model 和 Resources 文件夹中的所有项目,以及 Asset 。
这些项目定义了应用程序的数据模型和资源。
第 8 步
在文件检查器中,为所选文件添加 MacLandmarks 到目标成员资格。
为其他目标添加一个 macOS 应用程序图标集。
第 9 步
选择 MacLandmarks 组中的 Assets 文件,并删除空的 AppIcon 项。
你将在下一步中替换它。
第 10 步
将下载项目中的 AppIcon.appiconset 文件夹拖动到 MacLandmark 的 Asset 目录中。
第 11 步
在 MacLandmarks 组中的 ContentView 中,添加 LandmarkList 作为顶级视图,并对框架大小进行约束。
预览不再构建,因为使用了 LandmarkList 中的 LandmarkDetail ,但您还没有为 macOS 应用定义详细视图。您将在下一节中处理这个问题。
import SwiftUI
struct ContentView: View {
var body: some View {
LandmarkList()
.frame(minWidth: 700, minHeight: 300)
}
}
#Preview {
ContentView()
.environment(ModelData())
}
第 2 节
创建 macOS 详细视图 详细视图显示所选地标的信息。您为 iOS 应用创建了类似这样的视图,但不同的平台需要不同的数据呈现方法。
有时您可以在不同平台上重用视图并进行少量调整或条件编译,但 macOS 的详细视图差异足够大,最好创建一个专用的视图。您将复制 iOS 的详细视图作为起点,然后对其进行修改以适应 macOS 更大的显示。
步骤 1
在 MacLandmarks 组中为目标 macOS 创建一个新的自定义视图,命名为 LandmarkDetail 。
现在您有三个名为 LandmarkDetail 的文件。它们在视图层次结构中具有相同的功能,但为特定平台提供了定制的体验。
第 2 步
将 iOS 详细视图的内容复制到 macOS 详细视图中。
预览失败,因为 navigationBarTitleDisplayMode(_😃 方法在 macOS 中不可用。
import SwiftUI
struct LandmarkDetail: View {
@Environment(ModelData.self) var modelData
var landmark: Landmark
var landmarkIndex: Int {
modelData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}
var body: some View {
@Bindable var modelData = modelData
ScrollView {
MapView(coordinate: landmark.locationCoordinate)
.frame(height: 300)
CircleImage(image: landmark.image)
.offset(y: -130)
.padding(.bottom, -130)
VStack(alignment: .leading) {
HStack {
Text(landmark.name)
.font(.title)
FavoriteButton(isSet: $modelData.landmarks[landmarkIndex].isFavorite)
}
HStack {
Text(landmark.park)
Spacer()
Text(landmark.state)
}
.font(.subheadline)
.foregroundStyle(.secondary)
Divider()
Text("About \(landmark.name)")
.font(.title2)
Text(landmark.description)
}
.padding()
}
.navigationTitle(landmark.name)
.navigationBarTitleDisplayMode(.inline)
}
}
#Preview {
let modelData = ModelData()
return LandmarkDetail(landmark: modelData.landmarks[0])
.environment(modelData)
}
第 3 步
删除 navigationBarTitleDisplayMode(_😃 标记,并在预览中添加一个框架标记,以便可以看到更多的内容。
import SwiftUI
struct LandmarkDetail: View {
@Environment(ModelData.self) var modelData
var landmark: Landmark
var landmarkIndex: Int {
modelData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}
var body: some View {
@Bindable var modelData = modelData
ScrollView {
MapView(coordinate: landmark.locationCoordinate)
.frame(height: 300)
CircleImage(image: landmark.image)
.offset(y: -130)
.padding(.bottom, -130)
VStack(alignment: .leading) {
HStack {
Text(landmark.name)
.font(.title)
FavoriteButton(isSet: $modelData.landmarks[landmarkIndex].isFavorite)
}
VStack(alignment: .leading) {
Text(landmark.park)
Text(landmark.state)
}
.font(.subheadline)
.foregroundStyle(.secondary)
Divider()
Text("About \(landmark.name)")
.font(.title2)
Text(landmark.description)
}
.padding()
}
.navigationTitle(landmark.name)
}
}
#Preview {
let modelData = ModelData()
return LandmarkDetail(landmark: modelData.landmarks[0])
.environment(modelData)
.frame(width: 850, height: 700)
}
第 5 步
将 MapView 以下的所有内容包裹在 VStack 中,然后将 CircleImage 和其余的标题包裹在 HStack 中。
import SwiftUI
struct LandmarkDetail: View {
@Environment(ModelData.self) var modelData
var landmark: Landmark
var landmarkIndex: Int {
modelData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}
var body: some View {
@Bindable var modelData = modelData
ScrollView {
MapView(coordinate: landmark.locationCoordinate)
.frame(height: 300)
VStack(alignment: .leading, spacing: 20) {
HStack(spacing: 24) {
CircleImage(image: landmark.image)
.offset(y: -130)
.padding(.bottom, -130)
VStack(alignment: .leading) {
HStack {
Text(landmark.name)
.font(.title)
FavoriteButton(isSet: $modelData.landmarks[landmarkIndex].isFavorite)
}
VStack(alignment: .leading) {
Text(landmark.park)
Text(landmark.state)
}
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
Divider()
Text("About \(landmark.name)")
.font(.title2)
Text(landmark.description)
}
.padding()
}
.navigationTitle(landmark.name)
}
}
#Preview {
let modelData = ModelData()
return LandmarkDetail(landmark: modelData.landmarks[0])
.environment(modelData)
.frame(width: 850, height: 700)
}
第 6 步
移除圆圈的偏移,而是对整个 VStack 应用较小的偏移。
import SwiftUI
struct LandmarkDetail: View {
@Environment(ModelData.self) var modelData
var landmark: Landmark
var landmarkIndex: Int {
modelData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}
var body: some View {
@Bindable var modelData = modelData
ScrollView {
MapView(coordinate: landmark.locationCoordinate)
.frame(height: 300)
VStack(alignment: .leading, spacing: 20) {
HStack(spacing: 24) {
CircleImage(image: landmark.image)
VStack(alignment: .leading) {
HStack {
Text(landmark.name)
.font(.title)
FavoriteButton(isSet: $modelData.landmarks[landmarkIndex].isFavorite)
}
VStack(alignment: .leading) {
Text(landmark.park)
Text(landmark.state)
}
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
Divider()
Text("About \(landmark.name)")
.font(.title2)
Text(landmark.description)
}
.padding()
.offset(y: -50)
}
.navigationTitle(landmark.name)
}
}
#Preview {
let modelData = ModelData()
return LandmarkDetail(landmark: modelData.landmarks[0])
.environment(modelData)
.frame(width: 850, height: 700)
}
第 7 步
向图像添加 resizable() 修饰符,并使 CircleImage 稍微小一些。
import SwiftUI
struct LandmarkDetail: View {
@Environment(ModelData.self) var modelData
var landmark: Landmark
var landmarkIndex: Int {
modelData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}
var body: some View {
@Bindable var modelData = modelData
ScrollView {
MapView(coordinate: landmark.locationCoordinate)
.frame(height: 300)
VStack(alignment: .leading, spacing: 20) {
HStack(spacing: 24) {
CircleImage(image: landmark.image.resizable())
.frame(width: 160, height: 160)
VStack(alignment: .leading) {
HStack {
Text(landmark.name)
.font(.title)
FavoriteButton(isSet: $modelData.landmarks[landmarkIndex].isFavorite)
}
VStack(alignment: .leading) {
Text(landmark.park)
Text(landmark.state)
}
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
Divider()
Text("About \(landmark.name)")
.font(.title2)
Text(landmark.description)
}
.padding()
.offset(y: -50)
}
.navigationTitle(landmark.name)
}
}
#Preview {
let modelData = ModelData()
return LandmarkDetail(landmark: modelData.landmarks[0])
.environment(modelData)
.frame(width: 850, height: 700)
}
第 8 步
限制 ScrollView 的最大宽度。
当用户使窗口非常宽时,这可以提高可读性。
import SwiftUI
struct LandmarkDetail: View {
@Environment(ModelData.self) var modelData
var landmark: Landmark
var landmarkIndex: Int {
modelData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}
var body: some View {
@Bindable var modelData = modelData
ScrollView {
MapView(coordinate: landmark.locationCoordinate)
.frame(height: 300)
VStack(alignment: .leading, spacing: 20) {
HStack(spacing: 24) {
CircleImage(image: landmark.image.resizable())
.frame(width: 160, height: 160)
VStack(alignment: .leading) {
HStack {
Text(landmark.name)
.font(.title)
FavoriteButton(isSet: $modelData.landmarks[landmarkIndex].isFavorite)
}
VStack(alignment: .leading) {
Text(landmark.park)
Text(landmark.state)
}
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
Divider()
Text("About \(landmark.name)")
.font(.title2)
Text(landmark.description)
}
.padding()
.frame(maxWidth: 700)
.offset(y: -50)
}
.navigationTitle(landmark.name)
}
}
#Preview {
let modelData = ModelData()
return LandmarkDetail(landmark: modelData.landmarks[0])
.environment(modelData)
.frame(width: 850, height: 700)
}
第 9 步
将 FavoriteButton 改为使用 plain 按钮样式。
这里使用简洁样式会使按钮看起来更像 iOS 版本。
import SwiftUI
struct LandmarkDetail: View {
@Environment(ModelData.self) var modelData
var landmark: Landmark
var landmarkIndex: Int {
modelData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}
var body: some View {
@Bindable var modelData = modelData
ScrollView {
MapView(coordinate: landmark.locationCoordinate)
.frame(height: 300)
VStack(alignment: .leading, spacing: 20) {
HStack(spacing: 24) {
CircleImage(image: landmark.image.resizable())
.frame(width: 160, height: 160)
VStack(alignment: .leading) {
HStack {
Text(landmark.name)
.font(.title)
FavoriteButton(isSet: $modelData.landmarks[landmarkIndex].isFavorite)
.buttonStyle(.plain)
}
VStack(alignment: .leading) {
Text(landmark.park)
Text(landmark.state)
}
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
Divider()
Text("About \(landmark.name)")
.font(.title2)
Text(landmark.description)
}
.padding()
.frame(maxWidth: 700)
.offset(y: -50)
}
.navigationTitle(landmark.name)
}
}
#Preview {
let modelData = ModelData()
return LandmarkDetail(landmark: modelData.landmarks[0])
.environment(modelData)
.frame(width: 850, height: 700)
}
更大的显示屏可以提供更多空间以添加更多功能。
第 10 步
在 ZStack 中添加一个“在地图中打开”按钮,使其显示在地图右上角。
一定要包含 MapKit,以便能够创建发送给地图的 MKMapItem 。
import MapKit
struct LandmarkDetail: View {
@Environment(ModelData.self) var modelData
var landmark: Landmark
var landmarkIndex: Int {
modelData.landmarks.firstIndex(where: { $0.id == landmark.id })!
}
var body: some View {
@Bindable var modelData = modelData
ScrollView {
ZStack(alignment: Alignment(horizontal: .trailing, vertical: .top)) {
MapView(coordinate: landmark.locationCoordinate)
.frame(height: 300)
Button("Open in Maps") {
let destination = MKMapItem(placemark: MKPlacemark(coordinate: landmark.locationCoordinate))
destination.name = landmark.name
destination.openInMaps()
}
.padding()
}
VStack(alignment: .leading, spacing: 20) {
HStack(spacing: 24) {
CircleImage(image: landmark.image.resizable())
.frame(width: 160, height: 160)
VStack(alignment: .leading) {
HStack {
Text(landmark.name)
.font(.title)
FavoriteButton(isSet: $modelData.landmarks[landmarkIndex].isFavorite)
.buttonStyle(.plain)
}
VStack(alignment: .leading) {
Text(landmark.park)
Text(landmark.state)
}
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
Divider()
Text("About \(landmark.name)")
.font(.title2)
Text(landmark.description)
}
.padding()
.frame(maxWidth: 700)
.offset(y: -50)
}
.navigationTitle(landmark.name)
}
}
#Preview {
let modelData = ModelData()
return LandmarkDetail(landmark: modelData.landmarks[0])
.environment(modelData)
.frame(width: 850, height: 700)
}
第三部分
更新行视图 共享的 LandmarkRow 视图在 macOS 中可以正常工作,但鉴于新的视觉环境,值得重新审视以寻找改进之处。由于此视图被所有三个平台使用,您需要确保所做的任何更改在所有平台上都能正常工作。
在修改行之前,请设置列表的预览,因为您所做的更改将取决于行在上下文中的外观。
步骤 1
打开 LandmarkList 并添加最小宽度。
这可以改进预览,同时也能确保在用户调整 macOS 窗口大小时,列表永远不会变得太小。
第 2 步
将列表视图预览固定,以便在进行更改时可以看到行在上下文中的外观。
第 3 步
打开 LandmarkRow ,并为图片添加圆角以获得更精致的外观。
第 4 步
将地标名称包裹在 VStack 中,并将公园作为次要信息添加。
第 5 步
在行内容周围添加垂直间距,使每行都有更多的呼吸空间。
更新改进了 macOS 中的外观,但您还需要考虑使用该列表的其他平台。首先考虑 watchOS。
第 6 步
选择 WatchLandmarks 目标以查看 watchOS 的列表预览。
最小行宽在这里不合适。由于在接下来的部分中您将对列表进行的其他更改,最佳解决方案是创建一个针对 watchOS 的列表,不包含宽度约束。
第 7 步
在 WatchLandmarks Watch App 文件夹中添加一个新的 SwiftUI 视图,命名为 LandmarkList.swift ,仅针对 WatchLandmarks Watch App,并移除旧文件的 WatchLandmarks Watch App 目标成员资格。
第 8 步
将旧的 LandmarkList 的内容复制到新的版本中,但不包含框架修饰符。
内容现在宽度正确,但每一行的信息过多。
第 9 步
回到 LandmarkRow 并添加一个 #if 条件以防止在 watchOS 构建中显示次要文本。
对于行来说,使用条件编译是合适的,因为差异很小。
最后,考虑你的更改如何适用于 iOS。
第 10 步
选择 Landmarks 构建目标以查看 iOS 中的列表看起来是什么样子。
改变对 iOS 来说运行良好,因此无需为该平台进行任何更新。
第 4 节
更新列表视图 和 LandmarkRow 一样, LandmarkList 已经可以在 macOS 上使用,但还可以进行改进。例如,你会将仅显示收藏项的切换按钮移到工具栏的菜单中,在那里它可以与其他过滤控件一起使用。
你将要进行的更改适用于 macOS 和 iOS,但在 watchOS 上实现起来会比较困难。幸运的是,在上一节中,你已经将列表拆分到了一个单独的文件中用于 watchOS。
步骤 1
返回到 MacLandmarks 方案,在针对 iOS 和 macOS 的 LandmarkList 文件中,添加一个包含 Menu 的新 toolbar 修饰符中的 ToolbarItem 。
import SwiftUI
struct LandmarkList: View {
@Environment(ModelData.self) var modelData
@State private var showFavoritesOnly = false
var filteredLandmarks: [Landmark] {
modelData.landmarks.filter { landmark in
(!showFavoritesOnly || landmark.isFavorite)
}
}
var body: some View {
NavigationSplitView {
List {
Toggle(isOn: $showFavoritesOnly) {
Text("Favorites only")
}
ForEach(filteredLandmarks) { landmark in
NavigationLink {
LandmarkDetail(landmark: landmark)
} label: {
LandmarkRow(landmark: landmark)
}
}
}
.animation(.default, value: filteredLandmarks)
.navigationTitle("Landmarks")
.frame(minWidth: 300)
.toolbar {
ToolbarItem {
Menu {
} label: {
Label("Filter", systemImage: "slider.horizontal.3")
}
}
}
} detail: {
Text("Select a Landmark")
}
}
}
#Preview {
LandmarkList()
.environment(ModelData())
}
第 2 步
将收藏项 Toggle 移动到菜单中。
这将以平台特定的方式将切换按钮移动到工具栏中,这还有一个额外的好处,即无论地标列表有多长,或者用户向下滚动多远,都可以使其变得可访问。
import SwiftUI
struct LandmarkList: View {
@Environment(ModelData.self) var modelData
@State private var showFavoritesOnly = false
var filteredLandmarks: [Landmark] {
modelData.landmarks.filter { landmark in
(!showFavoritesOnly || landmark.isFavorite)
}
}
var body: some View {
NavigationSplitView {
List {
ForEach(filteredLandmarks) { landmark in
NavigationLink {
LandmarkDetail(landmark: landmark)
} label: {
LandmarkRow(landmark: landmark)
}
}
}
.animation(.default, value: filteredLandmarks)
.navigationTitle("Landmarks")
.frame(minWidth: 300)
.toolbar {
ToolbarItem {
Menu {
Toggle(isOn: $showFavoritesOnly) {
Label("Favorites only", systemImage: "star.fill")
}
} label: {
Label("Filter", systemImage: "slider.horizontal.3")
}
}
}
} detail: {
Text("Select a Landmark")
}
}
}
#Preview {
LandmarkList()
.environment(ModelData())
}
有了更多的空间,你将添加一个新的控件来按类别过滤地标列表。
第 3 步
添加一个 FilterCategory 枚举来描述过滤状态。
将案例字符串与 Category 枚举在 Landmark 结构中匹配,以便进行比较,并包含一个 all 案例来关闭过滤。
import SwiftUI
struct LandmarkList: View {
@Environment(ModelData.self) var modelData
@State private var showFavoritesOnly = false
enum FilterCategory: String, CaseIterable, Identifiable {
case all = "All"
case lakes = "Lakes"
case rivers = "Rivers"
case mountains = "Mountains"
var id: FilterCategory { self }
}
var filteredLandmarks: [Landmark] {
modelData.landmarks.filter { landmark in
(!showFavoritesOnly || landmark.isFavorite)
}
}
var body: some View {
NavigationSplitView {
List {
ForEach(filteredLandmarks) { landmark in
NavigationLink {
LandmarkDetail(landmark: landmark)
} label: {
LandmarkRow(landmark: landmark)
}
}
}
.animation(.default, value: filteredLandmarks)
.navigationTitle("Landmarks")
.frame(minWidth: 300)
.toolbar {
ToolbarItem {
Menu {
Toggle(isOn: $showFavoritesOnly) {
Label("Favorites only", systemImage: "star.fill")
}
} label: {
Label("Filter", systemImage: "slider.horizontal.3")
}
}
}
} detail: {
Text("Select a Landmark")
}
}
}
#Preview {
LandmarkList()
.environment(ModelData())
}
第 4 步
添加一个默认值为 all 案例的 filter 状态变量。
通过将过滤状态存储在列表视图中,用户可以打开多个列表视图窗口,每个窗口都有自己的过滤设置,以便以不同的方式查看数据。
import SwiftUI
struct LandmarkList: View {
@Environment(ModelData.self) var modelData
@State private var showFavoritesOnly = false
@State private var filter = FilterCategory.all
enum FilterCategory: String, CaseIterable, Identifiable {
case all = "All"
case lakes = "Lakes"
case rivers = "Rivers"
case mountains = "Mountains"
var id: FilterCategory { self }
}
var filteredLandmarks: [Landmark] {
modelData.landmarks.filter { landmark in
(!showFavoritesOnly || landmark.isFavorite)
}
}
var body: some View {
NavigationSplitView {
List {
ForEach(filteredLandmarks) { landmark in
NavigationLink {
LandmarkDetail(landmark: landmark)
} label: {
LandmarkRow(landmark: landmark)
}
}
}
.animation(.default, value: filteredLandmarks)
.navigationTitle("Landmarks")
.frame(minWidth: 300)
.toolbar {
ToolbarItem {
Menu {
Toggle(isOn: $showFavoritesOnly) {
Label("Favorites only", systemImage: "star.fill")
}
} label: {
Label("Filter", systemImage: "slider.horizontal.3")
}
}
}
} detail: {
Text("Select a Landmark")
}
}
}
#Preview {
LandmarkList()
.environment(ModelData())
}
第 5 步
更新 filteredLandmarks 以考虑新的 filter 设置与给定地标类别的组合。
import SwiftUI
struct LandmarkList: View {
@Environment(ModelData.self) var modelData
@State private var showFavoritesOnly = false
@State private var filter = FilterCategory.all
enum FilterCategory: String, CaseIterable, Identifiable {
case all = "All"
case lakes = "Lakes"
case rivers = "Rivers"
case mountains = "Mountains"
var id: FilterCategory { self }
}
var filteredLandmarks: [Landmark] {
modelData.landmarks.filter { landmark in
(!showFavoritesOnly || landmark.isFavorite)
&& (filter == .all || filter.rawValue == landmark.category.rawValue)
}
}
var body: some View {
NavigationSplitView {
List {
ForEach(filteredLandmarks) { landmark in
NavigationLink {
LandmarkDetail(landmark: landmark)
} label: {
LandmarkRow(landmark: landmark)
}
}
}
.animation(.default, value: filteredLandmarks)
.navigationTitle("Landmarks")
.frame(minWidth: 300)
.toolbar {
ToolbarItem {
Menu {
Toggle(isOn: $showFavoritesOnly) {
Label("Favorites only", systemImage: "star.fill")
}
} label: {
Label("Filter", systemImage: "slider.horizontal.3")
}
}
}
} detail: {
Text("Select a Landmark")
}
}
}
#Preview {
LandmarkList()
.environment(ModelData())
}
第 6 步
在菜单中添加一个 Picker 以设置过滤类别。
由于过滤器只有几个项目,因此使用 inline 选择器样式使它们全部一起显示。
import SwiftUI
struct LandmarkList: View {
@Environment(ModelData.self) var modelData
@State private var showFavoritesOnly = false
@State private var filter = FilterCategory.all
enum FilterCategory: String, CaseIterable, Identifiable {
case all = "All"
case lakes = "Lakes"
case rivers = "Rivers"
case mountains = "Mountains"
var id: FilterCategory { self }
}
var filteredLandmarks: [Landmark] {
modelData.landmarks.filter { landmark in
(!showFavoritesOnly || landmark.isFavorite)
&& (filter == .all || filter.rawValue == landmark.category.rawValue)
}
}
var body: some View {
NavigationSplitView {
List {
ForEach(filteredLandmarks) { landmark in
NavigationLink {
LandmarkDetail(landmark: landmark)
} label: {
LandmarkRow(landmark: landmark)
}
}
}
.animation(.default, value: filteredLandmarks)
.navigationTitle("Landmarks")
.frame(minWidth: 300)
.toolbar {
ToolbarItem {
Menu {
Picker("Category", selection: $filter) {
ForEach(FilterCategory.allCases) { category in
Text(category.rawValue).tag(category)
}
}
.pickerStyle(.inline)
Toggle(isOn: $showFavoritesOnly) {
Label("Favorites only", systemImage: "star.fill")
}
} label: {
Label("Filter", systemImage: "slider.horizontal.3")
}
}
}
} detail: {
Text("Select a Landmark")
}
}
}
#Preview {
LandmarkList()
.environment(ModelData())
}
第 7 步
更新导航标题以匹配过滤器的状态。
此更改将在 iOS 应用中很有用。
import SwiftUI
struct LandmarkList: View {
@Environment(ModelData.self) var modelData
@State private var showFavoritesOnly = false
@State private var filter = FilterCategory.all
enum FilterCategory: String, CaseIterable, Identifiable {
case all = "All"
case lakes = "Lakes"
case rivers = "Rivers"
case mountains = "Mountains"
var id: FilterCategory { self }
}
var filteredLandmarks: [Landmark] {
modelData.landmarks.filter { landmark in
(!showFavoritesOnly || landmark.isFavorite)
&& (filter == .all || filter.rawValue == landmark.category.rawValue)
}
}
var title: String {
let title = filter == .all ? "Landmarks" : filter.rawValue
return showFavoritesOnly ? "Favorite \(title)" : title
}
var body: some View {
NavigationSplitView {
List {
ForEach(filteredLandmarks) { landmark in
NavigationLink {
LandmarkDetail(landmark: landmark)
} label: {
LandmarkRow(landmark: landmark)
}
}
}
.animation(.default, value: filteredLandmarks)
.navigationTitle(title)
.frame(minWidth: 300)
.toolbar {
ToolbarItem {
Menu {
Picker("Category", selection: $filter) {
ForEach(FilterCategory.allCases) { category in
Text(category.rawValue).tag(category)
}
}
.pickerStyle(.inline)
Toggle(isOn: $showFavoritesOnly) {
Label("Favorites only", systemImage: "star.fill")
}
} label: {
Label("Filter", systemImage: "slider.horizontal.3")
}
}
}
} detail: {
Text("Select a Landmark")
}
}
}
#Preview {
LandmarkList()
.environment(ModelData())
}
第 8 步
运行 macOS 目标并查看菜单的操作。
第 9 步
选择 Landmarks 构建目标,并确保在实时预览中查看新的过滤功能在 iOS 上也能正常工作。
第 5 节
添加内置菜单命令 当你使用 SwiftUI 生命周期创建应用程序时,系统会自动创建一个包含常用项目的菜单,例如关闭最前面的窗口或退出应用程序。SwiftUI 允许你添加其他具有内置行为的常见命令,以及完全自定义的命令。
在本节中,您将添加一个系统提供的命令,允许用户切换侧边栏的显示状态,以便在拖动关闭后能够重新打开。
步骤 1
返回到 MacLandmarks 目标,运行 macOS 应用,并将列表视图和详细视图之间的分隔符拖动到最左边。
当你释放鼠标按钮时,列表会消失。你可以点击工具栏按钮将其恢复,但你也可以引入一个菜单命令来控制这一点。
第 2 步
添加一个新的 Swift 文件,命名为 LandmarkCommands.swift ,并将其目标设置为包括 macOS 和 iOS。
你也需要针对 iOS,因为共享的 LandmarkList 最终将依赖于你在该文件中定义的一些类型。
第 3 步
导入 SwiftUI 并添加一个 LandmarkCommands 结构,使其符合 Commands 协议,并包含一个计算属性。
与 View 结构类似, Commands 结构也需要一个使用构建器语义的计算属性,但使用的是命令而不是视图。
import SwiftUI
struct LandmarkCommands: Commands {
var body: some Commands {
}
}
第 4 步
在 body 中添加一个 SidebarCommands 命令。
这个内置命令集包括切换侧边栏的命令。
import SwiftUI
struct LandmarkCommands: Commands {
var body: some Commands {
SidebarCommands()
}
}
要使用应用程序中的命令,您需要将它们应用于一个场景,您将在下一步操作。
第 5 步
打开 LandmarksApp 文件,并使用 LandmarkCommands 在 commands(content:) 场景修饰符中应用。
场景修饰符类似于视图修饰符,不同的是您将它们应用于场景而不是视图。
import SwiftUI
@main
struct LandmarksApp: App {
@State private var modelData = ModelData()
var body: some Scene {
WindowGroup {
ContentView()
.environment(modelData)
}
.commands {
LandmarkCommands()
}
#if os(watchOS)
WKNotificationScene(controller: NotificationController.self, category: "LandmarkNear")
#endif
}
}
第 6 步
重新运行 macOS 应用程序,并查看是否可以通过“视图”>“显示侧边栏”菜单命令恢复列表视图。
不幸的是,由于 Commands 没有 watchOS 可用性,watchOS 应用程序无法构建。你将要解决这个问题。
第 7 步
在命令修饰符周围添加一个条件,以在 watchOS 应用程序中省略它。
watchOS 应用程序再次构建。
import SwiftUI
@main
struct LandmarksApp: App {
@State private var modelData = ModelData()
var body: some Scene {
WindowGroup {
ContentView()
.environment(modelData)
}
#if !os(watchOS)
.commands {
LandmarkCommands()
}
#endif
#if os(watchOS)
WKNotificationScene(controller: NotificationController.self, category: "LandmarkNear")
#endif
}
}
第 6 节
添加自定义菜单命令 在上一节中,您添加了一个内置菜单命令集。在本节中,您将为切换当前选中地标的状态添加一个自定义命令。为了知道当前选中的是哪个地标,您将使用一个聚焦绑定。
步骤 1
在 LandmarkCommands 中,将 FocusedValues 结构扩展为包含一个 selectedLandmark 值,使用一个自定义键 SelectedLandmarkKey 。
定义聚焦值的模式类似于定义新 Environment 值的模式:使用一个私有键在系统定义的 FocusedValues 结构上读取和写入一个自定义属性。
import SwiftUI
struct LandmarkCommands: Commands {
var body: some Commands {
SidebarCommands()
}
}
private struct SelectedLandmarkKey: FocusedValueKey {
typealias Value = Binding<Landmark>
}
extension FocusedValues {
var selectedLandmark: Binding<Landmark>? {
get { self[SelectedLandmarkKey.self] }
set { self[SelectedLandmarkKey.self] = newValue }
}
}
第 2 步
添加一个 @FocusedBinding 属性包装器来跟踪当前选中的地标。
你在这里读取这个值。稍后在列表视图中用户进行选择时,你将设置它。
import SwiftUI
struct LandmarkCommands: Commands {
@FocusedBinding(\.selectedLandmark) var selectedLandmark
var body: some Commands {
SidebarCommands()
}
}
private struct SelectedLandmarkKey: FocusedValueKey {
typealias Value = Binding<Landmark>
}
extension FocusedValues {
var selectedLandmark: Binding<Landmark>? {
get { self[SelectedLandmarkKey.self] }
set { self[SelectedLandmarkKey.self] = newValue }
}
}
第 3 步
在你的命令中添加一个新的 CommandMenu ,名为地标。
你将下一步定义菜单的内容。
import SwiftUI
struct LandmarkCommands: Commands {
@FocusedBinding(\.selectedLandmark) var selectedLandmark
var body: some Commands {
SidebarCommands()
CommandMenu("Landmark") {
}
}
}
private struct SelectedLandmarkKey: FocusedValueKey {
typealias Value = Binding<Landmark>
}
extension FocusedValues {
var selectedLandmark: Binding<Landmark>? {
get { self[SelectedLandmarkKey.self] }
set { self[SelectedLandmarkKey.self] = newValue }
}
}
第 4 步
在菜单中添加一个按钮,可以切换选定地标是否为收藏状态,并且按钮的外观会根据当前选定的地标及其状态而改变。
import SwiftUI
struct LandmarkCommands: Commands {
@FocusedBinding(\.selectedLandmark) var selectedLandmark
var body: some Commands {
SidebarCommands()
CommandMenu("Landmark") {
Button("\(selectedLandmark?.isFavorite == true ? "Remove" : "Mark") as Favorite") {
selectedLandmark?.isFavorite.toggle()
}
.disabled(selectedLandmark == nil)
}
}
}
private struct SelectedLandmarkKey: FocusedValueKey {
typealias Value = Binding<Landmark>
}
extension FocusedValues {
var selectedLandmark: Binding<Landmark>? {
get { self[SelectedLandmarkKey.self] }
set { self[SelectedLandmarkKey.self] = newValue }
}
}
第 5 步
为菜单项添加一个带有 keyboardShortcut(_:modifiers:) 修饰符的键盘快捷键。
SwiftUI 会自动在菜单中显示键盘快捷键。
import SwiftUI
struct LandmarkCommands: Commands {
@FocusedBinding(\.selectedLandmark) var selectedLandmark
var body: some Commands {
SidebarCommands()
CommandMenu("Landmark") {
Button("\(selectedLandmark?.isFavorite == true ? "Remove" : "Mark") as Favorite") {
selectedLandmark?.isFavorite.toggle()
}
.keyboardShortcut("f", modifiers: [.shift, .option])
.disabled(selectedLandmark == nil)
}
}
}
private struct SelectedLandmarkKey: FocusedValueKey {
typealias Value = Binding<Landmark>
}
extension FocusedValues {
var selectedLandmark: Binding<Landmark>? {
get { self[SelectedLandmarkKey.self] }
set { self[SelectedLandmarkKey.self] = newValue }
}
}
菜单现在包含了您的新命令,但您需要设置 selectedLandmark 对焦绑定才能使其生效。
第 6 步
在 LandmarkList 中,添加一个选中地标的状态变量以及一个表示选中地标索引的计算属性。
import SwiftUI
struct LandmarkList: View {
@Environment(ModelData.self) var modelData
@State private var showFavoritesOnly = false
@State private var filter = FilterCategory.all
@State private var selectedLandmark: Landmark?
enum FilterCategory: String, CaseIterable, Identifiable {
case all = "All"
case lakes = "Lakes"
case rivers = "Rivers"
case mountains = "Mountains"
var id: FilterCategory { self }
}
var filteredLandmarks: [Landmark] {
modelData.landmarks.filter { landmark in
(!showFavoritesOnly || landmark.isFavorite)
&& (filter == .all || filter.rawValue == landmark.category.rawValue)
}
}
var title: String {
let title = filter == .all ? "Landmarks" : filter.rawValue
return showFavoritesOnly ? "Favorite \(title)" : title
}
var index: Int? {
modelData.landmarks.firstIndex(where: { $0.id == selectedLandmark?.id })
}
var body: some View {
NavigationSplitView {
List {
ForEach(filteredLandmarks) { landmark in
NavigationLink {
LandmarkDetail(landmark: landmark)
} label: {
LandmarkRow(landmark: landmark)
}
}
}
.animation(.default, value: filteredLandmarks)
.navigationTitle(title)
.frame(minWidth: 300)
.toolbar {
ToolbarItem {
Menu {
Picker("Category", selection: $filter) {
ForEach(FilterCategory.allCases) { category in
Text(category.rawValue).tag(category)
}
}
.pickerStyle(.inline)
Toggle(isOn: $showFavoritesOnly) {
Label("Favorites only", systemImage: "star.fill")
}
} label: {
Label("Filter", systemImage: "slider.horizontal.3")
}
}
}
} detail: {
Text("Select a Landmark")
}
}
}
#Preview {
LandmarkList()
.environment(ModelData())
}
第 7 步
使用选中值的绑定初始化 List ,并向导航链接添加一个标签。
该标签将特定地标与 ForEach 中的给定项关联起来,从而驱动选择。
import SwiftUI
struct LandmarkList: View {
@Environment(ModelData.self) var modelData
@State private var showFavoritesOnly = false
@State private var filter = FilterCategory.all
@State private var selectedLandmark: Landmark?
enum FilterCategory: String, CaseIterable, Identifiable {
case all = "All"
case lakes = "Lakes"
case rivers = "Rivers"
case mountains = "Mountains"
var id: FilterCategory { self }
}
var filteredLandmarks: [Landmark] {
modelData.landmarks.filter { landmark in
(!showFavoritesOnly || landmark.isFavorite)
&& (filter == .all || filter.rawValue == landmark.category.rawValue)
}
}
var title: String {
let title = filter == .all ? "Landmarks" : filter.rawValue
return showFavoritesOnly ? "Favorite \(title)" : title
}
var index: Int? {
modelData.landmarks.firstIndex(where: { $0.id == selectedLandmark?.id })
}
var body: some View {
NavigationSplitView {
List(selection: $selectedLandmark) {
ForEach(filteredLandmarks) { landmark in
NavigationLink {
LandmarkDetail(landmark: landmark)
} label: {
LandmarkRow(landmark: landmark)
}
.tag(landmark)
}
}
.animation(.default, value: filteredLandmarks)
.navigationTitle(title)
.frame(minWidth: 300)
.toolbar {
ToolbarItem {
Menu {
Picker("Category", selection: $filter) {
ForEach(FilterCategory.allCases) { category in
Text(category.rawValue).tag(category)
}
}
.pickerStyle(.inline)
Toggle(isOn: $showFavoritesOnly) {
Label("Favorites only", systemImage: "star.fill")
}
} label: {
Label("Filter", systemImage: "slider.horizontal.3")
}
}
}
} detail: {
Text("Select a Landmark")
}
}
}
#Preview {
LandmarkList()
.environment(ModelData())
}
第 8 步
在 focusedValue(:😃 修改符中添加 NavigationSplitView ,并提供一个绑定来自 landmarks 数组的值。
这里执行查找以确保您正在修改模型中存储的地标,而不是副本。
import SwiftUI
struct LandmarkList: View {
@Environment(ModelData.self) var modelData
@State private var showFavoritesOnly = false
@State private var filter = FilterCategory.all
@State private var selectedLandmark: Landmark?
enum FilterCategory: String, CaseIterable, Identifiable {
case all = "All"
case lakes = "Lakes"
case rivers = "Rivers"
case mountains = "Mountains"
var id: FilterCategory { self }
}
var filteredLandmarks: [Landmark] {
modelData.landmarks.filter { landmark in
(!showFavoritesOnly || landmark.isFavorite)
&& (filter == .all || filter.rawValue == landmark.category.rawValue)
}
}
var title: String {
let title = filter == .all ? "Landmarks" : filter.rawValue
return showFavoritesOnly ? "Favorite \(title)" : title
}
var index: Int? {
modelData.landmarks.firstIndex(where: { $0.id == selectedLandmark?.id })
}
var body: some View {
@Bindable var modelData = modelData
NavigationSplitView {
List(selection: $selectedLandmark) {
ForEach(filteredLandmarks) { landmark in
NavigationLink {
LandmarkDetail(landmark: landmark)
} label: {
LandmarkRow(landmark: landmark)
}
.tag(landmark)
}
}
.animation(.default, value: filteredLandmarks)
.navigationTitle(title)
.frame(minWidth: 300)
.toolbar {
ToolbarItem {
Menu {
Picker("Category", selection: $filter) {
ForEach(FilterCategory.allCases) { category in
Text(category.rawValue).tag(category)
}
}
.pickerStyle(.inline)
Toggle(isOn: $showFavoritesOnly) {
Label("Favorites only", systemImage: "star.fill")
}
} label: {
Label("Filter", systemImage: "slider.horizontal.3")
}
}
}
} detail: {
Text("Select a Landmark")
}
.focusedValue(\.selectedLandmark, $modelData.landmarks[index ?? 0])
}
}
#Preview {
LandmarkList()
.environment(ModelData())
}
第 9 步
运行 macOS 应用程序并尝试新的菜单项。
第 7 节
添加带有设置场景的首选项 用户期望能够通过标准的设置菜单项来调整 macOS 应用的设置。您将通过添加一个 Settings 场景来向 MacLandmarks 添加偏好设置。场景中的视图定义了偏好设置窗口的内容,您将使用这些视图来控制 MapView 的初始缩放级别。通过使用 @AppStorage 属性包装器,您将向地图视图传达该值,并持久地存储该值。
您将从在 MapView 中添加一个控件开始,该控件将初始缩放级别设置为三个级别之一:近、中或远。
步骤 1
在 MapView 中,添加一个 Zoom 枚举来表示缩放级别。
import SwiftUI
import MapKit
struct MapView: View {
var coordinate: CLLocationCoordinate2D
enum Zoom: String, CaseIterable, Identifiable {
case near = "Near"
case medium = "Medium"
case far = "Far"
var id: Zoom {
return self
}
}
var body: some View {
Map(initialPosition: .region(region))
}
private var region: MKCoordinateRegion {
MKCoordinateRegion(
center: coordinate,
span: MKCoordinateSpan(latitudeDelta: 0.2, longitudeDelta: 0.2)
)
}
}
#Preview {
MapView(coordinate: CLLocationCoordinate2D(latitude: 34.011_286, longitude: -116.166_868))
}
第 2 步
添加一个名为 zoom 的 @AppStorage 属性,默认情况下取 medium 缩放级别。
使用一个唯一标识参数的存储密钥,就像在 UserDefaults 中存储项目时那样,因为 SwiftUI 正是依赖这种机制。
import SwiftUI
import MapKit
struct MapView: View {
var coordinate: CLLocationCoordinate2D
@AppStorage("MapView.zoom")
private var zoom: Zoom = .medium
enum Zoom: String, CaseIterable, Identifiable {
case near = "Near"
case medium = "Medium"
case far = "Far"
var id: Zoom {
return self
}
}
var body: some View {
Map(initialPosition: .region(region))
}
private var region: MKCoordinateRegion {
MKCoordinateRegion(
center: coordinate,
span: MKCoordinateSpan(latitudeDelta: 0.2, longitudeDelta: 0.2)
)
}
}
#Preview {
MapView(coordinate: CLLocationCoordinate2D(latitude: 34.011_286, longitude: -116.166_868))
}
第 3 步
将用于构建 region 属性的经度和纬度差值改为依赖于 zoom 的值。
import SwiftUI
import MapKit
struct MapView: View {
var coordinate: CLLocationCoordinate2D
@AppStorage("MapView.zoom")
private var zoom: Zoom = .medium
enum Zoom: String, CaseIterable, Identifiable {
case near = "Near"
case medium = "Medium"
case far = "Far"
var id: Zoom {
return self
}
}
var delta: CLLocationDegrees {
switch zoom {
case .near: return 0.02
case .medium: return 0.2
case .far: return 2
}
}
var body: some View {
Map(initialPosition: .region(region))
}
private var region: MKCoordinateRegion {
MKCoordinateRegion(
center: coordinate,
span: MKCoordinateSpan(latitudeDelta: delta, longitudeDelta: delta)
)
}
}
#Preview {
MapView(coordinate: CLLocationCoordinate2D(latitude: 34.011_286, longitude: -116.166_868))
}
接下来,你将创建一个 Settings 场景,控制存储的 zoom 值。
第 4 步
创建一个新的 SwiftUI 视图 LandmarkSettings ,仅针对 macOS 应用。
import SwiftUI
struct LandmarkSettings: View {
var body: some View {
Text("Hello, World!")
}
}
#Preview {
LandmarkSettings()
}
第 5 步
添加一个 @AppStorage 属性,使用与地图视图中相同的键。
mport SwiftUI
struct LandmarkSettings: View {
@AppStorage("MapView.zoom")
private var zoom: MapView.Zoom = .medium
var body: some View {
Text("Hello, World!")
}
}
#Preview {
LandmarkSettings()
}
第 6 步
添加一个 Picker ,通过绑定来控制 zoom 的值。
通常您会使用一个 Form 来在设置视图中排列控件。
import SwiftUI
struct LandmarkSettings: View {
@AppStorage("MapView.zoom")
private var zoom: MapView.Zoom = .medium
var body: some View {
Form {
Picker("Map Zoom:", selection: $zoom) {
ForEach(MapView.Zoom.allCases) { level in
Text(level.rawValue)
}
}
.pickerStyle(.inline)
}
.frame(width: 300)
.navigationTitle("Landmark Settings")
.padding(80)
}
}
#Preview {
LandmarkSettings()
}
第 7 步
在 LandmarksApp 中,只为 macOS 添加 Settings 场景到您的应用。
import SwiftUI
@main
struct LandmarksApp: App {
@State private var modelData = ModelData()
var body: some Scene {
WindowGroup {
ContentView()
.environment(modelData)
}
#if !os(watchOS)
.commands {
LandmarkCommands()
}
#endif
#if os(watchOS)
WKNotificationScene(controller: NotificationController.self, category: "LandmarkNear")
#endif
#if os(macOS)
Settings {
LandmarkSettings()
}
#endif
}
}
第 8 步
运行应用并尝试更改设置。
注意每次更改缩放级别时地图都会刷新。