Skip to content

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()
    }
}