第七篇 swiftUI Landmarks 使用UI控件

2,012 阅读7分钟

使用UI控件

在Landmarks应用程序中,用户可以创建个人资料来表达自己的个性。为了让用户能够更改他们的个人资料,您将添加一个编辑模式并设计偏好屏幕。 您将使用各种常见的用户界面控件进行数据输入,并在用户保存更改时更新Landmarks模型类型。
按照步骤构建这个项目,或者下载完成的项目来自己探索。

第一节 显示用户配置页面

Landmarks应用程序在本地存储一些配置详细信息和偏好。在用户编辑其详细信息之前,它们将显示在没有任何编辑控件的摘要视图中。

第一步 新建Profile.swift

在Models分组中新建一个配置模型Profile.swift

import Foundation

struct Profile {
    var username: String
    var prefersNotifications = true
    var seasonalPhoto = Season.winter
    var goalDate = Date()
    
    static let `default` = Profile(username: "g_kumar")
    
    enum Season: String, CaseIterable, Identifiable {
        case spring = "🌷"
        case summer = "🌞"
        case autumn = "🍂"
        case winter = "☃️"
        
        var id: String {rawValue}
        
    }
    
}

第二步 新建ProfileHost.swift

Views分组下,新增一个Profiles分组。在Profiles分组下,新增一个视图ProfileHost.swift,该视图展示了存储在profile模型中username。

struct ProfileHost: View {
    @State private var draftProfile = Profile.default
    var body: some View {
        Text("Profile for: \(draftProfile.username)")
    }
}

struct ProfileHost_Previews: PreviewProvider {
    static var previews: some View {
        ProfileHost()
    }
}

ProfileHost视图将同时承载配置文件信息的静态摘要视图和编辑模式。

第三步 新建ProfileSummary.swift

Profiles分组下,新建一个视图ProfileSummary.swift,该视图使用Profile实例展示一些用户的基本信息。

import SwiftUI

struct ProfileSummary: View {
    var profile: Profile
    var body: some View {
        
        ScrollView {
            VStack(alignment: .leading, spacing: 10) {
                
                Text(profile.username)
                    .bold()
                    .font(.title)
                
                Text("Notifications: \(profile.prefersNotifications ? "On" : "Off")")
                
                Text("Seasonal Photo: \(profile.seasonalPhoto.rawValue)")
                
                Text("Goal Date: ") + Text(profile.goalDate, style: .date)
            }
        }
        
        
    }
}

struct ProfileSummary_Previews: PreviewProvider {
    static var previews: some View {
        ProfileSummary(profile: Profile.default)
    }
}

配置文件摘要采用profile值,而不是绑定到配置文件,因为父视图ProfileHost管理此视图的状态。

第四步 更新ProfileHost.swift

import SwiftUI

struct ProfileHost: View {
    @State private var draftProfile = Profile.default
    var body: some View {
        
        VStack {
            ProfileSummary(profile: draftProfile)
        }
        .padding()
        
    }
}

struct ProfileHost_Previews: PreviewProvider {
    static var previews: some View {
        ProfileHost()
    }
}

第五步 创建HikeBadge.swift

Hikes分组下,新建视图HikeBadge.swift,该视图根据“绘制路径和形状”以及有关远足的一些描述性文本组成徽章。
徽章只是一个图形,因此HikeBadge中的文本以及accessibilityLabel(_:)修饰符可以让其他用户更清楚地理解徽章的含义。
笔记:
徽章的绘制逻辑会产生一个结果,该结果取决于其渲染的帧的大小。若要确保所需的外观,请在300 x 300点的frame中进行渲染。要获得最终图形所需的大小,请缩放渲染结果,并将其放置在相对较小的frame中。

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)
                .accessibilityLabel("Badge for \(name).")
        }
    }
}

struct HikeBadge_Previews: PreviewProvider {
    static var previews: some View {
        HikeBadge(name: "Preview Testing")
    }
}

第六步 更新ProfileSummary.swift

ProfileSummary.swift中添加几个不同色调的徽章,以及获得徽章的原因


                Divider()
                
                VStack(alignment: .leading) {
                    Text("Completed Badges")
                        .font(.headline)
                    
                    ScrollView(.horizontal) {
                        HStack {
                            HikeBadge(name: "First Hike")
                            
                            HikeBadge(name: "Earth Day")
                                .hueRotation(Angle(degrees: 90))
                            
                            HikeBadge(name: "Tenth Hike")
                                .grayscale(0.5)
                                .hueRotation(Angle(degrees: 45))
                        }
                        .padding(.bottom)
                    }
                }

第七步

