-
[SwiftUI] 2. Building Lists and NavigationiOS 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
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
반응형'iOS Dev > SwiftUI' 카테고리의 다른 글
[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 [SwiftUI] 3. Handling User Input (0) 2023.03.30 [SwiftUI] 1. Creating and Combining Views (0) 2023.03.20