티스토리 뷰

SwiftUI 기초 - 공식 튜토리얼 정리(1)에서 이어지는 내용입니다. 

SwiftUI 두 번째 튜토리얼입니다.

첫 번째 튜토리얼에서는 사용자에게 보여줄 정보를 하드 코딩했다면 이번 튜토리얼에서는 데이터를 저장하는 모델을 만들어서 사용해 볼 겁니다. 

먼저 developer.apple.com/tutorials/swiftui/building-lists-and-navigation 이곳에 들어가서 프로젝트 파일을 다운로드해주세요.

만약 영문으로 튜토리얼을 진행하고 싶으시다면 해당 링크에서 쭉 진행해 주셔도 무방합니다. 

다운로드한 프로젝트 Resources 폴더의 landmarkData.json 파일을 우리가 튜토리얼 1에서 만들었던 프로젝트에 드래그 앤 드랍합니다. 

그다음 Landmark라는 이름의 Swift 파일 하나를 생성하고 다음과 같이 Landmark 모델 하나를 만들어 줍니다.

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
    
    // 이 모델을 사용할 사용자는 image에만 관심 있으므로 imageName은 Private으로 설정
    private var imageName: String
    
    // 이미지 이름으로부터 이미지를 로드하는 연산 프로퍼티
    var image: Image {
        Image(imageName)
    }
    
    // 역시 locationCoordinate 연산 프로퍼티를 위해서만 사용되기 때문에, 직접 접근할 일이 없으므로 private으로 설정
    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라는 이름의 Swift 파일을 하나 더 만들어 준 후, JSON 데이터를 가져오는 load (_ :) 메서드와 해당 데이터를 담을 landmarks를 정의합니다.

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

 

다음과 같이 파일을 그룹화하여 성장하는 프로젝트를 보다 쉽게 ​​관리 하라구 안내가 되어있으니, 우리도 똑같이 폴더링 해줍시다!

 

이번에는 랜드마크에 대한 디테일을 표시할 Row View를 만들어 보겠습니다. 이 Row View는 나중에 여러 행으로 결합해 랜드마크 목록으로 만들 거에요!

 

먼저 Views 폴더 안에 LandmarkRow라는 이름의 SwiftUI파일을 하나 생성! 그 다음, 다음과 같이 작성해 주세요. 

import SwiftUI

struct LandmarkRow: View {
    var landmark: Landmark // 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]) // LandmarkRow 뷰 init
    }
}

HStack, modifiers, Spacer 모두 앞서 tutorial1에서 배운 내용이기에 별도로 설명하지 않겠습니다!

그럼 다음과 같이 귀여운 row (행) 하나가 만들어집니다.

 

만약 LandmarkRow(landmark: landmarks[0]) 에서 데이터를 landmarks[0] 대신 landmarks[1]을 주게 되면 자동으로 preview가 다른 데이터 정보로 변하는 것을 확인해 보실 수 있습니다. 

참고로 우리가 보는 Canvas의 Preview의 경우 다양하게 활용할 수 있습니다. 

먼저 다음과 같이 preveiwLayout modifier를 통해 프리뷰 화면을 조정할 수도 있고 

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

 

Group을 사용하여 여러 개의 Preview를 보여줄 수도 있습니다. 참고로 Group은 뷰 contents를 그룹핑하는 컨테이너 역할을 합니다.

struct LandmarkRow_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            LandmarkRow(landmark: landmarks[0])
                .previewLayout(.fixed(width: 300, height: 70))
            LandmarkRow(landmark: landmarks[1])
                .previewLayout(.fixed(width: 300, height: 70))
        }
    }
}
// 위와 같은 코드, but 다른 표현
struct LandmarkRow_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            LandmarkRow(landmark: landmarks[0])
            LandmarkRow(landmark: landmarks[1])
        }
        .previewLayout(.fixed(width: 300, height: 70))
    }
}

 

다시! 본론으로 돌아와서 우리가 아까 만든 row View를 사용해 list(목록)을 만들어 볼 건데요.