通过包含“设置视图和过渡动画”中的HikeView来完成配置文件摘要。
若要使用Hike数据,还需要添加模型数据环境对象。


    Divider()

    VStack(alignment: .leading) {
        Text("Recent Hikes")
            .font(.headline)
        HikeView(hike: modelData.hikes[0])
    }

第八步 串联CategoryHome.swift

在CategoryHome.swift中,使用工具栏修改器将用户配置文件按钮添加到导航栏,并在用户点击它时显示ProfileHost视图。

    .toolbar {
        Button {
            showProfile.toggle()
        } label: {
            Label("User Profile", systemImage: "person.crop.circle")
        }
    }
    .sheet(isPresented: $showProfile) {
        ProfileHost().environmentObject(modelData)
    }

第九步 更改listStyle

添加listStyle修饰符以选择更适合内容的列表样式。

import SwiftUI

struct CategoryHome: View {
    @EnvironmentObject var modelData: ModelData
    @State private var showProfile = false
    
    var body: some View {
        NavigationView {
            List {
                modelData.features[0].image
                    .resizable()
                    .scaledToFill()
                    .frame(height: 200)
                    .clipped()
                    .listRowInsets(EdgeInsets())
                
                ForEach(modelData.categories.keys.sorted(), id: \.self) { key in
                    CategoryRow(categoryName: key, items: modelData.categories[key]!)
                }
                .listRowInsets(EdgeInsets())
            }
            .listStyle(.inset)
            .navigationTitle(Text("Featured"))
            .toolbar {
                Button {
                    showProfile.toggle()
                } label: {
                    Label("User Profile", systemImage: "person.crop.circle")
                }
            }
            .sheet(isPresented: $showProfile) {
                ProfileHost().environmentObject(modelData)
            }
            
        }
    }
}

struct CategoryHome_Previews: PreviewProvider {
    static var previews: some View {
        CategoryHome().environmentObject(ModelData())
    }
}

第十步 切换到实时预览,然后尝试点击配置文件按钮以查看配置文件摘要。

image.png

image.png

第二节 添加编辑模式

在个人信息配置页面,用户既可以查看也可以编辑,因此您要提供一个编辑按钮,当用户点击的时候,创建一个视图供用户修改某些信息

第一步 修改ProfileHost.swift

在预览视图中添加环境对象ModelData

struct ProfileHost_Previews: PreviewProvider {
    static var previews: some View {
        ProfileHost()
            .environmentObject(ModelData())
    }
}

尽管此视图没有使用带有@EnvironmentObject属性的属性,但此视图的子级ProfileSummary使用了。因此,如果没有修改器,预览将失败。

第二步 引用视图环境的编辑模式

添加一个环境视图属性,该属性可以打开或者关闭编辑模式 在环境中,swiftUI提供了值的存储,您可以通过@Environment包裹属性来访问这些值。访问editMode值以读取或写入编辑范围

@Environment(\.editMode) var editMode

第三步 添加编辑按钮

添加一个按钮来决定是否开启编辑,EditButton同样控制着环境中editMode的值

HStack {
    Spacer()
    EditButton()
}

第四步 更新ModelData.swift

ModelData类添加用户配置文件的实例,该实例即使在用户解除配置文件视图后也会持续存在。

@Published var profile = Profile.default

第五步 ProfileHost控制环境中的用户配置数据

从环境中读取用户的配置文件数据,以将数据控制权传递给ProfileHost。 为了避免在确认任何编辑之前更新全局应用程序状态,例如在用户输入姓名时,编辑视图会对其自身的副本进行操作。

@EnvironmentObject var modelData: ModelData

第六步 编辑和非编辑模式下内容展示

添加一个条件判断,在编辑模式下展示一段文案“Profile Editor”。非编辑模式下正常展示用户的配置数据

import SwiftUI

struct ProfileHost: View {
    
    @Environment(\.editMode) var editMode
    @EnvironmentObject var modelData: ModelData
    @State private var draftProfile = Profile.default
    
    var body: some View {
        
        VStack(alignment: .leading, spacing: 10) {
            
            HStack {
                Spacer()
                EditButton()
            }
            if editMode?.wrappedValue == .inactive {
                ProfileSummary(profile: modelData.profile)
            }else {
                Text("Profile Editor")
            }
        }
        .padding()
        
    }
}

struct ProfileHost_Previews: PreviewProvider {
    static var previews: some View {
        ProfileHost()
            .environmentObject(ModelData())
    }
}

在预览模式下, 点击编辑按钮,可以看到在编辑模式下展示了一段静态文本

第三节 定义配置文件编辑器

用户配置文件编辑器主要由不同的控件组成,这些控件可更改配置文件中的各个细节。配置文件中的某些项目,如徽章,是不可由用户编辑的,因此它们不会出现在编辑器中。
为了与概要文件摘要保持一致,您将在编辑器中以相同的顺序添加概要文件详细信息。

