SwiftUI Essentials 챕터의 2번째 과정, 리스트와 네비게이션을 다뤄보자!
공식문서 링크는 여기 클릭 !
이번에도 프로젝트는 Github에 업로드 해놓았다. 🐱
이번 과정에서는 json 파일, 이미지 등 필요한 리소스가 많다. 이 친구들도 모두 Github Repository의 Resource 폴더 안에서 다운받을 수 있다.
(해당 리소스들은 모두 애플의 공식문서에서도 다운로드 받을 수 있다.)
Landmark 세부 정보 화면을 만들었으니, 이제는 사용자가 landmark의 전체 목록을 보고 각 landmark에 대한 세부 정보를 볼 수 있도록 해야한다.
이번 과정에서는 landmark에 대한 정보를 보여줄 수 있는 view를 만들고 사용자가 이를 클릭하면 세부 정보 화면으로 넘어가는 scrolling list를 만들어보자.
첫번째 튜토리얼에서는 custom view에 정보를 하드 코딩에 놓았다. 이제 view로 데이터를 전달하기 위해 model을 만들어보자 .
landmarkData.json을 프로젝트에 포함시킨다.
File > New > File에서 Swift File을 클릭하고 Landmark.swift 파일을 생성한다.
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
}
이미지 파일들을 프로젝트의 asset 카탈로그에 추가한다.
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)
}
}
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
}
}
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)")
}
}
var landmarks: [Landmark] = load("landmarkData.json")
각 landmark에 대한 세부 정보를 보여주고, 리스트를 이루는 row(행)을 만들자.
Views 그룹안에 LandmarkRow.swift 이름으로 SwiftUI View를 추가한다.
LandmarkRow의 프로퍼티로 Landmark 타입의 landmark를 만들어준다.
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])
}
}
struct LandmarkRow: View {
var landmark: Landmark
var body: some View {
HStack {
Text(landmark.name)
}
}
}
struct LandmarkRow: View {
var landmark: Landmark
var body: some View {
HStack {
landmark.image
.resizable()
.frame(width: 50, height: 50)
Text(landmark.name)
Spacer()
}
}
}
LandmarkRow_Preview에서 landmark parameter로 landmarks 배열의 두번째 요소를 넘겨주면 프리뷰에 바로 적용된다.
previewLayout(_:) modifier를 사용해서 리스트의 row와 비슷한 크기로 설정할 수 있다.
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))
}
}
SwiftUI의 List 타입을 이용하면 화면이 보여지는 플랫폼에 알맞은 생김새로 list를 보여줄 수 있다. list의 요소들은 우리가 이제까지 이용했던 stack의 view들 처럼 static할 수도 있고, 동적으로 만들어질 수도 있다. 그리고 두개를 섞어서 쓰는 것도 가능하다.
Views 그룹안에 LandmarkList.swift SwiftUI View 파일을 생성하자.
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 처럼 리스트의 요소를 하나하나 써주지 않고, 동적으로 row 들을 생성해보자.
LandmarkList.swift에서 2개의 정적인 landmark row를 지우고, model data의 landmarks 배열을 List 생성자에 전달하자.
List는 식별할 수 있는 데이터를 필요로한다. 따라서 데이터를 식별할 수 있는 프로퍼티를 함께 전달하거나, 데이터 타입이 Identifiable 프로토콜을 채택해야한다.
클로저에서 LandmarkRow를 반환하자.
struct LandmarkList: View {
var body: some View {
List(landmarks, id: \.id) { landmark in
LandmarkRow(landmark: 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 {
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
}
}
struct LandmarkList: View {
var body: some View {
List(landmarks) { landmark in
LandmarkRow(landmark: landmark)
}
}
}
리스트 중 한개의 요소를 클릭했을 때 상세 화면을 볼 수 있도록 NavigationView를 이용해보자.
LandmarkDetail.swift SwiftUI View를 생성하고, 전에 만들었던 ContentView의 body 내용을 붙여넣자.
ContentView에서 LandmarkList를 만들어주자.
struct ContentView: View {
var body: some View {
LandmarkList()
}
}
struct LandmarkList: View {
var body: some View {
NavigationView {
List(landmarks) { landmark in
LandmarkRow(landmark: landmark)
}
}
}
}
struct LandmarkList: View {
var body: some View {
NavigationView {
List(landmarks) { landmark in
LandmarkRow(landmark: landmark)
}
.navigationTitle("Landmarks")
}
}
}
struct LandmarkList: View {
var body: some View {
NavigationView {
List(landmarks) { landmark in
NavigationLink(destination: LandmarkDetail()) {
LandmarkRow(landmark: landmark)
}
}
.navigationTitle("Landmarks")
}
}
}
LandmarkDetail view는 여전히 하드코딩된 세부정보를 보여준다. 따라서 LandmarkRow 처럼 LandmarkDetail 타입이 landmark 프로퍼티를 가지고있고 이를 뷰에 띄워줘야한다.
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"))
}
}
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
)
)
}
}
LandmarkDetail.swift에 landmark 프로퍼티를 추가하고, preview provider의 LandmarkDetail 생성자에 landmarks 배열의 첫번째 요소를 넘겨 준다.
LanmarkList.swift에서 현재 landmark를 LandmarkDetail 생성자로 넘겨준다.
다시 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()
}
}
}
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