Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Working with UI Controls

Willie edited this page Feb 10, 2020 · 5 revisions

使用 UI 控件

Landmarks app 中,用户可以创建个人简介来展示自己。为了让用户能修改个人简介,我们需要添加一个编辑模式,并设计一个偏好设置界面。

我们将使用多种常用的 UI 控件来处理数据,并在用户保存修改时更新 Landmarks 模型。

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

1. 显示用户简介

Landmarks app 在本地保存一些详细配置和偏好设置。在用户编辑他们的简介前,会在一个没有修改控件的摘要视图中显示出来。

1.1 在 Landmark 文件夹里创建一个新文件夹 Profile ,然后在里面创建一个新文件 ProfileHost.swift

ProfileHost 视图负责用户信息的静态摘要视图以及编辑模式。

ProfileHost.swift

import SwiftUI
struct ProfileHost: View {
 @State var draftProfile = Profile.default
 var body: some View {
 Text("Profile for: \(draftProfile.username)")
 }
}
struct ProfileHost_Previews: PreviewProvider {
 static var previews: some View {
 ProfileHost()
 }
}

1.2 在 Home.swift 中,把静态的 Text 换成上一步中创建的 ProfileHost

现在主屏幕中的 profile 按钮会显示一个带有用户信息的模态。

Home.swift

import SwiftUI
struct CategoryHome: View {
 var categories: [String: [Landmark]] {
 Dictionary(
 grouping: landmarkData,
 by: { 0ドル.category.rawValue }
 )
 }
 
 var featured: [Landmark] {
 landmarkData.filter { 0ドル.isFeatured }
 }
 
 @State var showingProfile = false
 //
 @EnvironmentObject var userData: UserData
 //
 
 var profileButton: some View {
 Button(action: { self.showingProfile.toggle() }) {
 Image(systemName: "person.crop.circle")
 .imageScale(.large)
 .accessibility(label: Text("User Profile"))
 .padding()
 }
 }
 var body: some View {
 NavigationView {
 List {
 FeaturedLandmarks(landmarks: featured)
 .scaledToFill()
 .frame(height: 200)
 .clipped()
 .listRowInsets(EdgeInsets())
 
 ForEach(categories.keys.sorted(), id: \.self) { key in
 CategoryRow(categoryName: key, items: self.categories[key]!)
 }
 .listRowInsets(EdgeInsets())
 
 NavigationLink(destination: LandmarkList()) {
 Text("See All")
 }
 }
 .navigationBarTitle(Text("Featured"))
 .navigationBarItems(trailing: profileButton)
 .sheet(isPresented: $showingProfile) {
 //
 ProfileHost()
 .environmentObject(self.userData)
 //
 }
 }
 }
}
struct FeaturedLandmarks: View {
 var landmarks: [Landmark]
 var body: some View {
 landmarks[0].image.resizable()
 }
}
struct CategoryHome_Previews: PreviewProvider {
 static var previews: some View {
 CategoryHome()
 }
}

1.3 创建一个新视图 ProfileSummary ,它持有一个 Profile 实例并显示一些基本用户信息。

ProfileSummary 持有一个 Profile 值要比一个简介的绑定更合适,因为它的父视图ProfileHost 负责管理它的 state

ProfileSummary.swift

import SwiftUI
struct ProfileSummary: View {
 var profile: Profile
 
 static let goalFormat: DateFormatter = {
 let formatter = DateFormatter()
 formatter.dateStyle = .long
 formatter.timeStyle = .none
 return formatter
 }()
 
 var body: some View {
 List {
 Text(profile.username)
 .bold()
 .font(.title)
 
 Text("Notifications: \(self.profile.prefersNotifications ? "On": "Off" )")
 
 Text("Seasonal Photos: \(self.profile.seasonalPhoto.rawValue)")
 
 Text("Goal Date: \(self.profile.goalDate, formatter: Self.goalFormat)")
 }
 }
}
struct ProfileSummary_Previews: PreviewProvider {
 static var previews: some View {
 ProfileSummary(profile: Profile.default)
 }
}

