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

Yang Si Yeon·2021년 2월 23일
0

SwiftUI

목록 보기
2/5

SwiftUI Essentials 챕터의 2번째 과정, 리스트와 네비게이션을 다뤄보자!
공식문서 링크는 여기 클릭 !

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

이번 과정에서는 json 파일, 이미지 등 필요한 리소스가 많다. 이 친구들도 모두 Github Repository의 Resource 폴더 안에서 다운받을 수 있다.
(해당 리소스들은 모두 애플의 공식문서에서도 다운로드 받을 수 있다.)

Building Lists and Navigation

Landmark 세부 정보 화면을 만들었으니, 이제는 사용자가 landmark의 전체 목록을 보고 각 landmark에 대한 세부 정보를 볼 수 있도록 해야한다.
이번 과정에서는 landmark에 대한 정보를 보여줄 수 있는 view를 만들고 사용자가 이를 클릭하면 세부 정보 화면으로 넘어가는 scrolling list를 만들어보자.

Section 1. Landmark 모델 만들기

첫번째 튜토리얼에서는 custom view에 정보를 하드 코딩에 놓았다. 이제 view로 데이터를 전달하기 위해 model을 만들어보자 .

  1. landmarkData.json을 프로젝트에 포함시킨다.

  2. File > New > File에서 Swift File을 클릭하고 Landmark.swift 파일을 생성한다.

  3. landmarkData.json 파일의 key 값들에 맞게 Landmark의 프로퍼티를 정의한다. 뒤 Section에서 진행될 'json 파일에서 데이터를 가져오기'를 위해 Codable 프로토콜을 채택하자.

import Foundation

struct Landmark: Hashable, Codable {
    var id: Int
    var name: String
    var park: String
    var state: String
    var description: String
}
  1. 이미지 파일들을 프로젝트의 asset 카탈로그에 추가한다.

  2. data에서 이미지의 이름을 String으로 받기 때문에 imageName 프로퍼티는 String 타입으로 추가한다. 이후 asset 카탈로그에서 imageName으로 이미지를 가져와서 Image를 return 해주자. Image 타입을 사용하기 위해 SwiftUI 라이브러리도 import 시키자!
    실제 프로젝트내에서 사용하는 것은 image이고, imageName은 해당 struct 안에서만 쓰이기 때문에 imageName은 private으로 만들어주면 좋다.

import Foundation
import SwiftUI

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 {
        Image(imageName)
    }
}
  1. json 데이터 파일의 coordinates 데이터를 받기 위해 Coordinates struct를 만들고, 이후 MapKit 프레임워크에서 쓰일 locationCoordinate 프로퍼티를 coordinates를 이용해 만들자. 위의 예시처럼 실제 프로젝트 내에서 쓰이는 프로퍼티는 locationCoordinate이고 coordinates 프로퍼티는 해당 struct에서만 쓰이므로 private으로 !
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 {
        Image(imageName)
    }
    
    private var coordinates: Coordinates
    var locationCoordinate: CLLocationCoordinate2D {
        CLLocationCoordinate2D(
            latitude: coordinates.latitude,
            longitude: coordinates.longitude
        )
    }
    struct Coordinates: Hashable, Codable {
        var longitude: Double
        var latitude: Double
    }
}
  1. json 파일에서 Landmark들이 들어있는 리스트를 받아오기 위해 ModelData.swift 파일을 생성해주고, 앱의 main 번들에서 JSON 데이터를 가져오는 load(_:) 메서드를 만들어주자.
import Foundation

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 load \(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)")
    }
}
  1. ModelData.swift 안에 landmarkData.json의 값을 저장하는 배열을 만들자.
var landmarks: [Landmark] = load("landmarkData.json")
  1. 프로젝트를 좀 더 편하게 관리하기 위해 아래와 같이 Group을 나누어 주자.

Section 2. Row View 만들기

각 landmark에 대한 세부 정보를 보여주고, 리스트를 이루는 row(행)을 만들자.

  1. Views 그룹안에 LandmarkRow.swift 이름으로 SwiftUI View를 추가한다.

  2. LandmarkRow의 프로퍼티로 Landmark 타입의 landmark를 만들어준다.

  3. LandmarkRow_Previews의 static 프로퍼티안에서 LandmarkRow 생성자에 landmark 파라미터를 추가하고, landmarks 배열의 첫번째 요소를 전달하자.