第一步 创建ProfileEditor.swift

创建一个名为ProfileEditor的新视图,并包含到用户配置文件草稿副本的绑定。 视图中的第一个控件是TextField,它控制并更新字符串绑定——在本例中是用户选择的显示名称。创建文本字段时,可以为字符串提供标签和绑定。

import SwiftUI

struct ProfileEditor: View {
    @Binding var profile: Profile
    var body: some View {
        List {
            HStack {
                Text("Username").bold()
                Divider()
                TextField("username", text: $profile.username)
            }
        }
        .listStyle(.inset)
    }
}

struct ProfileEditor_Previews: PreviewProvider {
    static var previews: some View {
        ProfileEditor(profile: .constant(.default))
    }
}

第二步 更新ProfileHost

if editMode?.wrappedValue == .inactive {
    ProfileSummary(profile: modelData.profile) 
} else { 
    ProfileEditor(profile: $draftProfile) 
}

第三步 通知开关

添加一个开关,该开关与用户配置文件中的prefersNotifications相对应。

Toggle(isOn: $profile.prefersNotifications) {

    Text("Enable Notifications").bold()

}

第四步 季节图片选择器

将Picker控件及其标签放置在VStack中,使地标照片具有可选择的季节。

VStack(alignment: .leading, spacing: 20) {

    Text("Seasonal Photo").bold()

    Picker("Seasonl Photo", selection: $profile.seasonalPhoto) {
        ForEach(Profile.Season.allCases) { season in
            Text(season.rawValue).tag(season)
        }
    }
    .pickerStyle(.segmented)
}

第五步 添加一个时间选择器

最后,在季节选择器下面添加一个DatePicker,使里程碑式的访问目标日期可以修改。

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").bold()
            }
            
            VStack(alignment: .leading, spacing: 20) {
                
                Text("Seasonal Photo").bold()
                
                Picker("Seasonl Photo", selection: $profile.seasonalPhoto) {
                    ForEach(Profile.Season.allCases) { season in
                        Text(season.rawValue).tag(season)
                    }
                }
                .pickerStyle(.segmented)
            }
            
            DatePicker(selection: $profile.goalDate, in: dateRange, displayedComponents: .date) {
                Text("Goal Date").bold()
            }
        }
        .listStyle(.inset)
    }
}

struct ProfileEditor_Previews: PreviewProvider {
    static var previews: some View {
        ProfileEditor(profile: .constant(.default))
    }
}

image.png

第四节 延迟编辑传播

为了使编辑在用户退出编辑模式后才会生效,您可以在编辑过程中使用其配置文件的草稿副本,然后只有在用户确认编辑时才将草稿副本分配给真实副本。

第一步 向ProfileHost添加一个取消按钮。

与EditButton提供的“完成”按钮不同,“取消”按钮不会将编辑应用于其闭包中的真实配置文件数据。

 if editMode?.wrappedValue == .active {
    Button("Cancel", role: .cancel) {
        draftProfile = modelData.profile
        editMode?.animation().wrappedValue = .inactive
    }

}

第二步 编辑完成更新数据

当用户点击编辑按钮,编辑器显示时,将modelData中的配置数据的副本传递过去。当用户点击完成时,将副本赋值给modelData的profile。否则,在下次激活编辑模式时依旧显示的是旧值。

ProfileEditor(profile: $draftProfile)
    .onAppear {
        draftProfile = modelData.profile
    }
    .onDisappear {
        modelData.profile = draftProfile
    }

ProfileHost.swift完整代码

import SwiftUI

struct ProfileHost: View {
    
    @Environment(\.editMode) var editMode
    @EnvironmentObject var modelData: ModelData
    @State private var draftProfile = Profile.default
    
    var body: some View {
        
        VStack(alignment: .leading, spacing: 10) {
            
            HStack {
                if editMode?.wrappedValue == .active {
                    Button("Cancel", role: .cancel) {
                        draftProfile = modelData.profile
                        editMode?.animation().wrappedValue = .inactive
                    }

                }
                
                Spacer()
                EditButton()
            }
            if editMode?.wrappedValue == .inactive {
                ProfileSummary(profile: modelData.profile)
            }else {
                ProfileEditor(profile: $draftProfile)
                    .onAppear {
                        draftProfile = modelData.profile
                    }
                    .onDisappear {
                        modelData.profile = draftProfile
                    }
            }
        }
        .padding()
        
    }
}

struct ProfileHost_Previews: PreviewProvider {
    static var previews: some View {
        ProfileHost()
            .environmentObject(ModelData())
    }
}