공식문서보고 SwiftUI 따라하기 (5)

Yang Si Yeon·2021년 3월 18일
1

SwiftUI

목록 보기
5/5

App Design and Layout 챕터의 2번째 과정, UI Control 작업을 해보자!
공식문서 링크는 여기 클릭 !

이번에도 프로젝트는 Github에 업로드 해놓았다. 🐱


Working with UI Controls

Landmarks 앱에서 사용자는 자신의 개성을 표현하는 프로필을 만들 수 있다. 사용자에게 프로필 변경 기능을 제공하기 위해 편집 모드를 추가하고, 환경 설정 화면을 만들어보자 ! 또한, 데이터 입력을 위해 다양한 일반 사용자 인터페이스 컨트롤들을 사용하고, 사용자가 변경 사항을 저장할 때 마다 Landmarks 모델을 업데이트 해보자.

Section 1. 유저 프로필 보여주기

Landmarks 앱은 일부 세부 정보 및 기본 설정을 로컬에 저장한다. 사용자가 세부 정보를 수정하기 전에 해당 정보들은 편집 모드가 아닌 형태로 보여져야 한다.

  1. 유저 프로필을 정의하기 위해, 프로젝트의 Model 그룹안에 Profile.swift 파일을 생성하자.
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 }
    }
}
  1. Views 그룹 안에 Profiles라는 새로운 그룹을 만들고, ProfileHost라는 커스텀 뷰를 추가하자. 그리고 해당 뷰에서 Profile에 저장되어 있는 username을 보여주자.

    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()
    }
}
  1. Profile 인스턴스를 사용하고 사용자의 기본 정보를 보여주는 ProfileSummary 뷰를 만들자.

    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)
    }
}
  1. ProfileHost에서 요약된 정보를 보여주자.
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()
    }
}
  1. Views 폴더 안에 Hikes 폴더를 만들고 HikeBadge라는 커스텀 뷰를 만들자. 해당 뱃지는 Chapter 2에서 만든 Badge 클래스를 이용하는데, 해당 포스팅에서는 Chapter 2를 넘어갔으므로 있는 파일들을 다운받아서 사용했다.
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")
    }
}
  1. ProfileSummary에 다양한 뱃지들을 추가하고, HikeView를 포함시켜 profile summary 구성을 완료하자. 뱃지들과 HikeView는 Chapter2에서 만든 프로젝트를 다운받아 사용하자 !
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())
    }
}

  1. 카테고리 홈의 navigation 바에 사용자 프로필 버튼을 추가하고, 클릭시에 ProfileHost 뷰를 띄워주자. 그리고 listStyle modifier를 추가하고 InsetListStyle을 적용해서 inset들을 없애주자.
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)
            }
        }
    }
}

Section 2. 편집 모드 추가하기

사용자가 편집 모드와 일반 모드를 전환시킬 수 있게 하기 위해 ProfileHost에 EditButton을 추가시킨 다음 값을 편집하기 위한 control이 있는 view를 만들어 보자.

  1. 먼저 ProfileHost에서 preview의 environment 객체로 model data를 추가하고, key 경로가 .editMode인 environment property를 추가하자. 그 다음 EditButton을 추가하자.

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())
    }
}
  1. ModelData 클래스에 profile view를 dismiss 시켜도 남아있는 user profile 인스턴스를 추가하자.
@Published var profile = Profile.default
  1. environment에서 사용자의 프로필 데이터를 읽어 ProfileSummary에 전달해주자. 그리고 editMode에 따라 다른 화면을 볼 수 있도록 해주자.
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()
    }
}

Section 3. 사용자 프로필 Editor 만들기

사용자 프로필 editor는 프로필의 개별 세부 정보를 변경하는 여러 control들로 구성된다. 배지와 같은 프로필의 일부 항목은 사용자가 수정할 수 없기 때문에 editor에서 볼 수 없다. profile summary와 일관성을 유지시키기 위해 editor에 같은 순서로 프로필 세부 정보를 추가해주자.

  1. ProfileEditor라는 이름을 가진 파일을 생성하고, 사용자 프로필의 draft copy에 대한 binding을 포함해주자.

    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))
    }
}
  1. ProfileHost가 profile editor를 포함하고 draftProfile을 넘겨줄 수 있도록 코드를 수정하자.
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에는 복사본을 전달해야한다.

  1. landmark 관련 이벤트에 대한 알림 수신 설정을 하는 토글을 추가한다.

토글은 on/off 상태 두가지를 표현할 수 있다. 따라서 Boolean 값을 다룰 때 토글을 쓰면 좋다.

  1. Picker와 Label을 VStack안에 배치해서 랜드마크 사진에 선호하는 계절을 선택할 수 있도록 해주고, 마지막으로 DatePicker를 맨 아래에 배치해서 랜드마크 방문 목표 날짜를 수정할 수 있도록 하자.
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()
            }
        }
    }
}

짜잔-!

Section 4. 수정 사항 적용하기 (Delay Edit Propagation)

사용자가 편집 모드를 종료하기 전에 편집 내용이 적용되지 않도록, 편집하는 동안 프로필의 복사본을 사용하고 사용자가 편집을 완료하면 실제 data에 수정 사항을 적용해야 한다.

  1. ProfileHost에 cancel 버튼을 하나 추가하자. EditButton으로 만들어진 Done 버튼과 달리 Cancel 버튼은 닫을 때 실제 프로필 데이터에 편집 내용을 적용하지 않는다.
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()
    }
}
  1. onAppear(perform:)과 onDisappear(perform:) modifier를 적용해서 editor를 올바른 프로필 데이터로 채우고 사용자가 완료 버튼을 누를 때 persistent 프로필을 업데이트 해보자.
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()
    }
}
profile
가장 젊은 지금, 내가 성장하는 데에 쓰자

0개의 댓글