import SwiftUI

struct LandmarkRow: View {
    var landmark: Landmark
    var body: some View {
        Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
    }
}

struct LandmarkRow_Previews: PreviewProvider {
    static var previews: some View {
        LandmarkRow(landmark: landmarks[0])
    }
}
  1. text view를 HStack안에 넣고, text view의 내용을 landmark의 이름으로 바꿔준다.
struct LandmarkRow: View {
    var landmark: Landmark
    var body: some View {
        HStack {
            Text(landmark.name)
        }
    }
}
  1. text view 앞에 image를 넣고 text view 뒤에 spacer를 넣어서 좌측으로 정렬시키자.
struct LandmarkRow: View {
    var landmark: Landmark
    var body: some View {
        HStack {
            landmark.image
                .resizable()
                .frame(width: 50, height: 50)
            Text(landmark.name)
            Spacer()
        }
    }
}

Section 3. Row 프리뷰 커스터마이징하기

  1. LandmarkRow_Preview에서 landmark parameter로 landmarks 배열의 두번째 요소를 넘겨주면 프리뷰에 바로 적용된다.

  2. previewLayout(_:) modifier를 사용해서 리스트의 row와 비슷한 크기로 설정할 수 있다.

  3. Group을 사용하면 PreviewProvider가 여러개의 프리뷰를 반환한다.

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 4. Landmarks 리스트 만들기

SwiftUI의 List 타입을 이용하면 화면이 보여지는 플랫폼에 알맞은 생김새로 list를 보여줄 수 있다. list의 요소들은 우리가 이제까지 이용했던 stack의 view들 처럼 static할 수도 있고, 동적으로 만들어질 수도 있다. 그리고 두개를 섞어서 쓰는 것도 가능하다.

  1. Views 그룹안에 LandmarkList.swift SwiftUI View 파일을 생성하자.

  2. text view를 List로 바꾸고 List의 자식 view로 landmarks의 2개 인스턴스를 추가하자.

    프리뷰에는 2개의 landmark가 iOS에 적합한 목록 스타일로 랜더링되어 나타난다

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. 리스트를 동적으로 만들기

위 section 처럼 리스트의 요소를 하나하나 써주지 않고, 동적으로 row 들을 생성해보자.

  1. LandmarkList.swift에서 2개의 정적인 landmark row를 지우고, model data의 landmarks 배열을 List 생성자에 전달하자.

    List는 식별할 수 있는 데이터를 필요로한다. 따라서 데이터를 식별할 수 있는 프로퍼티를 함께 전달하거나, 데이터 타입이 Identifiable 프로토콜을 채택해야한다.

  2. 클로저에서 LandmarkRow를 반환하자.

struct LandmarkList: View {
    var body: some View {
        List(landmarks, id: \.id) { landmark in
            LandmarkRow(landmark: landmark)
        }
    }
}
  1. 앞서 말했듯이 Landmark 타입이 Identifiable 프로토콜을 채택하면 List에 id를 전달할 필요가 없기 때문에 코드를 깔끔하게 만들 수 있다. Landmark 타입이 Identifiable 프로토콜을 채택하게 하자. Landmark 타입은 이미 Identifiable 프로토콜에서 요구하는 id 프로퍼티를 가지고 있기 때문에 데이터를 읽을 때 decode 할 프로퍼티만 추가하면 된다.
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 {
        Image(imageName)
    }
    
    private var coordinates: Coordinates
    var locationCoordinate: CLLocationCoordinate2D {
        CLLocationCoordinate2D(
            latitude: coordinates.latitude,
            longitude: coordinates.longitude
        )
    }
    struct Coordinates: Hashable, Codable {
        var longitude: Double
        var latitude: Double
    }
}
  1. LandmarkList에서 id 삭제!
struct LandmarkList: View {
    var body: some View {
        List(landmarks) { landmark in
            LandmarkRow(landmark: landmark)
        }
    }
}

Section 6. List와 Detail 사이에 navigation 설정하기

리스트 중 한개의 요소를 클릭했을 때 상세 화면을 볼 수 있도록 NavigationView를 이용해보자.

  1. LandmarkDetail.swift SwiftUI View를 생성하고, 전에 만들었던 ContentView의 body 내용을 붙여넣자.

  2. ContentView에서 LandmarkList를 만들어주자.

