SwiftUI Essential의 두 번째 단락인 Building Lists and Navigation이다.
< Section 1. 샘플 데이터를 파악하기 >
첫 튜토리얼에서는 정보에 대한 내용을 다루었었다. 이제는 그 데이터를 디스플레이를 위한 custom views에 보내볼 것이다.
튜토리얼을 따라하되, 내용과 위치는 다르게 하겠다. 세종대 맛집 위치와 정보를 적어서 리스트를 구성해보겠다!
Models 폴더를 만들어서 그 안에 Landmark.swift 파일을 생성해준다. 이 파일은 앱에 띄우기 위한 Landmark(나는 음식점으로!)의 구조를 선언한다. 그리고 landmarkData.json으로부터 음식점 데이터의 배열을 입력할 것이다.
import SwiftUI
import CoreLocation
// Hashable : 정수 Hash 값을 제공하는 타입
// Codable : 자신을 변환하거나 외부표현(json)으로 변환할 수 있는 타입
// - Decodable : 자신 -> 외부 디코딩
// - Encodable : 외부 -> 자신 인코딩
struct Landmark: Hashable, Codable {
var id: Int
var name: String
fileprivate var imageName: String
fileprivate var coordinates: Coordinates
var address: String
var score: Double
var category: Category
var locationCoordinate : CLLocationCoordinate2D {
CLLocationCoordinate2D(latitude: coordinates.latitude,
longitude: coordinates.longitude)
}
enum Category: String,CaseIterable,Codable,Hashable {
case featured = "Featured"
case lakes = "Lakes"
case rivers = "Rivers"
}
}
extension Landmark {
var image: Image {
ImageStore.shared.image(name: imageName)
}
}
struct Coordinates: Hashable, Codable {
var latitude: Double
var longitude: Double
}
Landmark.json은 직접 세종대 주변 맛집,카페들의 데이터를 10개 입력했다.
[
{
"name": "빠오즈푸"
"category": "딤섬 전문 레스토랑"
"city": "서울특별시"
"address": "광진구 군자동 광나루로 373"
"id": 1
"score": 4.4
"coordinates": {
"latitude": 37.548298,
"longitude": 127.071483
}
},
{
"name": "화양식당"
"category": "음식점"
"city": "서울특별시"
"address": "광진구 화양동 16-84"
"id": 2
"score": 4.3
"coordinates": {
"latitude": 37.546597,
"longitude":127.069971
}
},
...
그리고 기존의 Data.swift는 가져왔다.
< Section 2. Row View 생성하기 >
랜드마크의 세부정보를 보여주는 row를 봤었다. row view는 랜드마크에 대한 정보를 담고 있으며 나중에 여러 rows를 랜드마크 리스트와 합칠 것이다.
먼저 새로운 SwiftUI view를 생성하자. 이름은 LandmarkRow.swift이다.
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()
}
}
초기화 동안에 LandmarkRow 타입은 landmark 인스턴스를 필요로 한다.
LandmarkRow_Previews에 landmark 파라미터를 추가하여 LandmarkRow 초기화를 한다. 배열의 첫 요소를 고른다
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: lanmarkData[0])
}
}
기존의 Text에 HStack을 추가하고 landmark.name으로 바꿔준다.
import SwiftUI
struct LandmarkRow: View {
var landmark: Landmark
var body: some View {
HStack {
Text(landmark.name)
}
}
}
struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
LandmarkRow(Landmark: lanmarkData[0])
}
}
텍스트 뷰 이전에 이미지를 추가하여 row를 완성시킨다.
import SwiftUI
struct LandmarkRow: View {
var landmark: Landmark
var body: some View {
HStack {
landmark.image
.resizable()
.frame(width:50, height:50)
Text(landmark.name)
}
}
}
struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
LandmarkRow(landmark: landmarkData[0])
}
}
< Section 3. Row Preview 커스터마이즈하기 >
Xcode의 캔버스는 PreviewProvider 프로토콜을 준수하는 편집기에서의 모든 유형을 자동으로 인식하고 표시한다. preview provider는 크기 및 장치를 구성하는 옵션과 함께 하나 이상의 보기를 반환한다.
반환된 콘텐츠를 커스터마이즈하여 사용자에게 유용한 미리보기를 정확히 렌더링할 수 있다.
먼저 landmarkData 배열에서 2번째 요소를 꺼낸다.
import SwiftUI
struct LandmarkRow: View {
var landmark: Landmark
var body: some View {
HStack {
landmark.image
.resizable()
.frame(width:50, height:50)
Text(landmark.name)
}
}
}
struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
LandmarkRow(landmark: landmarkData[1])
}
}
previewLayout(_:) 을 사용하여 리스트의 row에 가까운 크기를 설정한다.
import SwiftUI
struct LandmarkRow: View {
var landmark: Landmark
var body: some View {
HStack {
landmark.image
.resizable()
.frame(width:50, height:50)
Text(landmark.name)
}
}
}
struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
LandmarkRow(landmark: landmarkData[1])
.previewLayout(.fixed(width:300,height:70))
}
}
그리고 Group으로 반환된 row를 덮고, 다시 첫 row를 추가한다.
import SwiftUI
struct LandmarkRow: View {
var landmark: Landmark
var body: some View {
HStack {
landmark.image
.resizable()
.frame(width:50, height:50)
Text(landmark.name)
}
}
}
struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
Group {
LandmarkRow(landmark: landmarkData[0])
.previewLayout(.fixed(width:300,height:70))
LandmarkRow(landmark: landmarkData[1])
.previewLayout(.fixed(width:300,height:70))
}
}
}
코드를 간단히하기 위해서 previewLayout을 Group의 바깥쪽으로 뺐다.
import SwiftUI
struct LandmarkRow: View {
var landmark: Landmark
var body: some View {
HStack {
landmark.image
.resizable()
.frame(width:50, height:50)
Text(landmark.name)
}
}
}
struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
Group {
LandmarkRow(landmark: landmarkData[0])
LandmarkRow(landmark: landmarkData[1])
}
.previewLayout(.fixed(width:300,height:70))
}
}
< Section 4. 음식점 리스트 생성하기! >
SwiftUI의 List 타입을 사용하면, platform-specific한 리스트를 디스플레이 할 수 있다. 리스트의 요소들은 정적일 수 있고, 또는 역동적으로 만들 수 있다. 또는 두 가지를 섞어서 만들 수도 있다.
LandmarkList.swift를 생성해준다.
Text를 List로 바꾸고, 두 개의 랜드마크와 LandmarkRow 인스턴스를 제공한다. 미리보기로 2개 보여주는 것이다.
< Section 5. 리스트를 역동적으로 만들기! >
리스트의 요소들을 개인적으로 개별적으로 지정하는 대신 집합에서 직접 rows를 생성할 수 있다.
데이터 집합을 보내서 요소들을 표시하는 리스트와 각 요소에 대한 보기를 닫는 기능도 생성할 수 있다.
두 개의 정적인 landmark rows는 제거하고 대신 landmarkData를 List initializer에 보낸다.
import SwiftUI
struct LandmarkList: View {
var body: some View {
List(landmarkData, id: \.id) { landmark in
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}
닫기(closure)에서 LandmarkRow를 반환하여 동적으로 생성된 목록을 완성하자.
import SwiftUI
struct LandmarkList: View {
var body: some View {
List(landmarkData, id: \.id) { landmark in
LandmarkRow(landmark: landmark)
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}
다시 Landmark.swift로 옮겨서 Identifiable 프로토콜을 선언해줘라.
import SwiftUI
import CoreLocation
struct Landmark: Hashable, Codable, Identifiable {
var id: Int
var name: String
fileprivate var imageName: String
fileprivate var coordinates: Coordinates
var address: String
var score: Double
var category: Category
var locationCoordinate : CLLocationCoordinate2D {
CLLocationCoordinate2D(latitude: coordinates.latitude,
longitude: coordinates.longitude)
}
enum Category: String,CaseIterable,Codable,Hashable {
case featured = "Featured"
case lakes = "Lakes"
case rivers = "Rivers"
}
}
extension Landmark {
var image: Image {
ImageStore.shared.image(name: imageName)
}
}
struct Coordinates: Hashable, Codable {
var latitude: Double
var longitude: Double
}
그리고 다시 LandmarkList.swift로 돌아와서 id 파라미터를 지워준다
import SwiftUI
struct LandmarkList: View {
var body: some View {
List(landmarkData) { landmark in
LandmarkRow(landmark: landmark)
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}
< Section 6. 리스트와 세부내용 사이에 Navigation 만들기 >
리스트가 렌더링을 적절히 하지만, 개별 랜드마크를 눌러서 디테일 페이지를 볼 수 는 없다.
NavigationView를 입력시켜서 리스트에 네비게이션 능력을 더해야하고, 목적 view로 이동하기 위해 각 row를 NavigationLink로 묶는다.
동적인 리스트를 생성하기 위해서 NavigationView를 추가해준다.
import SwiftUI
struct LandmarkList: View {
var body: some View {
NavigationView {
List(landmarkData) { landmark in
LandmarkRow(landmark: landmark)
}
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}
navigationBarTitle(_:)을 불러서 리스트가 디스플레이 되었을 때의 네비게이션 바의 타이틀을 정한다.
import SwiftUI
struct LandmarkList: View {
var body: some View {
NavigationView {
List(landmarkData) { landmark in
LandmarkRow(landmark: landmark)
}
.navigationBarTitle(Text("세종대 맛집지도"))
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}
리스트 닫기 부분에서, NavigationLink로 반환된 row를 덮고, ContentView를 목적지로 지정해야한다.
NavigationLink는 리스트의 rows를 터치하여 다른 뷰로 이동시키게 해준다.
< Section 7. Child Views에 데이터 전달하기 >
LandmarkDetail view는 세부정보를 보여주기 위해 hard-coded되었다. LandmarkRow와 마찬가지로, LandmarkDetail 타입과 뷰는 데이터 소스로써, 랜드마크 속성을 이용할 필요가 있다.
child views부터 시작해서, CircleImage, MapView, LandmarkDetail를 변환하여 하드코딩하기 보다는 전달된 데이터를 표시해야한다.
* 하드코딩 : 설정사항이나 코드 등의 시스템적으로 사용하는 변수를 변수를 사용하지 않고 값을 직접 소스코드에 넣어서 사용하는 방식입니다. 코드가 바뀌었을 경우 자동으로 반영되지 않기 때문에 이후에 버그가 발생할 위기가 많은 시한폭탄 같은 방식이다.
CircleImage.swift에서 저장된 이미지 속성을 더해준다.
import SwiftUI
struct CircleImage: View {
var image:Image
var body: some View {
image
.clipShape(Circle())
.overlay(Circle().stroke(Color.white,lineWidth: 4))
.shadow(radius: 10)
}
}
struct CircleImage_Previews: PreviewProvider {
static var previews: some View {
CircleImage()
}
}
그리고 preview provider 업데이트하여 해당 이미지를 전달할 수 있도록 한다.
import SwiftUI
struct CircleImage: View {
var image:Image
var body: some View {
image
.clipShape(Circle())
.overlay(Circle().stroke(Color.white,lineWidth: 4))
.shadow(radius: 10)
}
}
struct CircleImage_Previews: PreviewProvider {
static var previews: some View {
CircleImage(image: Image("빠오즈"))
}
}
MapView.swift에서 coordinate 속성을 더하고 이 속성을 쓰도록 하드코딩된 latitude와 longitude를 바꿔준다.
import SwiftUI
import MapKit
struct MapView: UIViewRepresentable {
var coordinate: CLLocationCoordinate2D
func makeUIView(context: Context) -> MKMapView {
MKMapView(frame: .zero)
}
func updateUIView(_ view: MKMapView, context: Context) {
let span = MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02)
let region = MKCoordinateRegion(center: coordinate, span: span)
view.setRegion(region, animated: true)
}
}
struct MapView_Previews: PreviewProvider {
static var previews: some View {
MapView()
}
}
그리고 preview provider를 업데이트하여 데이터 배열에서 첫 음식점의 좌표를 보내주도록 한다.
import SwiftUI
import MapKit
struct MapView: UIViewRepresentable {
var coordinate: CLLocationCoordinate2D
func makeUIView(context: Context) -> MKMapView {
MKMapView(frame: .zero)
}
func updateUIView(_ view: MKMapView, context: Context) {
let span = MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02)
let region = MKCoordinateRegion(center: coordinate, span: span)
view.setRegion(region, animated: true)
}
}
struct MapView_Previews: PreviewProvider {
static var previews: some View {
MapView(coordinate: landmarkData[0].locationCoordinate)
}
}
ContentView.swift에서 Landmark 속성을 ContentView type에 더해준다.
import SwiftUI
struct ContentView: View {
var landmark: Landmark
var body: some View {
VStack {
MapView()
.frame(height: 300)
CircleImage()
.offset(y: -130)
.padding(.bottom, -130)
VStack(alignment: .leading) {
Text("고양시")
.font(.title)
HStack (alignment: .top) {
Text("푸르지오")
.font(.subheadline)
Spacer()
Text("원흥")
.font(.subheadline)
}
}
.padding()
Spacer()
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
똑같이 preview를 업데이트 해준다.
import SwiftUI
struct ContentView: View {
var landmark: Landmark
var body: some View {
VStack {
MapView()
.frame(height: 300)
CircleImage()
.offset(y: -130)
.padding(.bottom, -130)
VStack(alignment: .leading) {
Text("고양시")
.font(.title)
HStack (alignment: .top) {
Text("푸르지오")
.font(.subheadline)
Spacer()
Text("원흥")
.font(.subheadline)
}
}
.padding()
Spacer()
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(landmark: landmarkData[0])
}
}
그리고 하드코딩된 정보들을 다 바꿔준다. 데이터가 알맞게 들어갈 수 있도록 바꿔준다.
그리고 navigationBarTitle(_:displayMode:)를 불러서 세부정보를 불러올 때 타이틀바를 만들어준다.
import SwiftUI
struct ContentView: View {
var landmark: Landmark
var body: some View {
VStack {
MapView(coordinate: landmark.locationCoordinate)
.frame(height: 300)
CircleImage(image: landmark.image)
.offset(y: -130)
.padding(.bottom, -130)
VStack(alignment: .leading) {
Text(landmark.name)
.font(.title)
HStack (alignment: .top) {
Text(landmark.score)
.font(.subheadline)
Spacer()
Text(landmark.address)
.font(.subheadline)
}
}
.padding()
Spacer()
}
.navigationBarTitle(Text(landmark.name),displayMode: .inline)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(landmark: landmarkData[0])
}
}
그리고 SceneDelegate.swift에서 root view를 LandmarkList로 바꿔준다.
import UIKit
import SwiftUI
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
// Create the SwiftUI view that provides the window contents.
// Use a UIHostingController as window root view controller.
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: LandmarkList())
self.window = window
window.makeKeyAndVisible()
}
}
그리고 LandmarkList.swift로 가서 현재 랜드마크를 목적지(ContentView)로 향하게한다.
import SwiftUI
struct LandmarkList: View {
var body: some View {
NavigationView {
List(landmarkData) { landmark in
NavigationLink(destination: ContentView(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
.navigationBarTitle(Text("세종대 맛집지도"))
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}
< Section 8. Previews를 동적으로 생성하기 >
LandmarkList_Previews에 코드를 추가하여 기기마다 다른 사이즈의 리스트 뷰를 제공해줄 것이다. preview device는 previewDevice(_:)를 사용해서 바꿀 수 있다.
LandmarkList.swift에서 바꿔준다.
import SwiftUI
struct LandmarkList: View {
var body: some View {
NavigationView {
List(landmarkData) { landmark in
NavigationLink(destination: ContentView(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
.navigationBarTitle(Text("세종대 맛집지도"))
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
.previewDevice(PreviewDevice(rawValue: "iPhone SE"))
}
}
List내부에 ForEach 인스턴스를 추가하여 디바이스 이름을 배열로서 사용할 수 있다. (동적 리스트를 나타낼수도 있다.)
import SwiftUI
struct LandmarkList: View {
var body: some View {
NavigationView {
List(landmarkData) { landmark in
NavigationLink(destination: ContentView(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
.navigationBarTitle(Text("세종대 맛집지도"))
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
ForEach(["iPhone SE","iPhone XS Max"], id: \.self) { deviceName in
LandmarkList()
.previewDevice(PreviewDevice(rawValue :deviceName))
}
}
}
previewDisplayName(_:)를 사용하여 디바이스 이름을 라벨처럼 넣을 수 있다.
import SwiftUI
struct LandmarkList: View {
var body: some View {
NavigationView {
List(landmarkData) { landmark in
NavigationLink(destination: ContentView(landmark: landmark)) {
LandmarkRow(landmark: landmark)
}
}
.navigationBarTitle(Text("세종대 맛집지도"))
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
ForEach(["iPhone SE","iPhone XS Max"], id: \.self) { deviceName in
LandmarkList()
.previewDevice(PreviewDevice(rawValue :deviceName))
.previewDisplayName(deviceName)
}
}
}
캔버스를 통해서 다양한 기기에 적용시켜 볼 수 있다.