1.4 更新 ProfileHost 来显示简介视图。

ProfileHost.swift

import SwiftUI
struct ProfileHost: View {
 @State var draftProfile = Profile.default
 var body: some View {
 //
 VStack(alignment: .leading, spacing: 20) {
 ProfileSummary(profile: draftProfile)
 }
 .padding()
 //
 }
}
struct ProfileHost_Previews: PreviewProvider {
 static var previews: some View {
 ProfileHost()
 }
}

1.5 创建一个新视图 HikeBadge ,这个视图组合了 绘制路径和形状 中的徽章以及一些远足的描述文本。

徽章只是一个图形,因此 HikeBadge 中的文本以及 accessibility(label:) 修饰符让徽章对其他用户来说含义更加清晰。

注意:两个调用 frame(width:height:) 的修饰符让徽章以 300 ×ばつ 300 点的设计尺寸进行缩放渲染。

HikeBadge.swift

import SwiftUI
struct HikeBadge: View {
 var name: String
 var body: some View {
 VStack(alignment: .center) {
 Badge()
 .frame(width: 300, height: 300)
 .scaleEffect(1.0 / 3.0)
 .frame(width: 100, height: 100)
 Text(name)
 .font(.caption)
 .accessibility(label: Text("Badge for \(name)."))
 }
 }
}
struct HikeBadge_Previews: PreviewProvider {
 static var previews: some View {
 HikeBadge(name: "Preview Testing")
 }
}

1.6 更新 ProfileSummary ,给它添加几个具有不同色调的徽章以及获得徽章的原因。

ProfileSummary.swift

import SwiftUI
struct ProfileSummary: View {
 var profile: Profile
 
 static let goalFormat: DateFormatter = {
 let formatter = DateFormatter()
 formatter.dateStyle = .long
 formatter.timeStyle = .none
 return formatter
 }()
 
 var body: some View {
 List {
 Text(profile.username)
 .bold()
 .font(.title)
 
 Text("Notifications: \(self.profile.prefersNotifications ? "On": "Off" )")
 
 Text("Seasonal Photos: \(self.profile.seasonalPhoto.rawValue)")
 
 Text("Goal Date: \(self.profile.goalDate, formatter: Self.goalFormat)")
 
 //
 VStack(alignment: .leading) {
 Text("Completed Badges")
 .font(.headline)
 ScrollView {
 HStack {
 HikeBadge(name: "First Hike")
 
 HikeBadge(name: "Earth Day")
 .hueRotation(Angle(degrees: 90))
 
 
 HikeBadge(name: "Tenth Hike")
 .grayscale(0.5)
 .hueRotation(Angle(degrees: 45))
 }
 }
 .frame(height: 140)
 }
 //
 }
 }
}
struct ProfileSummary_Previews: PreviewProvider {
 static var previews: some View {
 ProfileSummary(profile: Profile.default)
 }
}

1.7 引入 动画视图与转场 中的 HikeView 来完成 ProfileSummary

ProfileSummary.swift

import SwiftUI
struct ProfileSummary: View {
 var profile: Profile
 
 static let goalFormat: DateFormatter = {
 let formatter = DateFormatter()
 formatter.dateStyle = .long
 formatter.timeStyle = .none
 return formatter
 }()
 
