Skip to content

Handling User Input

Willie edited this page Feb 10, 2020 · 9 revisions

处理用户输入

Landmarks app 中,用户可以标记他们喜欢的地点,并在列表中过滤出来。要实现这个功能,我们要先在列表中添加一个开关,这样用户可以只看到他们收藏的内容。另外还会添加一个星形按钮,用户可以点击该按钮来收藏地标。

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

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

1. 标记用户收藏的地标

首先,通过优化列表来清晰地给用户显示他们的收藏。给每个被收藏地标的 LandmarkRow 添加一颗星。

1.1 打开起始项目,在项目导航器中选择 LandmarkRow.swift

1.2 在 spacer 的下面添加一个 if 语句,在其中添加一个星形图片来测试当前地标是否被收藏。

SwiftUI block 中,我们使用 if 语句来有条件的引入视图。

LandmarkRow.swift

import SwiftUI

struct LandmarkRow: View {
    var landmark: Landmark

    var body: some View {
        HStack {
            landmark.image
                .resizable()
                .frame(width: 50, height: 50)
            Text(landmark.name)
            //
            Spacer()

            if landmark.isFavorite {
                Image(systemName: "star.fill")
                    .imageScale(.medium)
            }
            //
        }
    }
}

struct LandmarkRow_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            LandmarkRow(landmark: landmarkData[0])
            LandmarkRow(landmark: landmarkData[1])
        }
        .previewLayout(.fixed(width: 300, height: 70))
    }
}

1.3 由于系统图片是基于矢量的,所以我们可以通过 foregroundColor(_:) 修饰符来修改它们的颜色。

landmarkisFavorite 属性为 true 时,星星就会显示。稍后我们会在教程中看到如何修改这个属性。

LandmarkRow.swift

import SwiftUI

struct LandmarkRow: View {
    var landmark: Landmark

    var body: some View {
        HStack {
            landmark.image
                .resizable()
                .frame(width: 50, height: 50)
            Text(landmark.name)
            Spacer()

            if landmark.isFavorite {
                Image(systemName: "star.fill")
                    .imageScale(.medium)
                    //
                    .foregroundColor(.yellow)
                    //
            }
        }
    }
}

struct LandmarkRow_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            LandmarkRow(landmark: landmarkData[0])
            LandmarkRow(landmark: landmarkData[1])
        }
        .previewLayout(.fixed(width: 300, height: 70))
    }
}

2. 过滤列表视图

我们可以自定义列表视图,让它显示所有的地标或者只显示用户收藏的。为此,我们需要给 LandmarkList 类型添加一些 state

state 是一个值或一组值,它可以随时间变化,并且会影响视图的行为、内容或布局。我们用具有 @State 特征的属性将 state 添加到视图中。

2.1 在项目导航器中选择 LandmarkList.swift ,添加一个名叫 showFavoritesOnly@State 属性,把它的初始值设为 false

LandmarkList.swift

import SwiftUI

struct LandmarkList: View {
    //
    @State var showFavoritesOnly = false
    //

    var body: some View {
        NavigationView {
            List(landmarkData) { landmark in
                NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
                    LandmarkRow(landmark: landmark)
                }
            }
            .navigationBarTitle(Text("Landmarks"))
        }
    }
}

struct LandmarkList_Previews: PreviewProvider {
    static var previews: some View {
        LandmarkList()
    }
}

2.2 点击 Resume 按钮来刷新画布。

当我们对视图的结构进行更改,比如添加或修改属性时,需要手动刷新画布。

2.3 通过检查 showFavoritesOnly 属性和每个 landmark.isFavorite 的值来过滤地标列表。

LandmarkList.swift

import SwiftUI

struct LandmarkList: View {
    @State var showFavoritesOnly = false

    var body: some View {
        NavigationView {
            List(landmarkData) { landmark in
                //
                if !self.showFavoritesOnly || landmark.isFavorite {
                    NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
                        LandmarkRow(landmark: landmark)
                    }
                }
                //
            }
            .navigationBarTitle(Text("Landmarks"))
        }
    }
}

