ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [SwiftUI] 2. Building Lists and Navigation
    iOS Dev/SwiftUI 2023. 3. 28. 01:28
    반응형

    https://developer.apple.com/tutorials/swiftui/building-lists-and-navigation

     

    Building Lists and Navigation | Apple Developer Documentation

    With the basic landmark detail view set up, you need to provide a way for users to see the full list of landmarks, and to view the details about each location.

    developer.apple.com

    Resources.zip
    2.26MB

    Section 1. Create a Landmark Model

    json 데이터 파일을 프로젝트로 끌어오자.

    다음, Landmark.swift 파일을 만들자.

    다음 내용을 채워주자.

    import Foundation
    
    struct Landmark: Hashable, Codable {
        var id: Int
        var name: String
        var park: String
        var state: String
        var description: String
    }

    다음, 리소스 이미지들을 쭉 끌어오자.

    다음 내용으로 채우자.

    import Foundation
    import SwiftUI
    import CoreLocation
    
    struct Landmark: Hashable, Codable {
        var id: Int
        var name: String
        var park: String
        var state: String
        var description: String
        
        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
        }
    }

     

    새로운 ModelData 파일을 만들고, JSON 데이터를 fetching 하는 내용으로 채우자.

    import Foundation
    
    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)")
        }
    }

    그룹(폴더)로 묶어주자.

     

    Section 2. Create the Row View

    Views 그룹 안에 LandmarkRow SwiftUIView 파일을 만들자.

    다음 내용을 채운다.

    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()
            }
        }
    }
    
    struct LandmarkRow_Previews: PreviewProvider {
        static var previews: some View {
            LandmarkRow(landmark: landmarks[0])
        }
    }

    특이한 것은 struct에서 따로 생성자(init)이 없음에도 자동으로 인자를 받아준다는 것이다.
    class에서는 init이 없으면 만들으라고 에러를 뱉지만, struct에서는 Memberwise Initializer을 통해 자동으로 생성자를 정의해준다.

    프리뷰 내용을 좀 더 추가해보자.

    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()
            }
        }
    }
    
    struct LandmarkRow_Previews: PreviewProvider {
        static var previews: some View {
            Group {
                LandmarkRow(landmark: landmarks[0])
                LandmarkRow(landmark: landmarks[1])
            }
            .previewLayout(.fixed(width: 300, height: 70))
        }
    }

    Group을 타고 들어가본 후 extension 중 View를 받는 extension에 가보면 @ViewBuilder 클로져로 뷰들을 받는다. 즉, 입력되는 View 속성들을 모두 child로 인식하겠다는 말이다.

    LandmarkList를 새로 만들어서 요소들을 list형태로 보여준다.

    import SwiftUI
    
    struct LandmarkList: View {
        var body: some View {
            List {
                LandmarkRow(landmark: landmarks[0])
                LandmarkRow(landmark: landmarks[1])
            }
        }
    }
    
    struct LandmarkList_Previews: PreviewProvider {
        static var previews: some View {
            LandmarkList()
        }
    }

     

    Section 5. Make the List Dynamic

    List를 자세히 뜯어보자.

    @MainActor public init<Data, ID, RowContent>(_ data: Data, id: KeyPath<Data.Element, ID>, selection: Binding<Set<SelectionValue>>?, @ViewBuilder rowContent: @escaping (Data.Element) -> RowContent) where Content == ForEach<Data, ID, RowContent>, Data : RandomAccessCollection, ID : Hashable, RowContent : View

    우리가 사용할 생성자는 위와 같다. 데이터와 id, selection, rowContent를 인자로 받는다. 마지막 rowContent는 @ViewBuilder로 되어있어서 클로져로 받는 친구들이 모두 child 요소로 들어가게 한다.

    id는 KeyPath<Data.Element, ID> 로 되어있다. iterator를 통해 data로 받은 컨테이너(배열 등)을 하나씩 순회하면서 매칭되는 아이템이 Data.Element에 해당한다. ID는 Hashable을 준수해야 한다.

    KeyPath를 조금 더 자세히 보자

    /// A key path from a specific root type to a specific resulting value type.
    public class KeyPath<Root, Value> : PartialKeyPath<Root> {
    }

    KeyPath는 말 그대로 Root에서 해당 Value로 가는 길을 미리 지정해두는 것이다. 다시 위에 id: KeyPath<Data.Element, ID> 부분을 보면, 앞서 _ data: Data인자로 Data를 가져올 때 제네릭으로 선언된 Data의 타입이 정의된다. Data.Element에는 컨테이너의 한 요소를 가리키게 되고, ID는 Hashable을 준수해야 한다.

    List(landmarks, id: \.id) { landmark in
    
    }

    튜토리얼에 이렇게 나와있는데, 여기 id에는 KeyPath<Landmark, ID> 형을 집어넣어야 한다. 여기서 \ 역슬래쉬 기호를 사용해서 각각의 id요소를 넣어주겠다고 이해할 수 있겠다.

    import SwiftUI
    
    struct LandmarkList: View {
        var body: some View {
            List(landmarks, id: \.id) { landmark in
                LandmarkRow(landmark: landmark)
            }
        }
    }
    
    struct LandmarkList_Previews: PreviewProvider {
        static var previews: some View {
            LandmarkList()
        }
    }

    이렇게 하면 

    이렇게 리스트가 보여지게 된다. 

    여기서 조금 더 개선시키고 있는데, 바로 Landmark 구조체에 Identifiable 속성을 추가하는 것이다.

    Identifiable은 단순하게 Hashable한 id를 가져야 하는 것이다.

    public protocol Identifiable<ID> {
    
        /// A type representing the stable identity of the entity associated with
        /// an instance.
        associatedtype ID : Hashable
    
        /// The stable identity of the entity associated with this instance.
        var id: Self.ID { get }
    }

     

    Identifiable을 적용 하게 되면 List의 init 부분을 다른 방식으로 받을 수 있다.

    @MainActor public init<Data, RowContent>(_ data: Data, selection: Binding<Set<SelectionValue>>?, @ViewBuilder rowContent: @escaping (Data.Element) -> RowContent) where Content == ForEach<Data, Data.Element.ID, RowContent>, Data : RandomAccessCollection, RowContent : View, Data.Element : Identifiable

    끝에 where ... Data.Element : Identifiable 이 보인다. 즉 data의 요소가 각각 Identifiable을 지원하면 이 init을 쓸 수 있다는 것이다. 

    최종 Landmark 구조체는 다음과 같다.

    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
        
        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
        }
    }

    LandmarkList는 다음과 같이 작성한다. (List의 init부분을 다르게 적용한 것을 볼 수 있다.)

    import SwiftUI
    
    struct LandmarkList: View {
        var body: some View {
            List(landmarks) { landmark in
                LandmarkRow(landmark: landmark)
            }
        }
    }
    
    struct LandmarkList_Previews: PreviewProvider {
        static var previews: some View {
            LandmarkList()
        }
        
    }

     

    Section 6. Set Up Navigation Between List and Detail

    View 그룹에 LandmakrDetail.swift 파일을 만든다. 이제 리스트에서 아이템을 선택하면 이 뷰가 뜨도록 만들 것이다.

    전에 ContentView에 썼던 내용 그대로 가져와본다.

    import SwiftUI
    
    struct LandmarkDetail: View {
        var body: some View {
            VStack {
                MapView()
                    .ignoresSafeArea(edges: .top)
                    .frame(height: 300)
                CircleImage()
                    .offset(y: -130)
                    .padding(.bottom, -130)
                VStack(alignment: .leading) {
                    Text("Turtle Rock")
                        .font(.title)
                    HStack {
                        Text("Joshua Tree National Park")
                            .font(.subheadline)
                        Spacer()
                        Text("California")
                            .font(.subheadline)
                    }
                    .font(.subheadline)
                    .foregroundColor(.secondary)
                    
                    Divider()
                    
                    Text("About Turtle Rock")
                        .font(.title2)
                    Text("Descriptive text goes here.")
                }
                .padding()
                
                Spacer()
            }
        }
    }
    
    struct LandmarkDetail_Previews: PreviewProvider {
        static var previews: some View {
            LandmarkDetail()
        }
    }

    기존에 ContentView.swift에는 이 LandmarkList를 보여준다. 적용한 모습은 : 

    import SwiftUI
    
    struct ContentView: View {
        var body: some View {
            LandmarkList()
        }
    }
    
    struct ContentView_Previews: PreviewProvider {
        static var previews: some View {
            ContentView()
        }
    }

    이제 다시 LandmarkList.swift 파일로 이동하자. List를 NavigationView로 감싸고, List에 .navigationTitle 속성을 준다.
    NavigationLink와 label도 준다. 음... 우선 코드 전문이다.

    import SwiftUI
    
    struct LandmarkList: View {
        var body: some View {
            NavigationView {
                List(landmarks) { landmark in
                    NavigationLink {
                        LandmarkDetail()
                    } label: {
                        LandmarkRow(landmark: landmark)
                    }
                }
                .navigationTitle("Landmarks")
            }
        }
    }
    
    struct LandmarkList_Previews: PreviewProvider {
        static var previews: some View {
            LandmarkList()
        }
    }

    먼저 NavigationView가 어떻게 생겼는지 보자.

    public struct NavigationView<Content> : View where Content : View {
    	public init(@ViewBuilder content: () -> Content)
        	public typealias Body = Never
    }

    그렇다. 그냥 Navigation View라는 것을 인식만 시켜주는 것 같다. 핵심 로직은 NavigationLink에 있겠지?

    public struct NavigationLink<Label, Destination> : View where Label : View, Destination : View {
    	public init(@ViewBuilder destination: () -> Destination, @ViewBuilder label: () -> Label)
        	...
    }

    두 개의 ViewBuilder를 필요로 한다. destination은 띄울 뷰를 의미하고, label은 화면에 표시할 뷰를 의미한다.
    즉 간단하게 말하면, label을 누르면 destination으로 넘어간다.

    근데 사실 아까 NavigationView 볼 때 deprecated 써있었던 것을 대강 눈치 챘다. 뭔가 느낌이 쎄해서 애플 공식 문서를 찾아보니 아뿔사!

    대문짝만하게 이제 못쓴다는 걸 어필중이다. 표시된 NavigationStack을 한번 살펴보자.

    NavigationStack {
        List(parks) { park in
            NavigationLink(park.name, value: park)
        }
        .navigationDestination(for: Park.self) { park in
            ParkDetails(park: park)
        }
    }

    이런 식으로 사용하라고 한다. 우선 NavigationStack부터 살펴보자.

    @MainActor public struct NavigationStack<Data, Root> : View where Root : View {
        @MainActor public init(@ViewBuilder root: () -> Root) where Data == NavigationPath
    	...
    }

    Root를 처음 입력받는다. 찾아보니 가장 처음 쌓일 뷰를 의미한다고 하며, 이는 바뀔 수 없다고 한다.
    즉, NavigationStack에서 처음 쌓이는 뷰는 pop할 수 없다.

    또 열심히 찾아보니 NavigationPath라는 것을 이용해서 미리 쌓일 뷰들을 정의할 수도 있다고 한다. 자세한 것은 공식 문서에 나와있다. 이를 잘 활용하면 DeepLink도 간편하게(coordinator 안쓰고) 활용할 수 있겠다.

    다음 parks 나오는 예시에서 사용한 NavigationLink도 살펴보자. 아까는 label 어쩌구 하면서 쓴 것 같은데..

    public init<S, P>(_ title: S, value: P?) where Label == Text, S : StringProtocol, P : Decodable, P : Encodable, P : Hashable

    예시에서 쓴 init은 이거다. title에는 StringProtocol이 오고, P는 Decodable & Encodable & Hashable 을 채택해야 한다.
    간단한 텍스트만 띄워두려고 이걸 쓰는 것 같은데 우리는 커스텀 뷰를 사용한다. 커스텀 뷰를 사용할 수 있는 init은 다음과 같다.

    public init<P>(value: P?, @ViewBuilder label: () -> Label) where P : Decodable, P : Encodable, P : Hashable

    이건 바로 위에 init이랑 순서가 바뀌었다. 아마 label을 클로져 생략 가능하게 만들어준 것 같다.
    이걸로 NavigationLink를 만들면 value를 copy해서 저장하고 label을 띄워준다.
    어라? 그럼 뭘 띄우는지는 어디서 정의하지?
    => 그건 List에 .navigationDestination을 적용하면 된다.

    .navigationDestination을 보면 View의 extension임을 알 수 있고, 자세히 보면,

    public func navigationDestination<D, C>(for data: D.Type, @ViewBuilder destination: @escaping (D) -> C) -> some View where D : Hashable, C : View

    data에 Hashable을 준수하는 타입 자체를 받고, destination으로 @escaping 클로져를 받는다. 여기에는 Hashable을 준수하는 값을 입력받아서 뷰를 리턴하는 클로져가 들어가야 한다.
    즉 정리하면, 타입마다 어떤 뷰를 띄워줄 지 여기서 정의해줄 수 있다는 것이다.

    이제 코드를 NavigationView 말고 NavigationStack으로 바꿔보자.

    import SwiftUI
    
    struct LandmarkList: View {
        var body: some View {
            NavigationStack {
                List(landmarks) { landmark in
                    NavigationLink(value: landmark) {
                        LandmarkRow(landmark: landmark)
                    }
                }
                .navigationDestination(for: Landmark.self) { landmark in
                    LandmarkDetail()
                }
                .navigationTitle("Landmarks")
            }
        }
    }
    
    struct LandmarkList_Previews: PreviewProvider {
        static var previews: some View {
            LandmarkList()
        }
    }

     

    Section 7. Pass Data into Child Views

    아직 detail을 들어가보면 다 똑같은 데이터만 표시되는 것을 볼 수 있다. 이걸 이제 List의 각 요소마다 다른 데이터가 나오도록 바꿀 것이다.

    CircleImage.swift에 가서 내용을 다음과 같이 변경해준다.

    import SwiftUI
    
    struct CircleImage: View {
        var image: Image
        var body: some View {
            image
                .clipShape(Circle())
                .overlay {
                    Circle().stroke(.white, lineWidth: 4)
                }
                .shadow(radius: 7)
        }
    }
    
    struct CircleImage_Previews: PreviewProvider {
        static var previews: some View {
            CircleImage(image: Image("mosu"))
        }
    }

    여기까지만 하면 빌드 오류가 나서 프리뷰가 잘 안보인다. (DetailView에서도 인자를 넣어주어야 한다) 걱정말고 쭉 따라해보자.

    다음은 MapView.swift로 가서 다음 내용을 채우자.

    import SwiftUI
    import MapKit
    
    struct MapView: View {
        var coordinate: CLLocationCoordinate2D
        @State private var region = MKCoordinateRegion()
    
        var body: some View {
            Map(coordinateRegion: $region)
                .onAppear() {
                    setRegion(coordinate)
                }
        }
        
        private func setRegion(_ coordinate: CLLocationCoordinate2D) {
            region = MKCoordinateRegion(
                center: coordinate,
                span: MKCoordinateSpan(latitudeDelta: 0.2, longitudeDelta: 0.2)
            )
        }
    }
    
    struct MapView_Previews: PreviewProvider {
        static var previews: some View {
            MapView(coordinate: CLLocationCoordinate2D(latitude: 34.011_286, longitude: -116.166_868))
        }
    }

    다음은 LandmarkList.swift이다.

    import SwiftUI
    
    struct LandmarkList: View {
        var body: some View {
            NavigationStack {
                List(landmarks) { 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()
        }
    }

    다음은 LandmarkDetail.swift다.

    import SwiftUI
    
    struct LandmarkDetail: View {
        var landmark: Landmark
    
        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) {
                    Text(landmark.name)
                        .font(.title)
                    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 var previews: some View {
            LandmarkDetail(landmark: landmarks[0])
        }
    }

    LandmarkDetail에서 중간에 VStack을 ScrollView로 바꿨다. ScrollView에 대해서 한번 짚고 넘어가자.

    public struct ScrollView<Content> : View where Content : View {
    
        /// The scroll view's content.
        public var content: Content
    
        /// The scrollable axes of the scroll view.
        ///
        /// The default value is ``Axis/vertical``.
        public var axes: Axis.Set
    
        /// A value that indicates whether the scroll view displays the scrollable
        /// component of the content offset, in a way that's suitable for the
        /// platform.
        ///
        /// The default is `true`.
        public var showsIndicators: Bool
    
        /// Creates a new instance that's scrollable in the direction of the given
        /// axis and can show indicators while scrolling.
        ///
        /// - Parameters:
        ///   - axes: The scroll view's scrollable axis. The default axis is the
        ///     vertical axis.
        ///   - showsIndicators: A Boolean value that indicates whether the scroll
        ///     view displays the scrollable component of the content offset, in a way
        ///     suitable for the platform. The default value for this parameter is
        ///     `true`.
        ///   - content: The view builder that creates the scrollable view.
        public init(_ axes: Axis.Set = .vertical, showsIndicators: Bool = true, @ViewBuilder content: () -> Content)
    
        /// The content and behavior of the scroll view.
        @MainActor public var body: some View { get }
    
        /// The type of view representing the body of this view.
        ///
        /// When you create a custom view, Swift infers this type from your
        /// implementation of the required ``View/body-swift.property`` property.
        public typealias Body = some View
    }

    init에서 우리는 맨 마지막 클로져만 사용했지만, 방향과 indecator띄워주는 여부도 같이 설정할 수 있다. 적용만 시켜주면 바로 scroll할 수 있도록 설정할 수 있는게 신기하다.

    Section 8. Generate Previews Dynamically

    Preview에 대해 상세한 내용이 등장한다. 들어가기에 앞서 Xcode의 scheme 메뉴에 어떤 폰들이 있는지 확인해보자.

    지금부터는 iOS Simulators에 나온 저 기기들만 인자로 사용해야 한다는 점을 유의하자

    LandmarkList로 가서 프리뷰쪽 내용만 다음과 같이 바꿔보자.

    struct LandmarkList_Previews: PreviewProvider {
        static var previews: some View {
            LandmarkList()
                .previewDevice(PreviewDevice(rawValue: "iPhone SE (3rd generation)"))
        }
    }

    튜토리얼에는 2nd generation이라고 되어있지만 내 스킴에는 3rd가 있어서 이걸로 적용했다.

    프리뷰가 SE에서 뜨는 모습을 볼 수 있다.

    다양한 디바이스들을 띄울 수 있다.

    struct LandmarkList_Previews: PreviewProvider {
        static var previews: some View {
            ForEach(["iPhone SE (3rd generation)", "iPhone 14 Plus", "iPad Pro (12.9-inch) (6th generation)"], id: \.self) { deviceName in
                LandmarkList()
                    .previewDevice(PreviewDevice(rawValue: deviceName))
                    .previewDisplayName(deviceName)
            }
            
        }
    }

    이렇게 하면?

    위에 보이듯 SE, 14 Plus, iPad Pro 12.9인치 모델들로 프리뷰를 싹 띄울 수 있다.

     

    여기까지 결과물

     

    Section 9. Check Your Understanding

    정답은 순서대로 2 3 1 2

    반응형

    댓글

Designed by Tistory.