 var body: some View {
 List {
 Text(profile.username)
 .bold()
 .font(.title)
 
 Text("Notifications: \(self.profile.prefersNotifications ? "On": "Off" )")
 
 Text("Seasonal Photos: \(self.profile.seasonalPhoto.rawValue)")
 
 Text("Goal Date: \(self.profile.goalDate, formatter: Self.goalFormat)")
 
 VStack(alignment: .leading) {
 Text("Completed Badges")
 .font(.headline)
 ScrollView {
 HStack {
 HikeBadge(name: "First Hike")
 
 HikeBadge(name: "Earth Day")
 .hueRotation(Angle(degrees: 90))
 
 
 HikeBadge(name: "Tenth Hike")
 .grayscale(0.5)
 .hueRotation(Angle(degrees: 45))
 }
 }
 .frame(height: 140)
 }
 
 //
 VStack(alignment: .leading) {
 Text("Recent Hikes")
 .font(.headline)
 
 HikeView(hike: hikeData[0])
 }
 //
 }
 }
}
struct ProfileSummary_Previews: PreviewProvider {
 static var previews: some View {
 ProfileSummary(profile: Profile.default)
 }
}

2. 加入编辑模式

用户需要在个人简介中切换浏览模式和编辑模式。我们会通过在现有的 ProfileHost 中添加一个 EditButton 来实现编辑模式,并且创建一个带有编辑单个数据控件的视图。

2.1 创建一个 Environment 视图属性,并输入 \.editMode

我们可以使用此属性来读取和写入当前编辑范围。

ProfileHost.swift

import SwiftUI
struct ProfileHost: View {
 //
 @Environment(\.editMode) var mode
 //
 @State var draftProfile = Profile.default
 var body: some View {
 VStack(alignment: .leading, spacing: 20) {
 ProfileSummary(profile: draftProfile)
 }
 .padding()
 }
}
struct ProfileHost_Previews: PreviewProvider {
 static var previews: some View {
 ProfileHost()
 }
}

2.2 创建一个可以切换环境中编辑模式开关的 Edit 按钮。

ProfileHost.swift

import SwiftUI
struct ProfileHost: View {
 @Environment(\.editMode) var mode
 @State var draftProfile = Profile.default
 var body: some View {
 VStack(alignment: .leading, spacing: 20) {
 //
 HStack {
 Spacer()
 
 EditButton()
 }
 //
 ProfileSummary(profile: draftProfile)
 }
 .padding()
 }
}
struct ProfileHost_Previews: PreviewProvider {
 static var previews: some View {
 ProfileHost()
 }
}

2.3 更新 UserData 类,让其包括一个用户个人资料的实例,即使在用户关闭个人简介视图之后仍然保留数据。

UserData.swift

import Combine
import SwiftUI
final class UserData: ObservableObject {
 @Published var showFavoritesOnly = false
 @Published var landmarks = landmarkData
 //
 @Published var profile = Profile.default
 //
}

2.4 从环境中读取用户的配置文件数据,然后将数据的控制权传递给 ProfileHost

为了避免在任何编辑确认之前(例如在用户输入名称时)更新 app 的全局状态,编辑视图会对自身进行复制。

ProfileHost.swift

import SwiftUI
struct ProfileHost: View {
 @Environment(\.editMode) var mode
 //
 @EnvironmentObject var userData: UserData
 //
 @State var draftProfile = Profile.default
 //
 //
 var body: some View {
 VStack(alignment: .leading, spacing: 20) {
 HStack {
 Spacer()
 
 EditButton()
 }
 ProfileSummary(profile: draftProfile)
 }
 .padding()
 }
}
struct ProfileHost_Previews: PreviewProvider {
 static var previews: some View {
 ProfileHost()
 }
}

2.5 添加一个条件视图,来显示静态简介或编辑模式的视图。

注意:目前,编辑模式只是一个静态的文本。

ProfileHost.swift

import SwiftUI
struct ProfileHost: View {
 @Environment(\.editMode) var mode
 @EnvironmentObject var userData: UserData
 @State var draftProfile = Profile.default
 
 var body: some View {
 VStack(alignment: .leading, spacing: 20) {
 HStack {
 Spacer()
 
 EditButton()
 }
 //
 if self.mode?.wrappedValue == .inactive {
 ProfileSummary(profile: userData.profile)
 } else {
 Text("Profile Editor")
 }
 //
 }
 .padding()
 }
}
struct ProfileHost_Previews: PreviewProvider {
 static var previews: some View {
 ProfileHost()
 }
}

