-
[SwiftUI] 7. Working with UI ControlsiOS Dev/SwiftUI 2023. 5. 6. 23:44반응형
https://developer.apple.com/tutorials/swiftui/working-with-ui-controls
Working with UI Controls | Apple Developer Documentation
In the Landmarks app, users can create a profile to express their personality. To give users the ability to change their profile, you’ll add an edit mode and design the preferences screen.
developer.apple.com
이제 UI Control 요소들과 어떻게 상호작용하는지 알아보자.
Section 1. Display a User Profile
먼저 Model 그룹에 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 } } }
그 다음 Views 그룹 안에 Profiles 그룹을 만들고, 그 안에 ProfileHost.swift 파일을 만든다.
내용은 다음과 같이 채운다.
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() } }
그 다음 같은 그룹 안에 ProfileSummary.swift 파일을 만든다.
내용은 다음과 같이 채운다.
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 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) } }
ProfileSummary의 부모가 될 ProfileHost 뷰에서 State로 Profile을 관리하고 있기 때문에 자식인 ProfileSummary는 굳이 바인딩할 필요 없다고 설명에 나와있다. ProfileSummary 뷰를 수정하는 동안 뷰 갱신을 하면 안되기 때문에 바인딩 시키지 않는다.
또한, 특이하게 View간 + 연산자가 적용되는 것을 볼 수 있다.
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) extension Text { /// Concatenates the text in two text views in a new text view. /// /// - Parameters: /// - lhs: The first text view with text to combine. /// - rhs: The second text view with text to combine. /// /// - Returns: A new text view containing the combined contents of the two /// input text views. public static func + (lhs: Text, rhs: Text) -> Text }
들어가서 보면 단순히 Text 안에 있는 문자열을 concatenate 해주는 연산으로 적용되어있다.
다음, ProfileHost.swift 파일로 다시 가서 다음 내용을 수정한다.
import SwiftUI 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() } }
다음, Hikes 그룹 안에 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) .accessibilityLabel("Badge for \(name).") } } } struct HikeBadge_Previews: PreviewProvider { static var previews: some View { HikeBadge(name: "Preview Testing!") } }
중간에 이상한게 몇개 끼어있다. 먼저 .accessibilityLabel은 이 배지가 어떤 역할을 하는지 다른 협업자에게 명시해줄 수 있는 좋은 수단이다.
그리고, Badge()를 300x300으로 잡고 scale을 1/3으로 줄인 다음 다시 100x100으로 줄인다. 왜 이럴까? 실험을 한번 해보자.
(1)
.frame(width: 100, height: 100)(2)
.frame(width: 300, height: 300)
.scaleEffect(1.0 / 3.0)
.frame(width: 100, height: 100)(3)
.scaleEffect(1.0 / 3.0)
.frame(width: 100, height: 100)(4)
.scaleEffect(1.0 / 3.0)
.frame(width: 300, height: 300)가운데 문양의 크기를 더 작게 하기 위해 이런 방법을 택한 것 같다.
다음은 ProfileSummary.swift에 다음 내용을 추가한다.
import SwiftUI 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.prefersNotifications ? "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") .grayscale(0.5) .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()) } }
다음은 CategoryHome.swift 파일을 수정한다.
struct CategoryHome: View { @EnvironmentObject var modelData: ModelData @State private var showingProfile = false var body: some View { NavigationStack { 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("Featured") .toolbar { Button { showingProfile.toggle() } label: { Label("User Profile", systemImage: "person.crop.circle") } } .sheet(isPresented: $showingProfile) { ProfileHost() .environmentObject(modelData) } } } }
Section 2. Add an Edit Mode
이제 화면에 Edit Mode를 달아보자.
ProfileHost.swift에 다음 내용을 추가한다.
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()) // ✅ } }
중간에 @Environment가 보인다. 한번 알아보자.
/// A property wrapper that reads a value from a view's environment. /// /// Use the `Environment` property wrapper to read a value /// stored in a view's environment. Indicate the value to read using an /// ``EnvironmentValues`` key path in the property declaration. For example, you /// can create a property that reads the color scheme of the current /// view using the key path of the ``EnvironmentValues/colorScheme`` /// property: /// /// @Environment(\.colorScheme) var colorScheme: ColorScheme /// ...중략... @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) @frozen @propertyWrapper public struct Environment<Value> : DynamicProperty { /// Creates an environment property to read the specified key path. /// /// Don’t call this initializer directly. Instead, declare a property /// with the ``Environment`` property wrapper, and provide the key path of /// the environment value that the property should reflect: /// /// struct MyView: View { /// @Environment(\.colorScheme) var colorScheme: ColorScheme /// /// // ... /// } /// /// SwiftUI automatically updates any parts of `MyView` that depend on /// the property when the associated environment value changes. /// You can't modify the environment value using a property like this. /// Instead, use the ``View/environment(_:_:)`` view modifier on a view to /// set a value for a view hierarchy. /// /// - Parameter keyPath: A key path to a specific resulting value. @inlinable public init(_ keyPath: KeyPath<EnvironmentValues, Value>) /// The current value of the environment property. /// /// The wrapped value property provides primary access to the value's data. /// However, you don't access `wrappedValue` directly. Instead, you read the /// property variable created with the ``Environment`` property wrapper: /// /// @Environment(\.colorScheme) var colorScheme: ColorScheme /// /// var body: some View { /// if colorScheme == .dark { /// DarkContent() /// } else { /// LightContent() /// } /// } /// @inlinable public var wrappedValue: Value { get } }
뷰의 환경에서 값을 읽는 property wrapper이다.
keypath로 되어있으며, EnvironmentValues 타입을 입력해서 값을 뽑을 수 있다.
다음은 ModelData.swift 를 수정한다.
final class ModelData: ObservableObject { @Published var landmarks: [Landmark] = load("landmarkData.json") @Published var profile = Profile.default // ✅ var hikes: [Hike] = load("hikeData.json") var features: [Landmark] { landmarks.filter { $0.isFeatured } } var categories: [String: [Landmark]] { Dictionary( grouping: landmarks, by: { $0.category.rawValue } ) } }
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: 20) { 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()) } }
Edit하고 나서 Done을 누르기 전에 모델에 반영되면 안되기 때문에 profile 전달할 때 modelData.profile을 카피해서 보낸다.
Section 3. Define the Profile Editor
Views 안에 Profiles 그룹 안에 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)) } }
TextField 요소가 처음 등장했다. 기존 UIKit에서는 delegate 등으로 값을 알거나 변경할 때 감지했는데, swiftui에서는 바인딩으로 쉽게 해결한 것을 볼 수 있다. text에 들어가야 하는 요소는 Binding<String>이다. 첫번째로 들어가는 요소는 이 TextField의 Key라고 생각하면 된다.
다음은 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: 20) { HStack { Spacer() EditButton() } if editMode?.wrappedValue == .inactive { ProfileSummary(profile: modelData.profile) } else { ProfileEditor(profile: $draftProfile) // ✅ } } .padding() } }
다시 ProfileEditor.swift로 가서 Toggle 요소와 Picker요소들을 추가한다.
struct ProfileEditor: View { @Binding var profile: Profile var dateRange: ClosedRange<Date> { let min = Calendar.current.date(byAdding: .year, value: -1, to: profile.goalDate) ?? Date() let max = Calendar.current.date(byAdding: .year, value: 1, to: profile.goalDate) ?? Date() 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("Seasonal 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() } } } }
Section 4. Delay Edit Propagation
cancel 버튼과 edit가 끝나면 데이터 반영되도록 변경해보자. 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: 20) { 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() } }
.onAppear과 .onDisappear을 저렇게 쉽게 사용할 수 있다니..!!!
Check Your Understanding
답은 2 2 2
반응형'iOS Dev > SwiftUI' 카테고리의 다른 글
[SwiftUI] Square Shape를 활용하여 Image 조정하기 (1) 2023.07.03 [SwiftUI] 8. Interfacing with UIKit [마지막] (1) 2023.05.11 [SwiftUI] 6. Composing Complex Interfaces (0) 2023.05.04 [SwiftUI] 5. Animating Views and Transitions (0) 2023.05.04 [SwiftUI] 4. Drawing Paths and Shapes (2) 2023.04.30