그러기 위해서 LandmarkList 이름의 SwiftUI 파일 하나 생성합니다.

그리고 다음과 같이 List{} 로 아까 우리가 만든 LandmarkRow 뷰를 감싸주게 되면 기존 테이블 뷰와 같은 형태의 뷰를 볼 수 있습니다. 

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

(참고로 프리뷰 화면이 안 보일 때는 resume 버튼을 눌러주세요~)

Instead of specifying a list’s elements individually, you can generate rows directly from a collection. You can create a list that displays the elements of collection by passing your collection of data and a closure that provides a view for each element in the collection. The list transforms each element in the collection into a child view by using the supplied closure.

list의 요소를 개별적으로 지정하는 대신 컬렉션으로부터 직접 행을 생성할 수 있습니다.  데이터 컬렉션과 컬렉션의 각 요소에 대한 view를 제공하는 클로저를 전달하여 컬렉션 요소를 표시하는 list를 만들 수 있습니다. 이 list는 제공된 클로저를 사용하여 컬렉션의 각 요소를 child view로 변환합니다.

무슨 말인가 하면, 다음과 같이 구현 가능하다는 말입니다. 우리가 LandmarkRow(landmark: landmarks[0]), LandmarkRow(landmark: landmarks[1]), LandmarkRow(landmark: landmarks[2]).... 이런 식으로 구현 안 해도 된다는 말 

struct LandmarkList: View {
    var body: some View {
        List(landmarks) { landmark in
            LandmarkRow(landmark: landmark)
        }
    }
}

 

이제 다시 Landmark 모델로 돌아와서 Identifiable 프로토콜을 추가해줄 건데요. Identifiable 프로토콜은 다들 생소하시죠?

이 identifiable 프로토콜은 Swift5.1부터 추가되었는데요. 다음과 같이 생겼습니다.

protocol Identifiable {
    associatedtype ID: Hashable
    var id: ID { get }
}

Hashable을 준수하는 연관 타입(associatedtype) ID가 있고 이 ID를 타입으로 하는 id가 있는 단순한 구조로, Identifiable 프로토콜의 목적은 이 프로토콜을 채택함으로써 Hashable을 준수하는 id(식별자)를 구현하도록 하는 데 있습니다. 

Landmark 모델에는 id 프로퍼티가 이미 구현되어 있고 타입이 Int이기 때문에 우리는 Identifiable 프로토콜 채택만 해주면 됩니다.

(참고로 Int, String, float 등등 표준 라이브러리의 많은 타입들의 경우 Hashable을 준수합니다.)

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 latitude: Double
        var longitude: Double
    }
}

 

이제 각 행을 눌렀을 때 튜토리얼 1에서 만든 공원 상세정보 뷰를 보여줄 겁니다.

detail 정보를 보여주기 위한 LandmarkDetail.swift 파일을 하나 만들어 줍니다. (SwiftUI파일)

그다음 기존 튜토리얼 1에서 작성했던 ContentView 내용(하단 코드 참고)을 카피한 후 LandmarkDetail body 프로퍼티에 넣어주세요. 그럼 다음과 같이 되겠죠? 

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)
                    .foregroundColor(.primary)

                HStack {
                    Text("Joshua Tree National Park")
                    Spacer()
                    Text("California")
                }
                .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는 다음과 같이 바꿔줍니다. 이제 LandmarkList()를 보여줄꺼니까요. 

import SwiftUI

struct ContentView: View {
    var body: some View {
        LandmarkList()
    }
}

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

 

다시 LandmarkList로 돌아와서!

각 행을 눌렀을 때 LandmarkDetail뷰로 화면 전환할 건데, push 방식으로 전환하려면 기존에는 NavagationController를 embeded 하고 있어야 했습니다. SwiftUI에서도 역시 마찬가지로 다음과 같이 NavigationView를 embed 할 수 있고 modifier를 통해 타이틀 등 여러 가지 설정을 할 수 있습니다. 

import SwiftUI