3. 定义简介编辑器

用户简介编辑器主要包含了更改详情时的不同控件。简介中徽章之类某些项目是用户编辑不了的,因此它们不会出现在编辑器中。

为了与信息摘要保持一致,我们会在编辑器中以相同的顺序添加信息详情。

3.1 创建一个新视图 ProfileEditor ,然后给用户信息的草稿副本引入一个绑定。

视图中第一个控件是一个 TextField ,它控制并更新一个字符串的绑定,在此例子中则是用户选择的显示名称。

ProfileEditor.swift

import SwiftUI
struct ProfileEditor: View {
 @Binding var profile: Profile
 
 var body: some View {
 List {
 HStack {
 Text("Username").bold()
 Divider()
 TextField("Username", text: $profile.username)
 }
 }
 }
}
struct ProfileEditor_Previews: PreviewProvider {
 static var previews: some View {
 ProfileEditor(profile: .constant(.default))
 }
}

3.2 更新 ProfileHost 中的条件内容,引入 ProfileEditor 并给它传递一个信息的绑定。

现在当你点击 Edit 后,信息编辑视图就会显示。

ProfileHost.swift

import SwiftUI
struct ProfileHost: View {
 @Environment(\.editMode) var mode
 @EnvironmentObject var userData: UserData
 @State var draftProfile = Profile.default
 
 var body: some View {
 VStack(alignment: .leading, spacing: 20) {
 HStack {
 Spacer()
 
 EditButton()
 }
 if self.mode?.wrappedValue == .inactive {
 ProfileSummary(profile: userData.profile)
 } else {
 //
 ProfileEditor(profile: $draftProfile)
 //
 }
 }
 .padding()
 }
}
struct ProfileHost_Previews: PreviewProvider {
 static var previews: some View {
 ProfileHost()
 }
}

3.3 添加接收地标相关事件通知的开关,它与用户偏好相对应。

开关是只有 onoff 的控件,所以它很适合像 yesno 之类的 Boolean 值。

ProfileEditor.swift

import SwiftUI
struct ProfileEditor: View {
 @Binding var profile: Profile
 
 var body: some View {
 List {
 HStack {
 Text("Username").bold()
 Divider()
 TextField("Username", text: $profile.username)
 }
 
 //
 Toggle(isOn: $profile.prefersNotifications) {
 Text("Enable Notifications")
 }
 //
 }
 }
}
struct ProfileEditor_Previews: PreviewProvider {
 static var previews: some View {
 ProfileEditor(profile: .constant(.default))
 }
}

3.4 将一个 Picker 控件和它的标签放在一个 VStack 中,使地标照片具有可选择的季节。

ProfileEditor.swift

import SwiftUI
struct ProfileEditor: View {
 @Binding var profile: Profile
 
 var body: some View {
 List {
 HStack {
 Text("Username").bold()
 Divider()
 TextField("Username", text: $profile.username)
 }
 
 Toggle(isOn: $profile.prefersNotifications) {
 Text("Enable Notifications")
 }
 
 //
 VStack(alignment: .leading, spacing: 20) {
 Text("Seasonal Photo").bold()
 
 Picker("Seasonal Photo", selection: $profile.seasonalPhoto) {
 ForEach(Profile.Season.allCases, id: \.self) { season in
 Text(season.rawValue).tag(season)
 }
 }
 .pickerStyle(SegmentedPickerStyle())
 }
 .padding(.top)
 //
 }
 }
}
struct ProfileEditor_Previews: PreviewProvider {
 static var previews: some View {
 ProfileEditor(profile: .constant(.default))
 }
}

3.5 最后,在季节选择器的下面添加一个 DatePicker ,用来修改到达地标的日期。

ProfileEditor.swift

import SwiftUI
struct ProfileEditor: View {
 @Binding var profile: Profile
 