struct LandmarkList_Previews: PreviewProvider {
    static var previews: some View {
        LandmarkList()
    }
}

3. 添加控件来切换状态

为了让用户控制列表的过滤,我们需要一个可以修改 showFavoritesOnly 值的控件。通过给切换控件传递一个绑定来实现这个需求。

绑定是对可变状态的引用。当用户将状态从关闭切换为打开然后再关闭时,控件使用绑定来更新视图相应的状态。

3.1 创建一个嵌套的 ForEach grouplandmarks 转换为行视图。

若要在列表中组合静态和动态视图,或者将两个或多个不同的动态视图组合在一起,要使用 ForEach 类型,而不是将数据集合传递给 List

LandmarkList.swift

import SwiftUI

struct LandmarkList: View {
    @State var showFavoritesOnly = true

    var body: some View {
        NavigationView {
            //
            List {
                ForEach(landmarkData) { landmark in
                    if !self.showFavoritesOnly || landmark.isFavorite {
                        NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
                            LandmarkRow(landmark: landmark)
                        }
                    }
                }
            }
            //
            .navigationBarTitle(Text("Landmarks"))
        }
    }
}

struct LandmarkList_Previews: PreviewProvider {
    static var previews: some View {
        LandmarkList()
    }
}

3.2 添加一个开关视图作为列表视图的第一个子项,然后给 showFavoritesOnly 传递一个绑定。

我们使用 $ 前缀来访问一个状态变量或者它的属性的绑定。

LandmarkList.swift

import SwiftUI

struct LandmarkList: View {
    @State var showFavoritesOnly = true

    var body: some View {
        NavigationView {
            List {
                //
                Toggle(isOn: $showFavoritesOnly) {
                    Text("Favorites only")
                }
                //

                ForEach(landmarkData) { landmark in
                    if !self.showFavoritesOnly || landmark.isFavorite {
                        NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
                            LandmarkRow(landmark: landmark)
                        }
                    }
                }
            }
            .navigationBarTitle(Text("Landmarks"))
        }
    }
}

struct LandmarkList_Previews: PreviewProvider {
    static var previews: some View {
        LandmarkList()
    }
}

3.3 使用实时预览并点击切换来尝试这个新功能。

4. 使用 Observable Object 进行存储

为了让用户控制哪些特定地标被收藏,我们先要把地标数据存储在 observable object 中。

observable object 是数据的自定义对象,它可以从 SwiftUI 环境中的存储绑定到视图上。 SwiftUI 监视 observable object 中任何可能影响视图的修改,并在修改后显示正确的视图内容。

4.1 创建一个新 Swift 文件,命名为 UserData.swift

UserData.swift

import SwiftUI

4.2 引入 Combine 库,声明一个遵循 ObservableObject 协议的模型类型。

SwiftUI 会订阅您的 ObservableObject ,并在数据更改时更新需要刷新的所有视图。

UserData.swift

import SwiftUI
//
import Combine

final class UserData: ObservableObject  {

}
//

4.3 添加存储属性 showFavoritesOnlylandmarks 以及它们的初始值。

UserData.swift

import SwiftUI
import Combine

final class UserData: ObservableObject  {
    //
    var showFavoritesOnly = false
    var landmarks = landmarkData
    //
}

ObservableObject 需要发布对其数据的任何更改,以便其订阅者可以获取其更改。

4.4 给通过 didChange 发布者发送更新的两个属性创建 didSet handlers

UserData.swift

import SwiftUI
import Combine

final class UserData: ObservableObject  {
    //
    @Published var showFavoritesOnly = false
    @Published var landmarks = landmarkData
    //
}

5. 在视图中接收模型对象

现在已经创建了 UserData 对象,我们需要更新视图来将 UserData 对象用作 app 的数据存储。

5.1 在 LandmarkList.swift 中,将 showFavoritesOnly 声明换成一个 @EnvironmentObject 属性,然后给 preview 添加一个 environmentObject(_:) 修饰符。

