处理用户输入
在 Landmarks app 中,用户可以标记他们喜欢的地点,并在列表中过滤出来。要实现这个功能,我们要先在列表中添加一个开关,这样用户可以只看到他们收藏的内容。另外还会添加一个星形按钮,用户可以点击该按钮来收藏地标。
你可以下载起始项目文件并按照以下步骤操作,或者直接浏览已完成项目的代码。
1. 标记用户收藏的地标
首先,通过优化列表来清晰地给用户显示他们的收藏。给每个被收藏地标的 LandmarkRow 添加一颗星。打开起始项目,在 Project navigator 中选择 LandmarkRow.swift。在 spacer 的下面添加一个 if 语句,在其中添加一个星形图片来测试当前地标是否被收藏。在 SwiftUI block 中,我们使用 if 语句来有条件的引入 view。
import SwiftUI
struct LandmarkRow: View {
var landmark: Landmark
var body: some View {
HStack {
landmark.image(forSize: 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))
}
}
由于系统图片是基于矢量的,所以我们可以通过 foregroundColor(_:) 方法来修改它们的颜色。当 landmark 的 isFavorite 属性为 true 时,星星就会显示。稍后我们会在教程中看到如何修改这个属性。
import SwiftUI
struct LandmarkRow: View {
var landmark: Landmark
var body: some View {
HStack {
landmark.image(forSize: 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. 过滤 List View
我们可以自定义 list view 让它显示所有的地标,也可以只显示用户收藏的。为此,我们需要给 LandmarkList 类型添加一点 state。state 是一个值或一组值,它可以随时间变化,并且会影响视图的行为、内容或布局。我们用具有 @State 特征的属性将 state 添加到 view 中。
在 Project navigator 中选择 LandmarkList.swift,添加一个名叫 showFavoritesOnly 的 @State 属性,把它的初始值设为 false。
import SwiftUI
struct LandmarkList: View {
@State var showFavoritesOnly = false
var body: some View {
NavigationView {
List(landmarkData) { landmark in
NavigationButton(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}
点击 Resume 按钮来刷新 canvas。当我们对 view 的结构进行更改,比如添加或修改属性时,需要手动刷新 canvas。然后通过检查 showFavoritesOnly 属性和每个 landmark.isFavorite 的值来过滤地标列表。
import SwiftUI
struct LandmarkList: View {
@State var showFavoritesOnly = false
var body: some View {
NavigationView {
List(landmarkData) { landmark in
if !self.showFavoritesOnly || landmark.isFavorite {
NavigationButton(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}
3. 添加控件来切换状态
为了让用户控制列表的过滤,我们需要一个可以修改 showFavoritesOnly 值的控件。通过给切换控件传递一个 binding 来实现这个需求。binding 是对可变状态的引用。当用户将状态从关闭切换为打开然后再关闭时,控件使用 binding 来更新 view 相应的状态。
首先创建一个嵌套的 ForEach group 将 landmarks 转换为 rows。若要在列表中组合静态和动态 view,或者将两个或多个不同的动态 view 组合在一起,要使用 ForEach 类型,而不是将数据集合传递给 List。
import SwiftUI
struct LandmarkList: View {
@State var showFavoritesOnly = true
var body: some View {
NavigationView {
List {
ForEach(landmarkData) { landmark in
if !self.showFavoritesOnly || landmark.isFavorite {
NavigationButton(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}
然后添加一个 Toggle view 作为 List view 的第一个子项,然后给 showFavoritesOnly 传递一个 binding。我们使用 $ 前缀来访问一个状态变量或者它的属性的 binding。
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 {
NavigationButton(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}
使用实时预览并点击切换来尝试这个新功能。
4. 使用 Bindable Object 进行存储
为了让用户控制哪些特定地标被收藏,我们先要把地标数据存储在 bindable object 中。bindable object 是数据的自定义对象,它可以从 SwiftUI 环境中的存储绑定到 view 上。SwiftUI 监视 bindable object 中任何可能影响 view 的修改,并在修改后显示正确的 view 版本。
创建一个新 Swift 文件,命名为 UserData.swift,然后声明一个模型类型。
import SwiftUI
final class UserData: BindableObject {
}
添加必要属性 didChange,使用 PassthroughSubject 作为发布者。PassthroughSubject 是 Combine 框架中一个简易的发布者,它把任何值都直接传递给它的订阅者。SwiftUI 通过这个发布者订阅我们的对象,然后当数据改变时更新所有需要更新的 view。
import SwiftUI
import Combine
final class UserData: BindableObject {
let didChange = PassthroughSubject<UserData, Never>()
}
添加存储属性 showFavoritesOnly 和 landmarks 以及它们的初始值。
import SwiftUI
import Combine
final class UserData: BindableObject {
let didChange = PassthroughSubject<UserData, Never>()
var showFavoritesOnly = false
var landmarks = landmarkData
}
当客户端更新模型的数据时,bindable object 需要通知它的订阅者。当任何属性更改时,UserData 应通过它的 didChange 发布者发布更改。给通过 didChange 发布者发送更新的两个属性创建 didSet handlers。
import SwiftUI
import Combine
final class UserData: BindableObject {
let didChange = PassthroughSubject<UserData, Never>()
var showFavoritesOnly = false {
didSet {
didChange.send(self)
}
}
var landmarks = landmarkData {
didSet {
didChange.send(self)
}
}
}
5. 在 View 中接受模型对象
现在已经创建了 UserData 对象,我们需要更新 view 来将 UserData 对象用作 app 的数据存储。
在 LandmarkList.swift 中,将 showFavoritesOnly 声明换成一个 @EnvironmentObject 属性,然后给 preview 添加一个 environmentObject(_:) 方法。一旦将 environmentObject(_:) 应用于父级,userData 属性就会自动获取它的值。
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 {
NavigationButton(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
.environmentObject(UserData())
}
}
将 showFavoritesOnly 的调用更改成访问 userData 上的相同属性。像 @State 属性一样,我们可以使用 $ 前缀访问 userData 对象成员的 binding。
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 {
NavigationButton(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
.environmentObject(UserData())
}
}
创建 ForEach 对象时,使用 userData.landmarks 作为其数据。
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 {
NavigationButton(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
.environmentObject(UserData())
}
}
在 SceneDelegate.swift 中,给 LandmarkList 添加 environmentObject(_:) 方法。如果我们不是使用预览,而是在模拟器或真机上构建或运行 Landmarks,这个更新可以确保 LandmarkList 在环境中持有 UserData 对象。
import UIKit
import SwiftUI
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
let window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = UIHostingController(
rootView: LandmarkList()
.environmentObject(UserData())
)
self.window = window
window.makeKeyAndVisible()
}
// ...
}
更新 LandmarkDetail view 来使用环境中的 UserData 对象。我们使用 landmarkIndex 访问或更新 landmark 的收藏状态,这样就可以始终得到该数据的正确版本。
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(forSize: 250))
.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())
}
}
切回 LandmarkList.swift,打开实时预览来验证一切是否正常。
6. 给每个 Landmark 创建收藏按钮
Landmarks app 现在可以在已过滤和未过滤的地标视图之间切换,但收藏的地标仍是硬编码的。为了让用户添加和删除收藏,我们需要在地标详情 view 中添加收藏夹按钮。
在 LandmarkDetail.swift 中,把 landmark.name 嵌套在一个 HStack 中。
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(forSize: 250))
.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())
}
}
在 landmark.name 下面创建一个新按钮。用 if-else 条件语句给地标传递不同的图片来区分是否被收藏。在按钮的 action 闭包中,代码使用持有 userData 对象的 landmarkIndex 来更新地标。
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(forSize: 250))
.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())
}
}
在 LandmarkList.swift 中打开预览。当我们从列表导航到详情并点击按钮时,我们会在返回列表后看到这些更改仍然存在。由于两个 view 在环境中访问相同的模型对象,因此这两个 view 会保持一致。