App Design and Layout 챕터의 2번째 과정, UI Control 작업을 해보자!
공식문서 링크는 여기 클릭 !
이번에도 프로젝트는 Github에 업로드 해놓았다. 🐱
Landmarks 앱에서 사용자는 자신의 개성을 표현하는 프로필을 만들 수 있다. 사용자에게 프로필 변경 기능을 제공하기 위해 편집 모드를 추가하고, 환경 설정 화면을 만들어보자 ! 또한, 데이터 입력을 위해 다양한 일반 사용자 인터페이스 컨트롤들을 사용하고, 사용자가 변경 사항을 저장할 때 마다 Landmarks 모델을 업데이트 해보자.
Landmarks 앱은 일부 세부 정보 및 기본 설정을 로컬에 저장한다. 사용자가 세부 정보를 수정하기 전에 해당 정보들은 편집 모드가 아닌 형태로 보여져야 한다.
import Foundation
struct Profile {
var username: String
var prefersNotificatoins = 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 { self.rawValue }
}
}
ProfileHost 뷰는 프로필 정보 요약 보기와 편집모드 모두 host 한다.
import SwiftUI
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에서는 프로필에 대한 바인딩이 아닌 프로필 값을 사용한다.
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.prefersNotificatoins ? <"On" : "OFF")")
Text("Seasonal Photos: \(profile.seasonalPhoto.rawValue)")
Text("Goal Date: ") + Text(profile.goalDate, style: .date)
}
}
}
}
struct ProfileSummary_Previews: PreviewProvider {
static var previews: some View {
ProfileSummary(profile: Profile.default)
}
}
struct ProfileHost: View {
@State private 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()
}
}
import SwiftUI
struct HikeBadge: View {
var name: String
var body: some View {
VStack(alignment: .leading) {
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")
}
}
struct ProfileSummary: View {
@EnvironmentObject var modelData: ModelData
var profile: Profile
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 10) {
Text(profile.username)
.bold()
.font(.title)
Text("Notifications: \(profile.prefersNotificatoins ? "On" : "OFF")")
Text("Seasonal Photos: \(profile.seasonalPhoto.rawValue)")
Text("Goal Date: ") + Text(profile.goalDate, style: .date)
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")
.hueRotation(Angle(degrees: 45))
}
.padding(.bottom)
}
}
Divider()
VStack(alignment: .leading) {
Text("Recent Hikes")
.font(.headline)
HikeView(hike: modelData.hikes[0])
}
}
}
}
}
struct ProfileSummary_Previews: PreviewProvider {
static var previews: some View {
ProfileSummary(profile: Profile.default)
.environmentObject(ModelData())
}
}
struct CategoryHome: View {
@EnvironmentObject var modelData: ModelData
@State private var showingProfile = 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(InsetListStyle())
.navigationTitle("Featured")
.toolbar {
Button(action: { showingProfile.toggle() }) {
Image(systemName: "person.crop.circle")
.accessibilityLabel("User Profile")
}
}
.sheet(isPresented: $showingProfile) {
ProfileHost()
.environmentObject(modelData)
}
}
}
}
사용자가 편집 모드와 일반 모드를 전환시킬 수 있게 하기 위해 ProfileHost에 EditButton을 추가시킨 다음 값을 편집하기 위한 control이 있는 view를 만들어 보자.
EditButton은 property로 선언된 editMode environment 값을 제어한다.
@Environment: Environment property wrapper를 이용해 view의 environment에 저장된 값을 읽을 수 있다. property 선언에서 key 경로를 사용해서 읽을 값을 나타낸다. 예를 들어 property key 경로를 사용해 현재 view의 color scheme을 읽는 속성을 만들 수 있다.
@Environment(\.colorScheme) var colorScheme: ColorScheme
그리고 다음과 같이 사용할 수 있다.
if colorScheme == .dark { DarkContent() } else { LightContent() }
import SwiftUI
struct ProfileHost: View {
@Environment(\.editMode) var editMode
@State private 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()
.environmentObject(ModelData())
}
}
@Published var profile = Profile.default
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: 20) {
HStack {
Spacer()
EditButton()
}
if editMode?.wrappedValue == .inactive {
ProfileSummary(profile: modelData.profile)
} else {
Text("Profile Editor")
}
}
.padding()
}
}
사용자 프로필 editor는 프로필의 개별 세부 정보를 변경하는 여러 control들로 구성된다. 배지와 같은 프로필의 일부 항목은 사용자가 수정할 수 없기 때문에 editor에서 볼 수 없다. profile summary와 일관성을 유지시키기 위해 editor에 같은 순서로 프로필 세부 정보를 추가해주자.
TextField는 문자열 바인딩을 control하고 업데이트한다.
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))
}
}
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: 20) {
HStack {
Spacer()
EditButton()
}
if editMode?.wrappedValue == .inactive {
ProfileSummary(profile: modelData.profile)
} else {
ProfileEditor(profile: $draftProfile)
}
}
.padding()
}
}
수정 사항을 확정하기 전에 global app state가 업데이트 되는 일을 방지하기 위해 ProfileEditor에는 복사본을 전달해야한다.
토글은 on/off 상태 두가지를 표현할 수 있다. 따라서 Boolean 값을 다룰 때 토글을 쓰면 좋다.
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.prefersNotificatoins) {
Text("Enable Notifications").bold()
}
VStack(alignment: .leading, spacing: 20) {
Text("Seasonal Photo").bold()
Picker("Seasonal Photo", selection: $profile.seasonalPhoto){
ForEach(Profile.Season.allCases) { season in
Text(season.rawValue).tag(season)
}
}
.pickerStyle(SegmentedPickerStyle())
}
DatePicker(selection: $profile.goalDate, in: dateRange, displayedComponents: .date) {
Text("Goal Date").bold()
}
}
}
}
짜잔-!
사용자가 편집 모드를 종료하기 전에 편집 내용이 적용되지 않도록, 편집하는 동안 프로필의 복사본을 사용하고 사용자가 편집을 완료하면 실제 data에 수정 사항을 적용해야 한다.
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: 20) {
HStack {
if editMode?.wrappedValue == .active {
Button("Cancel") {
draftProfile = modelData.profile
editMode?.animation().wrappedValue = .inactive
}
}
Spacer()
EditButton()
}
if editMode?.wrappedValue == .inactive {
ProfileSummary(profile: modelData.profile)
} else {
ProfileEditor(profile: $draftProfile)
}
}
.padding()
}
}
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: 20) {
HStack {
if editMode?.wrappedValue == .active {
Button("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()
}
}