由于 API 变动,此文章部分内容已失效,最新完整中文教程及代码请查看 https://github.com/WillieWangWei/SwiftUI-Tutorials
处理用户输入
在
Landmarks
app 中,用户可以标记他们喜欢的地点,并在列表中过滤出来。要实现这个功能,我们要先在列表中添加一个开关,这样用户可以只看到他们收藏的内容。另外还会添加一个星形按钮,用户可以点击该按钮来收藏地标。下载起始项目文件并按照以下步骤操作,也可以打开已完成的项目自行浏览代码。
- 预计完成时间:20 分钟
- 初始项目文件:下载
1. 标记用户收藏的地标
首先,通过优化列表来清晰地给用户显示他们的收藏。给每个被收藏地标的 LandmarkRow
添加一颗星。
1.1 打开起始项目,在 Project navigator
中选择 LandmarkRow.swift
。
1.2 在 spacer
的下面添加一个 if
语句,在其中添加一个星形图片来测试当前地标是否被收藏。
在 SwiftUI
block 中,我们使用 if
语句来有条件的引入 view 。
LandmarkRow.swift
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))
}
}
1.3 由于系统图片是基于矢量的,所以我们可以通过 foregroundColor(_:)
方法来修改它们的颜色。
当 landmark
的 isFavorite
属性为 true
时,星星就会显示。稍后我们会在教程中看到如何修改这个属性。
LandmarkRow.swift
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 中。
2.1 在 Project navigator
中选择 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
NavigationButton(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}
2.2 点击 Resume
按钮来刷新 canvas
。
当我们对 view 的结构进行更改,比如添加或修改属性时,需要手动刷新 canvas
。
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 {
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 相应的状态
3.1 创建一个嵌套的 ForEach group
将 landmarks
转换为 rows
。
若要在列表中组合静态和动态 view ,或者将两个或多个不同的动态 view 组合在一起,要使用 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 {
NavigationButton(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}
3.2 添加一个 Toggle
view 作为 List
view 的第一个子项,然后给 showFavoritesOnly
传递一个 binding
。
我们使用 $
前缀来访问一个状态变量或者它的属性的 binding
。
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 {
NavigationButton(destination: LandmarkDetail(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
}
}
.navigationBarTitle(Text("Landmarks"))
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}
3.3 使用实时预览并点击切换来尝试这个新功能。
4. 使用 Bindable Object 进行存储
为了让用户控制哪些特定地标被收藏,我们先要把地标数据存储在 bindable object
中。
bindable object
是数据的自定义对象,它可以从 SwiftUI
环境中的存储绑定到 view 上。 SwiftUI
监视 bindable object
中任何可能影响 view 的修改,并在修改后显示正确的 view 版本。
4.1 创建一个新 Swift
文件,命名为 UserData.swift
,然后声明一个模型类型。
UserData.swift
import SwiftUI
final class UserData: BindableObject {
}
4.2 添加必要属性 didChange
,使用 PassthroughSubject
作为发布者。
PassthroughSubject
是 Combine
框架中一个简易的发布者,它把任何值都直接传递给它的订阅者。 SwiftUI
通过这个发布者订阅我们的对象,然后当数据改变时更新所有需要更新的 view 。
UserData.swift
import SwiftUI
import Combine
final class UserData: BindableObject {
let didChange = PassthroughSubject()
}
4.3 添加存储属性 showFavoritesOnly
和 landmarks
以及它们的初始值。
UserData.swift
import SwiftUI
import Combine
final class UserData: BindableObject {
let didChange = PassthroughSubject()
var showFavoritesOnly = false
var landmarks = landmarkData
}
当客户端更新模型的数据时,bindable object
需要通知它的订阅者。当任何属性更改时, UserData
应通过它的 didChange
发布者发布更改。
4.4 给通过 didChange
发布者发送更新的两个属性创建 didSet handlers
。
UserData.swift
import SwiftUI
import Combine
final class UserData: BindableObject {
let didChange = PassthroughSubject()
var showFavoritesOnly = false {
didSet {
didChange.send(self)
}
}
var landmarks = landmarkData {
didSet {
didChange.send(self)
}
}
}
5. 在 View 中接受模型对象
现在已经创建了 UserData
对象,我们需要更新 view 来将 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 {
NavigationButton(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
对象成员的 binding
。
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 {
NavigationButton(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 {
NavigationButton(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
let window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = UIHostingController(
rootView: LandmarkList()
.environmentObject(UserData())
)
self.window = window
window.makeKeyAndVisible()
}
// ...
}
5.5 更新 LandmarkDetail
view 来使用环境中的 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(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())
}
}
5.6 切回 LandmarkList.swift
,打开实时预览来验证一切是否正常。
6. 给每个 Landmark 创建收藏按钮
Landmarks
app 现在可以在已过滤和未过滤的地标视图之间切换,但收藏的地标仍是硬编码的。为了让用户添加和删除收藏,我们需要在地标详情 view 中添加收藏夹按钮。
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(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())
}
}
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(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())
}
}
6.3 在 LandmarkList.swift
中打开预览。
当我们从列表导航到详情并点击按钮时,我们会在返回列表后看到这些更改仍然存在。由于两个 view 在环境中访问相同的模型对象,因此这两个 view 会保持一致。