-
Notifications
You must be signed in to change notification settings - Fork 317
Working with UI Controls
在
Landmarks
app 中,用户可以创建个人简介来展示自己。为了让用户能修改个人简介,我们需要添加一个编辑模式,并设计一个偏好设置界面。我们将使用多种常用的 UI 控件来处理数据,并在用户保存修改时更新
Landmarks
模型。
- 预计完成时间:25 分钟
- 项目文件:下载
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)
}
}
用户需要在个人简介中切换浏览模式和编辑模式。我们会通过在现有的 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.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 添加接收地标相关事件通知的开关,它与用户偏好相对应。
开关是只有 on
或 off
的控件,所以它很适合像 yes
或 no
之类的 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.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()
}
}
SwiftUI 纲要 - 绘制与动画 - App 设计与布局 - 框架集成