一旦将 environmentObject(_:) 修饰符应用于父级, userData 属性就会自动获取它的值。

LandmarkList.swift

import SwiftUI

struct LandmarkList: View {
    //
    @EnvironmentObject var userData: UserData
    //

    var body: some View {
        NavigationView {
            List {
                Toggle(isOn: $showFavoritesOnly) {
                    Text("Favorites only")
                }

                ForEach(landmarkData) { landmark in
                    if !self.showFavoritesOnly || landmark.isFavorite {
                        NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
                            LandmarkRow(landmark: landmark)
                        }
                    }
                }
            }
            .navigationBarTitle(Text("Landmarks"))
        }
    }
}

struct LandmarkList_Previews: PreviewProvider {
    static var previews: some View {
        LandmarkList()
            //
            .environmentObject(UserData())
            //
    }
}

5.2 将 showFavoritesOnly 的调用更改成访问 userData 上的相同属性。

@State 属性一样,我们可以使用 $ 前缀访问 userData 对象成员的绑定。

LandmarkList.swift

import SwiftUI

struct LandmarkList: View {
    @EnvironmentObject var userData: UserData

    var body: some View {
        NavigationView {
            List {
                //
                Toggle(isOn: $userData.showFavoritesOnly) {
                //
                    Text("Favorites only")
                }

                ForEach(landmarkData) { landmark in
                    //
                    if !self.userData.showFavoritesOnly || landmark.isFavorite {
                    //
                        NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
                            LandmarkRow(landmark: landmark)
                        }
                    }
                }
            }
            .navigationBarTitle(Text("Landmarks"))
        }
    }
}

struct LandmarkList_Previews: PreviewProvider {
    static var previews: some View {
        LandmarkList()
            .environmentObject(UserData())
    }
}

5.3 创建 ForEach 对象时,使用 userData.landmarks 作为其数据。

LandmarkList.swift

import SwiftUI

struct LandmarkList: View {
    @EnvironmentObject var userData: UserData

    var body: some View {
        NavigationView {
            List {
                Toggle(isOn: $userData.showFavoritesOnly) {
                    Text("Favorites only")
                }

                //
                ForEach(userData.landmarks) { landmark in
                //
                    if !self.userData.showFavoritesOnly || landmark.isFavorite {
                        NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
                            LandmarkRow(landmark: landmark)
                        }
                    }
                }
            }
            .navigationBarTitle(Text("Landmarks"))
        }
    }
}

struct LandmarkList_Previews: PreviewProvider {
    static var previews: some View {
        LandmarkList()
            .environmentObject(UserData())
    }
}

5.4 在 SceneDelegate.swift 中,给 LandmarkList 添加 environmentObject(_:) 修饰符。

如果我们不是使用预览,而是在模拟器或真机上构建或运行 Landmarks ,这个更新可以确保 LandmarkList 在环境中持有 UserData 对象。

SceneDelegate.swift

import UIKit
import SwiftUI

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
        // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
        // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).

        // Use a UIHostingController as window root view controller
        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            //
            window.rootViewController = UIHostingController(
                rootView: LandmarkList()
                    .environmentObject(UserData())
            )
            //
            self.window = window
            window.makeKeyAndVisible()
        }
    }

    // ...
}

5.5 更新 LandmarkDetail 视图来使用环境中的 UserData 对象。

我们使用 landmarkIndex 访问或更新 landmark 的收藏状态,这样就可以始终得到该数据的正确版本。

LandmarkDetail.swift

import SwiftUI

struct LandmarkDetail: View {
    //
    @EnvironmentObject var userData: UserData
    //
    var landmark: Landmark

    //
    var landmarkIndex: Int {
        userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
    }
    //