 //
 var dateRange: ClosedRange<Date> {
 let min = Calendar.current.date(byAdding: .year, value: -1, to: profile.goalDate)!
 let max = Calendar.current.date(byAdding: .year, value: 1, to: profile.goalDate)!
 return min...max
 }
 //
 
 var body: some View {
 List {
 HStack {
 Text("Username").bold()
 Divider()
 TextField("Username", text: $profile.username)
 }
 
 Toggle(isOn: $profile.prefersNotifications) {
 Text("Enable Notifications")
 }
 
 VStack(alignment: .leading, spacing: 20) {
 Text("Seasonal Photo").bold()
 
 Picker("Seasonal Photo", selection: $profile.seasonalPhoto) {
 ForEach(Profile.Season.allCases, id: \.self) { season in
 Text(season.rawValue).tag(season)
 }
 }
 .pickerStyle(SegmentedPickerStyle())
 }
 //
 .padding(.top)
 
 VStack(alignment: .leading, spacing: 20) {
 Text("Goal Date").bold()
 DatePicker(
 "Goal Date",
 selection: $profile.goalDate,
 in: dateRange,
 displayedComponents: .date)
 }
 //
 .padding(.top)
 }
 }
}
struct ProfileEditor_Previews: PreviewProvider {
 static var previews: some View {
 ProfileEditor(profile: .constant(.default))
 }
}

4. 延迟编辑的传递

要使编辑在用户退出编辑模式之后才生效,我们需要在编辑期间使用信息的草稿副本,然后仅在用户确认编辑时将草稿副本分配给真实副本。

4.1 给 ProfileHost 添加一个确认按钮。

EditButton 提供的 Cancel 按钮不同, Done 按钮会在其操作闭包中将编辑应用于实际的数据。

ProfileHost.swift

import SwiftUI
struct ProfileHost: View {
 @Environment(\.editMode) var mode
 @EnvironmentObject var userData: UserData
 @State var draftProfile = Profile.default
 
 var body: some View {
 VStack(alignment: .leading, spacing: 20) {
 HStack {
 //
 if self.mode?.wrappedValue == .active {
 Button("Cancel") {
 self.draftProfile = self.userData.profile
 self.mode?.animation().wrappedValue = .inactive
 }
 }
 //
 
 Spacer()
 
 EditButton()
 }
 if self.mode?.wrappedValue == .inactive {
 ProfileSummary(profile: userData.profile)
 } else {
 ProfileEditor(profile: $draftProfile)
 }
 }
 .padding()
 }
}
struct ProfileHost_Previews: PreviewProvider {
 static var previews: some View {
 ProfileHost()
 }
}

4.2 使用 onAppear(perform:)onDisappear(perform:) 修饰符将正确的简介数据填充到编辑器中,并在用户点击完成按钮时更新持久性简介文件。

否则,下次编辑模式激活时会显示旧值。

ProfileHost.swift

import SwiftUI
struct ProfileHost: View {
 @Environment(\.editMode) var mode
 @EnvironmentObject var userData: UserData
 @State var draftProfile = Profile.default
 
 var body: some View {
 VStack(alignment: .leading, spacing: 20) {
 HStack {
 if self.mode?.wrappedValue == .active {
 Button("Cancel") {
 self.draftProfile = self.userData.profile
 self.mode?.animation().wrappedValue = .inactive
 }
 }
 
 Spacer()
 
 EditButton()
 }
 if self.mode?.wrappedValue == .inactive {
 ProfileSummary(profile: userData.profile)
 } else {
 ProfileEditor(profile: $draftProfile)
 //
 .onAppear {
 self.draftProfile = self.userData.profile
 }
 .onDisappear {
 self.userData.profile = self.draftProfile
 }
 //
 }
 }
 .padding()
 }
}
struct ProfileHost_Previews: PreviewProvider {
 static var previews: some View {
 ProfileHost()
 }
}

Clone this wiki locally

AltStyle によって変換されたページ (->オリジナル) /