struct LandmarkList: View {
    var body: some View {
        NavigationView {
            List(landmarks) { landmark in
                LandmarkRow(landmark: landmark)
            }
            .navigationTitle("Landmarks")
        }
    }
}

struct LandmarkList_Previews: PreviewProvider {
    static var previews: some View {
        LandmarkList()
    }
}

 

push는 다음과 같이 NavigationLink를 사용함으로써 가능합니다. NavigationLink(destination: 목적지 뷰) { row View }

import SwiftUI

struct LandmarkList: View {
    var body: some View {
        NavigationView {
            List(landmarks) { landmark in
                NavigationLink(destination: LandmarkDetail()) {
                    LandmarkRow(landmark: landmark)
                }
            }
            .navigationTitle("Landmarks")
        }
    }
}

struct LandmarkList_Previews: PreviewProvider {
    static var previews: some View {
        LandmarkList()
    }
}

Preview를 Live mode로 바꾸면 굳이 빌드 안 하고도 실제 앱을 동작시켜보는 것도 가능합니다. Live mode로 바꾼 후 행을 클릭해서 detail 뷰로 제대로 화면 전환이 되는지 확인해보세요. 

이제 detail view로 데이터를 넘겨주고 해당 데이터를 detail view에서 띄워주는 작업이 필요합니다.

먼저! 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"))
    }
}

 

그다음 MapView 역시 다음과 같이 수정합니다.

import SwiftUI
import MapKit

struct MapView: View {
    var coordinate: CLLocationCoordinate2D
    @State private var region = MKCoordinateRegion()

    var body: some View {
        Map(coordinateRegion: $region)
            .onAppear {
            	// 뷰가 보일때 setRegion 메서드 실행
                setRegion(coordinate)
            }
    }
	
    // mapView를 해당 좌표로 업데이트 하기 위한 메서드, 외부에서 접근할 일 없기때문에 private
    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))
    }
}

 

developer.apple.com/documentation/swiftui/view/onappear(perform:)

 

Apple Developer Documentation

 

developer.apple.com

Map의 onAppear(perform:)에 대한 설명은 다음과 같이 나와있습니다.

 Adds an action to perform when this view appears.

즉 뷰가 보일 때 매번 실행되는 메서드로, 뷰컨트롤러에서 ViewDidAppear()와 비슷한 역할을 하는 메서드라고 생각하면 될 것 같습니다. 

(참고로 onDissapear(perform:)도 있습니다 ㅋ developer.apple.com/documentation/swiftui/text/ondisappear(perform:))

LandmarkDetail에 역시 Landmark 프로퍼티를 하나 추가해주고,

struct LandmarkDetail: View {
    var landmark: Landmark
    ...
    ...
  
}

 

LandmarkList으로 가서  LandmarkDetail에 landmark 프로퍼티가 생겼으니, 다음과 같이 이니셜라이저를 변경해줍니다. 

import SwiftUI

struct LandmarkList: View {
    var body: some View {
        NavigationView {
            List(landmarks) { landmark in
            	// 변경되는 부분
                NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
                    LandmarkRow(landmark: landmark) 
                }
            }
            .navigationTitle("Landmarks")
        }
    }
}

struct LandmarkList_Previews: PreviewProvider {
    static var previews: some View {
        LandmarkList()
    }
}

 

이제 마지막으로 LandmarkDetail로 돌아가서, 데이터를 바인딩 해주기 위해 다음과 같이 코드를 변경해주시면, 완성이 되게 됩니다 ㅎㅎ

import SwiftUI

struct LandmarkDetail: View {
    var landmark: Landmark

    var body: some View {
    	// 사용자가 스크롤 가능하도록 VStack -> ScrollView로 변경
        // MapView, CircleImage, Text 등등.. 데이터 넘겨주기
        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)
                    .foregroundColor(.primary)

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

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

 

SwiftUI List에 대해 자세히 공부해보고 싶으신 분들은 developer.apple.com/documentation/swiftui/list 해당 문서 보시면서 따라 해 보시면 좋을 것 같습니다.

댓글
링크
최근에 올라온 글
최근에 달린 댓글