struct ContentView: View {
    var body: some View {
        LandmarkList()
    }
}
  1. LandmarkList.swift에서 List를 NavigationView 안에 넣자.
struct LandmarkList: View {
    var body: some View {
        NavigationView {
            List(landmarks) { landmark in
                LandmarkRow(landmark: landmark)
            }
        }
    }
}
  1. 리스트 navigation bar의 title을 지정하기 위해 navigationTitle(_:) modifier 메서드를 호출한다.
struct LandmarkList: View {
    var body: some View {
        NavigationView {
            List(landmarks) { landmark in
                LandmarkRow(landmark: landmark)
            }
            .navigationTitle("Landmarks")
        }
    }
}
  1. 리스트의 클로저안에서 row를 NavigationLink로 감싸고 destination을 LandmarkDetail로 지정하자. 이후 live 모드에서 리스트의 요소 한개를 누르면 상세 페이지로 넘어간다.
struct LandmarkList: View {
    var body: some View {
        NavigationView {
            List(landmarks) { landmark in
                NavigationLink(destination: LandmarkDetail()) {
                    LandmarkRow(landmark: landmark)
                }
            }
            .navigationTitle("Landmarks")
        }
    }
}

Section 7. 자식 View로 데이터 전달하기

LandmarkDetail view는 여전히 하드코딩된 세부정보를 보여준다. 따라서 LandmarkRow 처럼 LandmarkDetail 타입이 landmark 프로퍼티를 가지고있고 이를 뷰에 띄워줘야한다.

  1. CircleImage에 image 프로퍼티를 추가해주고, preview provider안에 있는 CircleImage 생성자에 임의의 이미지를 전달해주자.
import SwiftUI

struct CircleImage: View {
    var image: Image
    var body: some View {
        image
            .clipShape(Circle())
            .overlay(Circle().stroke(Color.white, lineWidth: 4))
            .shadow(radius: 7)
    }
}

struct CircleImage_Previews: PreviewProvider {
    static var previews: some View {
        CircleImage(image: Image("turtlerock"))
    }
}
  1. MapView에 coordinate 프로퍼티를 추가해주고, preview provider안에 있는 MapView 생성자에 임의의 좌표를 전달해주자. 그리고 좌표 값을 기반으로 영역을 업데이트하는 메서드를 추가하자. 마지막으로 지도에 onAppear modifier를 추가해서 Map이 사용자에게 보일 때, 현재 좌표를 기반으로 Map을 구성하도록 한다.
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
            )
        )
    }
}
  1. LandmarkDetail.swift에 landmark 프로퍼티를 추가하고, preview provider의 LandmarkDetail 생성자에 landmarks 배열의 첫번째 요소를 넘겨 준다.

  2. LanmarkList.swift에서 현재 landmark를 LandmarkDetail 생성자로 넘겨준다.

  3. 다시 LandmarkDetail.swift로 돌아와서 MapView, CircleImage, TextView에 필요한 데이터들을 landmark에서 뽑아서 전달한다. 이후 VStack을 ScrollView로 바꿔서 사용자가 스크롤을해서 설명을 볼 수 있도록 해주자.


struct LandmarkDetail: View {
    var landmark: Landmark
    var body: some View {
        VStack {
            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)
                      
                    Spacer()
                    Text(landmark.state)
                }
                .font(.subheadline)
                .foregroundColor(.secondary)
                
                Divider()
                
                Text("About \(landmark.name)")
                    .font(.title2)
                Text(landmark.description)
            }
            .padding()
            
            Spacer()
        }
    }
}
  1. 마지막으로 navigationTitle(:) modifier로 navigation bar의 title을 설정하고, navigationBarTitleDisplayMode(:) modifier로 mode를 inline으로 설정한다.
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)
                      
                    Spacer()
                    Text(landmark.state)
                }
                .font(.subheadline)
                .foregroundColor(.secondary)
                
                Divider()
                
                Text("About \(landmark.name)")
                    .font(.title2)
                Text(landmark.description)
            }
            .padding()
        }
        .navigationTitle(landmark.name)
        .navigationBarTitleDisplayMode(.inline)
    }
}

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

profile
가장 젊은 지금, 내가 성장하는 데에 쓰자

0개의 댓글