ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [SwiftUI] 3. Handling User Input
    iOS Dev/SwiftUI 2023. 3. 30. 03:10
    반응형

    https://developer.apple.com/tutorials/swiftui/handling-user-input

     

    Handling User Input | Apple Developer Documentation

    In the Landmarks app, a user can flag their favorite places, and filter the list to show just their favorites. To create this feature, you’ll start by adding a switch to the list so users can focus on just their favorites, and then you’ll add a star-sh

    developer.apple.com

    이제 지금까지 짜여진 List에 몇가지 기능을 조금 더 추가해볼 것이다.

    Section 1. Mark the User's Favorite Landmarks

    Landmark.swift 파일로 들어가보자. 모델을 수정할것이다.

    isFavorite: Bool 문장을 추가해주자.

    import Foundation
    import SwiftUI
    import CoreLocation
    
    struct Landmark: Hashable, Codable, Identifiable {
        var id: Int
        var name: String
        var park: String
        var state: String
        var description: String
        var isFavorite: Bool ---- ✅
        
        private var imageName: String
        var image: Image {
            return Image(imageName)
        }
        
        private var coordinates: Coordinates
        var locationCoordinate: CLLocationCoordinate2D {
            CLLocationCoordinate2D(latitude: coordinates.latitude, longitude: coordinates.longitude)
        }
        
        struct Coordinates: Hashable, Codable {
            var latitude: Double
            var longitude: Double
        }
    }

    다음, LandmarkRow.swift로 넘어가자. 중간에 if landmark.isFavorite 부분을 추가해주었다.

    import SwiftUI
    
    struct LandmarkRow: View {
        var landmark: Landmark
        var body: some View {
            HStack {
                landmark.image
                    .resizable()
                    .frame(width: 50, height: 50)
                Text(landmark.name)
                Spacer()
                
                if landmark.isFavorite { ---- ✅
                    Image(systemName: "star.fill")
                        .foregroundColor(.yellow)
                }
            }
        }
    }
    
    struct LandmarkRow_Previews: PreviewProvider {
        static var previews: some View {
            Group {
                LandmarkRow(landmark: landmarks[0])
                LandmarkRow(landmark: landmarks[1])
            }
            .previewLayout(.fixed(width: 300, height: 70))
        }
    }

    Section 1에서 더 살펴볼 내용은 없는 것 같다.

    Section 2. Filter the List View

    이제 filter를 걸어서 isFavorite가 true인 친구들만 따로 보여줄 수 있도록 커스터마이징 해 볼 것이다. @State를 활용한다.
    튜토리얼에서 설명하는 @State : 시간이 지남에 따라 변할 수 있는 값, 또는 값의 집합. 뷰의 행동, 내용, 레이아웃에 영향을 줄 수 있음.

    LandmarkList.swift 파일로 넘어가서, previews에 LandmarkList()만 위치하도록 롤백시켜준다.

    @State 변수인 showFavoritesOnly 를 추가한다. 추가시켜주는 이유? 이 정보를 계속 유지하고싶고, 바꿀 수 있게 하고싶어서. 또한 바뀐 정보를 뷰에 계속 반영하고 싶어서. 그냥 var로 추가하면 뷰에 바로 반영이 안된다.
    주의할 점 : State 변수는 항상 private으로 생성해야 한다!!!!!!!!!
        이유는? 다른 곳에서 변경할 일이 없기 때문.
            case 1. 부모 뷰에서 자식 뷰의 State를 바꾸고 싶다? 애초에 데이터를 잘 바인딩 시켜서 자식 뷰를 push해주면 그럴 일이 안생긴다.
            case 2. 자식 뷰에서 부모 뷰의 State를 바꾸고 싶다? 애초에 $을 통해 해당 값을 바인딩시켜주면 된다.
    고로 어이없는 실수를 방지하기 위해 private으로 선언해주자.

    이제 데이터를 방금 선언한 State 변수와 모델의 isFavorite 변수를 이용해 필터링하자.
    필터링된 데이터를 따로 변수로 담고, 해당 변수를 리스트로 보여주는 방식으로 진행하자.

    import SwiftUI
    
    struct LandmarkList: View {
        @State private var showFavoritesOnly = true
        
        var filteredLandmarks: [Landmark] {
            landmarks.filter { landmark in
                (!showFavoritesOnly || landmark.isFavorite)
            }
        }
        
        var body: some View {
            NavigationStack {
                List(filteredLandmarks) { landmark in
                    NavigationLink(value: landmark) {
                        LandmarkRow(landmark: landmark)
                    }
                }
                .navigationDestination(for: Landmark.self) { landmark in
                    LandmarkDetail(landmark: landmark)
                }
                .navigationTitle("Landmarks")
            }
        }
    }
    
    struct LandmarkList_Previews: PreviewProvider {
        static var previews: some View {
            LandmarkList()
        }
    }

     

    Section 3. Add a Control to Toggle the State

    이제 Binding을 활용해서 Filter 적용을 toggle할 수 있게 만들어보자.

    LandmarkList.swift에서 filteredLandmarks를 ForEach로 바꿔주고, 그걸 List로 감싸주자.
    ForEach로 갑자기 바꾼 이유? : 정적 뷰와 동적 뷰를 리스트에서 같이 띄워주기 위해, 또는 여러 개의 서로 다른 그룹들을 같이 띄워주기 위해 사용한다. 즉, 뷰들을 모두 동적 뷰로 띄워주겠다는 의미이다.

    그럼 ForEach를 한번 보자.

    public struct ForEach<Data, ID, Content> where Data : RandomAccessCollection, ID : Hashable {
    
        /// The collection of underlying identified data that SwiftUI uses to create
        /// views dynamically.
        public var data: Data
    
        /// A function to create content on demand using the underlying data.
        public var content: (Data.Element) -> Content
    }

    data는 컬렉션 자체를 갖고 있고, content는 Data의 원소 하나를 인자로 갖는다.

    public init(_ data: Data, @ViewBuilder content: @escaping (Data.Element) -> Content)

    평소에 보던 for-each들이랑 똑같다.

    이제 토글 버튼을 추가하고, 결과를 지켜보자.

    import SwiftUI
    
    struct LandmarkList: View {
        @State private var showFavoritesOnly = false
        
        var filteredLandmarks: [Landmark] {
            modelData.landmarks.filter { landmark in
                (!showFavoritesOnly || landmark.isFavorite)
            }
        }
        
        var body: some View {
            NavigationStack {
                List {
                    Toggle(isOn: $showFavoritesOnly) {
                        Text("Favorites only")
                    }
                    ForEach(filteredLandmarks) { landmark in
                        NavigationLink(value: landmark) {
                            LandmarkRow(landmark: landmark)
                        }
                    }
                    
                    .navigationTitle("Landmarks")
                }
                .navigationDestination(for: Landmark.self) { landmark in
                    LandmarkDetail(landmark: landmark)
                }
            }
        }
    }
    
    struct LandmarkList_Previews: PreviewProvider {
        static var previews: some View {
            LandmarkList()
        }
    }

    이전에 설명 했듯, 중간에 나오는 달러($) 기호는 바인딩을 의미한다. 포인터 개념으로 값의 제어권을 준다고 생각해도 무방할 것 같다.

     

    Section 4. Use an Observable Object for Storage

    Combine을 사용해서 각각의 row에 대해 Favorite를 설정할 수 있도록 해보자.

    ModelData.swift파일을 열어서 다음과 같이 만들자.

    import Foundation
    import Combine
    
    final class ModelData: ObservableObject {
        @Published var landmarks: [Landmark] = load("landmarkData.json")
    }
    
    func load<T: Decodable>(_ filename: String) -> T {
        let data: Data
        guard let file = Bundle.main.url(forResource: filename, withExtension: nil)
        else {
            fatalError("Couldn't find \(filename) in main bundle")
        }
        
        do {
            data = try Data(contentsOf: file)
        } catch {
            fatalError("Couldn't find \(filename) from main bundle:\n\(error)")
        }
        
        do {
            let decoder = JSONDecoder()
            return try decoder.decode(T.self, from: data)
        } catch {
            fatalError("Couldn't parse \(filename) as \(T.self):\n\(error)")
        }
    }

    재밌는 것들이 추가되었다. 먼저 Combine을 보자.
    Combine은 async 작업들을 이벤트 처리 연산자로 결합하여 처리하는 방법이며, 선언적인 프로그래밍 형태로 사용 가능하다.
    즉, SwiftUI에서 쓸 수 있는 Rx요소라고 생각하면 되겠다.

    ObservableObject 먼저 보자.

    public protocol ObservableObject : AnyObject {
    
        /// The type of publisher that emits before the object has changed.
        associatedtype ObjectWillChangePublisher : Publisher = ObservableObjectPublisher where Self.ObjectWillChangePublisher.Failure == Never
    
        /// A publisher that emits before the object has changed.
        var objectWillChange: Self.ObjectWillChangePublisher { get }
    }

    결국 objectWillChange라는 변수가 있어야 하고, 이건 Publisher 타입이라는 것을 알 수 있다. 주석으로 두번이나 같은 내용을 써뒀는데, publisher는 object의 값이 바뀌기 직전에 방출한다는 소리다. 즉 ObservableObject 프로토콜을 준수한다면, objectWillChange 변수가 있어야 하고, 이 친구는 object의 값이 바뀌기 직전에 방출한다.

    다음은 @Published이다.

    /// > Important: The `@Published` attribute is class constrained. Use it with properties of classes, not with non-class types like structures.
    ///
    /// ### See Also
    ///
    /// - ``Combine/Publisher/assign(to:)``
    @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
    @propertyWrapper public struct Published<Value> {
    
        /// Creates the published instance with an initial wrapped value.
        ///
        /// Don't use this initializer directly. Instead, create a property with the `@Published` attribute, as shown here:
        ///
        ///     @Published var lastUpdated: Date = Date()
        ///
        /// - Parameter wrappedValue: The publisher's initial value.
        public init(wrappedValue: Value)
    
        /// Creates the published instance with an initial value.
        ///
        /// Don't use this initializer directly. Instead, create a property with the `@Published` attribute, as shown here:
        ///
        ///     @Published var lastUpdated: Date = Date()
        ///
        /// - Parameter initialValue: The publisher's initial value.
        public init(initialValue: Value)
    
        /// A publisher for properties marked with the `@Published` attribute.
        public struct Publisher : Publisher {
    
            /// The kind of values published by this publisher.
            public typealias Output = Value
    
            /// The kind of errors this publisher might publish.
            ///
            /// Use `Never` if this `Publisher` does not publish errors.
            public typealias Failure = Never
    
            /// Attaches the specified subscriber to this publisher.
            ///
            /// Implementations of ``Publisher`` must implement this method.
            ///
            /// The provided implementation of ``Publisher/subscribe(_:)-4u8kn``calls this method.
            ///
            /// - Parameter subscriber: The subscriber to attach to this ``Publisher``, after which it can receive values.
            public func receive<S>(subscriber: S) where Value == S.Input, S : Subscriber, S.Failure == Never
        }
    
        /// The property for which this instance exposes a publisher.
        ///
        /// The ``Published/projectedValue`` is the property accessed with the `$` operator.
        public var projectedValue: Published<Value>.Publisher { mutating get set }
    }
    ...
    /// - Add the `@Published` annotation to a property of one of your own types. In doing so, the property gains a publisher that emits an event whenever the property’s value changes. See the ``Published`` type for an example of this approach.

    오.. 첫 문장에 @Published는 무조건 클래스 안에서만 사용하라고 되어있다. 주의하자.
    대충 읽어보니 Subscriber로 이 @Published 된 친구들을 구독할 수 있다고 한다.
    달러 기호($)로 Publisher를 접근할 수 있다.

     

    Section 5. Adopt the Model Object in Your Views

    뷰에 Model Object를 적용 시키는 과정을 살펴보자.

    LandmarkList.swift로 가서, @EnvironmentObject로 선언된 modelData를 적어준다.
    이제 modelData는 값을 자동으로 불러올 수 있으며, 이는 부모 단계에서 environmentObject(_:) 함수로 값을 쥐어주어야 한다.

    다음과 같이 완성시키자.

    import SwiftUI
    
    struct LandmarkList: View {
        @EnvironmentObject var modelData: ModelData
        @State private var showFavoritesOnly = false
        
        var filteredLandmarks: [Landmark] {
            modelData.landmarks.filter { landmark in
                (!showFavoritesOnly || landmark.isFavorite)
            }
        }
        
        var body: some View {
            NavigationStack {
                List {
                    Toggle(isOn: $showFavoritesOnly) {
                        Text("Favorites only")
                    }
                    ForEach(filteredLandmarks) { landmark in
                        NavigationLink(value: landmark) {
                            LandmarkRow(landmark: landmark)
                        }
                    }
                    
                    .navigationTitle("Landmarks")
                }
                .navigationDestination(for: Landmark.self) { landmark in
                    LandmarkDetail(landmark: landmark)
                }
            }
        }
    }
    
    struct LandmarkList_Previews: PreviewProvider {
        static var previews: some View {
            LandmarkList()
                .environmentObject(ModelData())
        }
    }

    @EnvironmentObject에 대해 좀 더 살펴보자.

    /// A property wrapper type for an observable object supplied by a parent or
    /// ancestor view.
    ///
    /// An environment object invalidates the current view whenever the observable
    /// object changes. If you declare a property as an environment object, be sure
    /// to set a corresponding model object on an ancestor view by calling its
    /// ``View/environmentObject(_:)`` modifier.
    @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
    @frozen @propertyWrapper public struct EnvironmentObject<ObjectType> : DynamicProperty where ObjectType : ObservableObject {
    
        /// A wrapper of the underlying environment object that can create bindings
        /// to its properties using dynamic member lookup.
        @dynamicMemberLookup @frozen public struct Wrapper {
    
            /// Returns a binding to the resulting value of a given key path.
            ///
            /// - Parameter keyPath: A key path to a specific resulting value.
            ///
            /// - Returns: A new binding.
            public subscript<Subject>(dynamicMember keyPath: ReferenceWritableKeyPath<ObjectType, Subject>) -> Binding<Subject> { get }
        }
    
        /// The underlying value referenced by the environment object.
        ///
        /// This property provides primary access to the value's data. However, you
        /// don't access `wrappedValue` directly. Instead, you use the property
        /// variable created with the ``EnvironmentObject`` attribute.
        ///
        /// When a mutable value changes, the new value is immediately available.
        /// However, a view displaying the value is updated asynchronously and may
        /// not show the new value immediately.
        @inlinable @MainActor public var wrappedValue: ObjectType { get }
    
        /// A projection of the environment object that creates bindings to its
        /// properties using dynamic member lookup.
        ///
        /// Use the projected value to pass an environment object down a view
        /// hierarchy.
        @MainActor public var projectedValue: EnvironmentObject<ObjectType>.Wrapper { get }
    
        /// Creates an environment object.
        public init()
    }

    부모 또는 상위 뷰에서 온 observable object의 property wrapper 타입이라고 한다.
    EnvironmentObject는 Obervable Object의 값이 변경되면 현재 뷰를 invalidate 시킨다. 
    최상위 뷰에서 주입된 객체를 계속 재사용하기 위해 사용한다. (call by reference처럼 값의 변화도 유지된다.)

    다음은 LandmarkDetail.swift로 넘어가서, Preview를 다음과 같이 수정한다.

    struct LandmarkDetail_Previews: PreviewProvider {
        static var previews: some View {
            LandmarkDetail(landmark: ModelData().landmarks[0])
        }
    }

    다음은 LandmarkRow.swift로 넘어가서 Preview를 또 수정한다.

    struct LandmarkRow_Previews: PreviewProvider {
        static var landmarks = ModelData().landmarks
        static var previews: some View {
            Group {
                LandmarkRow(landmark: landmarks[0])
                LandmarkRow(landmark: landmarks[1])
            }
            .previewLayout(.fixed(width: 300, height: 70))
        }
    }

    마지막으로 ContentView.Swift로 넘어가서 Preview를 수정한다.

    struct ContentView_Previews: PreviewProvider {
        static var previews: some View {
            ContentView()
                .environmentObject(ModelData())
        }
    }

    EnvironmentObject를 사용하면 상위 뷰에서 무조건 .environmentObject(_:)를 통해 데이터를 내려주어야 하기 때문에 이걸 다 바꾼 것이다.

    어라? 근데 지금까지는 preview에만 데이터를 넣어주었는데요?

    걱정하지 말자. 지금부터 App에도 똑같이 적용할 것이다.

    LandmarksApp.swift로 넘어가자. 다음과 같이 코드를 적어주자.

    import SwiftUI
    
    @main
    struct LandmarksApp: App {
        @StateObject private var modelData = ModelData()
        var body: some Scene {
            WindowGroup {
                ContentView()
                    .environmentObject(modelData)
            }
        }
    }

    여기서 @StateObject로 선언된 modelData를 내려주고 있다.
    @StateObject는 Observable Object로 된 클래스의 property wrapper type이다. 
    그냥 @State는 값이 변경되면 뷰도 같이 바꿔주었다.
    @StateObject도 동일하게 데이터가 변경되면 뷰도 같이 바꿔준다. 차이점은 @State는 SwiftUI에서 제공하는 Type, 즉 Struct으로만 가능한데, @StateObject는 ObservableObject 프로토콜을 채택한 Class으로만 가능하다.
    SwiftUI는 @StateObject에 대해 오직 하나의 인스턴스(Only One Instance)만 만들어서 관리한다.

     

    Section 6. Create a Favorite Button for Each Landmark

    이제 각각의 랜드마크에서 사용할 수 있는, favorite button을 만들어서 붙일 것이다.

    FavoriteButton.swift 파일을 생성하자.

    다음 내용으로 채워주자.

    import SwiftUI
    
    struct FavoriteButton: View {
        @Binding var isSet: Bool
        var body: some View {
            Button {
                isSet.toggle()
            } label: {
                Label("Toggle Favorite", systemImage: isSet ? "star.fill" : "star")
                    .labelStyle(.iconOnly)
                    .foregroundColor(isSet ? .yellow : .gray)
            }
        }
    }
    
    struct FavoriteButton_Previews: PreviewProvider {
        static var previews: some View {
            FavoriteButton(isSet: .constant(true))
        }
    }

    @Binding은 저번에 @State할 때 알아보아서, 간단하게 짚고 넘어간다.
    @Binding을 사용해서 값의 변화가 뷰에서도 일어나고, 뷰에서의 변화가 값에서도 동일하게 일어나게 된다.

    중간에 Label() 부분 보면 .iconOnly로 되어있지만 Title은 들어있다. 이는 Voice Over같은 접근성 항목들에 영향을 준다.

    폴더가 지저분하다. 다음과 같이 이동해주자.

    그 다음 LandmarkDetail.swift로 가서 다음 내용을 채워준다.

    import SwiftUI
    
    struct LandmarkDetail: View {
        @EnvironmentObject var modelData: ModelData
        var landmark: Landmark
        
        var landmarkIndex: Int {
            modelData.landmarks.firstIndex(where: { $0.id == landmark.id })!
        }
    
        var body: some View {
            ScrollView {
                MapView(coordinate: landmark.locationCoordinate)
                    .ignoresSafeArea(edges: .top)
                    .frame(height: 300)
                CircleImage(image: landmark.image)
                    .offset(y: -130)
                    .padding(.bottom, -130)
                VStack(alignment: .leading) {
                    HStack {
                        Text(landmark.name)
                            .font(.title)
                        FavoriteButton(isSet: $modelData.landmarks[landmarkIndex].isFavorite)
                    }
                    HStack {
                        Text(landmark.park)
                            .font(.subheadline)
                        Spacer()
                        Text(landmark.state)
                            .font(.subheadline)
                    }
                    .font(.subheadline)
                    .foregroundColor(.secondary)
                    
                    Divider()
                    
                    Text("About \(landmark.name)")
                        .font(.title2)
                    Text(landmark.description)
                }
                .padding()
            }
            .navigationTitle(landmark.name)
            .navigationBarTitleDisplayMode(.inline)
        }
    }
    
    struct LandmarkDetail_Previews: PreviewProvider {
        static let modelData = ModelData()
        static var previews: some View {
            LandmarkDetail(landmark: ModelData().landmarks[0])
                .environmentObject(modelData)
        }
    }

    결국 @EnvironmentObject로 주입받은 modelData의 자신의 인덱스를 찾고, 그 곳에 있는 .isFavorite 요소와 연결해둔 것이다.

    이제 완성된 앱을 테스트 해보자.

     

     

    Section 7. Check Your Understanding

    정답은 순서대로 2 1 3

    반응형

    댓글

Designed by Tistory.