    var body: some View {
        VStack {
            MapView(landmark: landmark)
                .frame(height: 300)

            CircleImage(image: landmark.image)
                .offset(y: -130)
                .padding(.bottom, -130)

            VStack(alignment: .leading) {
                Text(landmark.name)
                    .font(.title)
                HStack(alignment: .top) {
                    Text(landmark.park)
                        .font(caption)
                    Spacer()
                    Text(landmark.state)
                        .font(.caption)
                }
            }
            .padding()

            Spacer()
        }
        .navigationBarTitle(Text(landmark.name), displayMode: .inline)
    }
}

struct LandmarkDetail_Preview: PreviewProvider {
    static var previews: some View {
        LandmarkDetail(landmark: landmarkData[0])
            //
            .environmentObject(UserData())
            //
    }
}

5.6 切回 LandmarkList.swift ,打开实时预览来验证一切是否正常。

6. 给每个 Landmark 创建收藏按钮

Landmarks app 现在可以在已过滤和未过滤的地标视图之间切换,但收藏的地标仍是硬编码的。为了让用户添加和删除收藏,我们需要在地标详情视图中添加收藏夹按钮。

6.1 在 LandmarkDetail.swift 中,把 landmark.name 嵌套在一个 HStack 中。

LandmarkDetail.swift

import SwiftUI

struct LandmarkDetail: View {
    @EnvironmentObject var userData: UserData
    var landmark: Landmark

    var landmarkIndex: Int {
        userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
    }

    var body: some View {
        VStack {
            MapView(landmark: landmark)
                .frame(height: 300)

            CircleImage(image: landmark.image)
                .offset(y: -130)
                .padding(.bottom, -130)

            VStack(alignment: .leading) {
                //
                HStack {
                    Text(landmark.name)
                        .font(.title)
                }
                //

                HStack(alignment: .top) {
                    Text(landmark.park)
                        .font(caption)
                    Spacer()
                    Text(landmark.state)
                        .font(.caption)
                }
            }
            .padding()

            Spacer()
        }
        .navigationBarTitle(Text(landmark.name), displayMode: .inline)
    }
}

struct LandmarkDetail_Preview: PreviewProvider {
    static var previews: some View {
        LandmarkDetail(landmark: landmarkData[0])
            .environmentObject(UserData())
    }
}

6.2 在 landmark.name 下面创建一个新按钮。用 if-else 条件语句给地标传递不同的图片来区分是否被收藏。

在按钮的 action 闭包中,代码使用持有 userData 对象的 landmarkIndex 来更新地标。

LandmarkDetail.swift

import SwiftUI

struct LandmarkDetail: View {
    @EnvironmentObject var userData: UserData
    var landmark: Landmark

    var landmarkIndex: Int {
        userData.landmarks.firstIndex(where: { $0.id == landmark.id })!
    }

    var body: some View {
        VStack {
            MapView(landmark: landmark)
                .frame(height: 300)

            CircleImage(image: landmark.image)
                .offset(y: -130)
                .padding(.bottom, -130)

            VStack(alignment: .leading) {
                HStack {
                    Text(landmark.name)
                        .font(.title)

                    //
                    Button(action: {
                        self.userData.landmarks[self.landmarkIndex].isFavorite.toggle()
                    }) {
                        if self.userData.landmarks[self.landmarkIndex].isFavorite {
                            Image(systemName: "star.fill")
                                .foregroundColor(Color.yellow)
                        } else {
                            Image(systemName: "star")
                                .foregroundColor(Color.gray)
                        }
                    }
                    //
                }

                HStack(alignment: .top) {
                    Text(landmark.park)
                        .font(caption)
                    Spacer()
                    Text(landmark.state)
                        .font(.caption)
                }
            }
            .padding()

            Spacer()
        }
        .navigationBarTitle(Text(landmark.name), displayMode: .inline)
    }
}

struct LandmarkDetail_Preview: PreviewProvider {
    static var previews: some View {
        LandmarkDetail(landmark: landmarkData[0])
            .environmentObject(UserData())
    }
}

6.3 在 LandmarkList.swift 中打开预览。

当我们从列表导航到详情并点击按钮时,我们会在返回列表后看到这些更改仍然存在。由于两个视图在环境中访问相同的模型对象,因此这两个视图会保持一致。