跳转至

创建 macOS App

在创建了一个 watchOS 版本的 Landmarks 之后,让我们把目光投向更大的内容:将 Landmarks 运行在 Mac 上。在你目前为止所学到的基础上,强化你在构建 iOS、watchOS 和 macOS 的 SwiftUI 应用的经验。

首先,给项目添加一个 macOS target,然后重用在 iOS app 中创建的共享数据。当所有资源都准备好后,你就可以通过创建 SwiftUI 视图,在 macOS 上显示详细信息和列表视图。

下载项目文件并按照以下步骤操作,也可以打开已完成的项目自行浏览代码。

  • 预计完成时间:25 分钟
  • 项目文件:下载

1. 添加一个 macOS Target

首先要给项目添加一个 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()
    }
}

2. 共享数据和资源

接下来,我们会从 iOS app 中重用模型和资源文件,并在 macOS target 中共享。

2.1 在项目导航中,打开 Landmarks 目录并选中 ModelsResources 中所有的文件。

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
}

3. 创建行视图

使用 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.pdfstar-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])
    }
}

4. 将行视图组合到列表中

通过使用上一步定义的行视图,我们可以创建一个列表来给用户展示所有的已知 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())
    }
}

5. 创建一个过滤器来管理列表

因为用户可以将一个 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")
    //
}

遵循 CaseIterableIdentifiable 协议可以将 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())
    }
}

6. 组合列表和过滤视图

创建一个组合了过过滤器和列表的主视图。将 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())
    }
}

7. 准备重用的 CircleImage

有时我们仅需进行少量修改就能跨平台共享视图。在为 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"))
    }
}

8 在 macOS 上展开 Map View

和圆形视图一样,我们会在 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 插入可创建平台特定行为区域的编译指令。

我们会使用编译指令的两个分支来区分 UIViewRepresentableNSViewRepresentable 协议。

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 将由 makeUIViewupdateUIView 方法组成的 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)
    }
}

9. 构建详情视图

详情视图显示选中 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())
    }
}

10. 组合主视图和详情视图

现在我们已经构建了所有组件视图,接下来通过将主视图和详情视图合并到内容视图中来完善 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 的收藏指示符,查看内容是如何响应变化。