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

Yang Si Yeon·2021년 3월 2일
0

SwiftUI

목록 보기
4/5

공식문서보고 SwiftUI 따라하기 (1) ~ (3)을 통해 SwiftUI Tutorials의 Chapter 1인 SwiftUI Essentials를 끝냈다. 이제 SwiftUI의 기초를 어느정도 해봤으니, 이제는 Chapter 3 App Design and Layout 에서 SwiftUI로 구축된 더 복잡한 인터페이스 구조와 레이아웃을 살펴보자!

(Chapter 2인 Drawing and Animation은 일단은 넘어가도록 하겠다.)

이번에도 소스코드는 Github에서! 🐱


Composing Complex Interfaces

이전에 만들었던 Landmarks 앱에 카테고리 view를 추가하고, 가로로 스크롤되는 랜드마크들을 세로로 스크롤 할 수 있는 리스트뷰를 만들어보자. 해당 작업을 통해 만들어진 view들이 다양한 기기의 크기 및 방향에따라 어떻게 표현되는지 살펴볼 수 있을 것이다.

Section 1. 카테고리 view 추가하기

뷰 상단에 주요 landmark를 강조하면서 카테고리 별로 landmark를 정렬하는 view를 만들어서 landmark를 보는 방식을 바꿔보자.

  1. 프로젝트의 Views 그룹 안에 Categories 그룹을 만들고, 그 안에 CategoryHome.swift SwiftUI view 파일을 만들자. 그리고 다른 카테고리를 호스팅하기 위해 NavigationView를 추가하자.
  1. Navigation bar의 타이틀을 "Featured"로 설정한다. 화면은 상단에 하나 이상의 주요(featured) landmark를 보여줄 것이다.
import SwiftUI

struct CategoryHome: View {
    var body: some View {
        NavigationView {
            Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
                .navigationTitle("Featured")
        }
    }
}

struct CategoryHome_Previews: PreviewProvider {
    static var previews: some View {
        CategoryHome()
    }
}

Section 2. 카테고리 리스트 만들기

카테고리 view는 쉬운 탐색을 위해 모든 category를 세로 row로 정렬한다. vertical과 horizontal stack을 이용해 목록에 스크롤을 추가해보자.

  1. Landmark struct에 Category enum과, Category 타입의 category 프로퍼티를 추가하자.
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
    var isFavorite: Bool
    
    var category: Category
    enum Category: String, CaseIterable, Codable {
        case lakes = "Lakes"
        case rivers = "Rivers"
        case mountain = "Mountains"
    }
    
    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
    }
}

LandmakrData.json 파일에는 이미 각 landmark의 카테고리 값들을 포함하고 있다. 데이터의 이름을 똑같게 만들고, Codable 프로토콜을 채택하면 data를 로드할 수 있다.


아래는 landmarkData.json에 있는 데이터 중 1개이다.

{
        "name": "Turtle Rock",
        "category": "Rivers",
        "city": "Twentynine Palms",
        "state": "California",
        "id": 1001,
        "isFeatured": true,
        "isFavorite": true,
        "park": "Joshua Tree National Park",
        "coordinates": {
            "longitude": -116.166868,
            "latitude": 34.011286
        },
        "description": "Suscipit inceptos est felis purus aenean aliquet adipiscing diam venenatis, augue nibh duis neque aliquam tellus condimentum sagittis vivamus, cras ante etiam sit conubia elit tempus accumsan libero, mattis per erat habitasse cubilia ligula penatibus curae. Sagittis lorem augue arcu blandit libero molestie non ullamcorper, finibus imperdiet iaculis ad quam per luctus neque, ligula curae mauris parturient diam auctor eleifend laoreet ridiculus, hendrerit adipiscing sociosqu pretium nec velit aliquam. Inceptos egestas maecenas imperdiet eget id donec nisl curae congue, massa tortor vivamus ridiculus integer porta ultrices venenatis aliquet, curabitur et posuere blandit magnis dictum auctor lacinia, eleifend dolor in ornare vulputate ipsum morbi felis. Faucibus cursus malesuada orci ultrices diam nisl taciti torquent, tempor eros suspendisse euismod condimentum dis velit mi tristique, a quis etiam dignissim dictum porttitor lobortis ad fermentum, sapien consectetur dui dolor purus elit pharetra. Interdum mattis sapien ac orci vestibulum vulputate laoreet proin hac, maecenas mollis ridiculus morbi praesent cubilia vitae ligula vel, sem semper volutpat curae mauris justo nisl luctus, non eros primis ultrices nascetur erat varius integer.",
        "imageName": "turtlerock"
    }
  1. ModelData.swift에서 카테고리 이름을 키로 사용하는 카테고리 dictionary를 추가하고, 각 키에 대해 관련된 Landmark 배열을 추가하자.
final class ModelData: ObservableObject {
    @Published var landmarks: [Landmark] = load("landmarkData.json")
    
    var categories: [String: [Landmark]] {
        Dictionary(
            grouping: landmarks,
            by: { $0.category.rawValue }
        )
    }
}
  1. CategoryHome.swift에 Environment 객체를 만들자.

현재 ModelData 클래스는 Observable 객체로 만들어져 있고, Observable 객체의 정의는 다음과 같다.

  • Observable 객체는 SwiftUI의 저장소에서 view에 바인딩할 수 있는 데이터의 사용자 지정 개체이다. SwiftUI는 view에 영향을 줄 수 있는 Observable 객체의 변경사항을 감시하고 변경 후 알맞은 view를 보여준다.
  • Observable 객체를 Environment 객체로 변환하면, 모든 뷰에서 Environment 객체에 접근이 가능하다.
  1. List를 이용해서 Landmark의 카테고리를 디스플레이하자.
import SwiftUI

struct CategoryHome: View {
    @EnvironmentObject var modelData: ModelData
    
    var body: some View {
        NavigationView {
            List {
                ForEach(modelData.categories.keys.sorted(), id: \.self){ key in
                    Text(key)
                }
            }
            .navigationTitle("Featured")
        }
    }
}

struct CategoryHome_Previews: PreviewProvider {
    static var previews: some View {
        CategoryHome()
            .environmentObject(ModelData())
    }
}

Section 3. 카테고리 Row 만들기

우리가 만들 Landmarks 앱은 가로로 스크롤되는 row에 각 카테고리에 해당하는 landmark들을 표시한다. row를 나타내는 새로운 view를 추가한 다음 해당 view에서 한 카테고리에 해당하는 모든 랜드마크를 띄우자.

  1. row의 콘텐츠를 보여주는 CategoryRow 커스텀뷰를 만들고, 카테고리 이름과 아이템 배열을 프로퍼티로 추가하자. 그리고 카테고리의 이름을 띄워주자.
import SwiftUI

struct CategoryRow: View {
    var categoryName: String
    var items: [Landmark]
    
    var body: some View {
        Text(categoryName)
            .font(.headline)
    }
}

struct CategoryRow_Previews: PreviewProvider {
    static var landmarks = ModelData().landmarks
    static var previews: some View {
        CategoryRow(
            categoryName: landmarks[0].category.rawValue,
            items: Array(landmarks.prefix(3))
        )
    }
}
  1. 카테고리 아이템들을 HStack에 넣고, 얘네들을 VStack 안에 넣은 다음 카테고리 이름으로 그룹화 시켜주자. 그리고 카테고리 이름에 패딩을 추가하고, HStack을 스크롤뷰로 감싼뒤, frame(width:height:)으로 Scroll 뷰의 높이를 지정하자.
struct CategoryRow: View {
    var categoryName: String
    var items: [Landmark]
    
    var body: some View {
        VStack(alignment: .leading) {
            Text(categoryName)
                .font(.headline)
                .padding(.leading, 15)
                .padding(.top, 5)
            
            ScrollView(.horizontal, showsIndicators: false) {
                HStack(alignment: .top, spacing: 0) {
                    ForEach(items) { landmark in
                        Text(landmark.name)
                    }
                }
            }
            .frame(height: 185)
        }
    }
}
  1. 1개의 landmark를 보여주는 CategoryItem 커스텀 뷰를 만들자.
import SwiftUI

struct CategoryItem: View {
    var landmark: Landmark
    
    var body: some View {
        VStack(alignment: .leading) {
            landmark.image
                .resizable()
                .frame(width: 155, height: 155)
                .cornerRadius(5)
            Text(landmark.name)
                .font(.caption)
        }
        .padding(.leading, 15)
    }
}

struct CategoryItem_Previews: PreviewProvider {
    static var previews: some View {
        CategoryItem(landmark: ModelData().landmarks[0])
    }
}
  1. 그리고 CategoryRow.swift에서 landmark의 이름을 띄우던 Text을 CategoryItem 뷰로 바꿔주자.
struct CategoryRow: View {
    var categoryName: String
    var items: [Landmark]
    
    var body: some View {
        VStack(alignment: .leading) {
            Text(categoryName)
                .font(.headline)
                .padding(.leading, 15)
                .padding(.top, 5)
            
            ScrollView(.horizontal, showsIndicators: false) {
                HStack(alignment: .top, spacing: 0) {
                    ForEach(items) { landmark in
                        CategoryItem(landmark: landmark)
                    }
                }
            }
            .frame(height: 185)
        }
    }
}

짠!

Section 4. 카테고리 View 완성하기

row들과 이미지들을 추가해서 category home 페이지를 완성해봅시다-!

  1. CategoryHome에서 CategoryRow의 인스턴스들을 생성해 리스트로 만들어주자.
struct CategoryHome: View {
    @EnvironmentObject var modelData: ModelData
    
    var body: some View {
        NavigationView {
            List {
                ForEach(modelData.categories.keys.sorted(), id: \.self){ key in
                    CategoryRow(categoryName: key, items: modelData.categories[key]!)
                }
            }
            .navigationTitle("Featured")
        }
    }
}
  1. 이제 주요 landmark를 화면의 상단에 보여줘야하는데, 이를 위해서는 더 많은 정보가 필요하다. 먼저 Landmark.swift 파일에 isFeatured 프로퍼티를 추가해주자. 해당 프로퍼티는 landmarkData.json에서 가져오는 데이터에 이미 있는 값이다.
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
    var isFavorite: Bool
    var isFeatured: Bool
    
    var category: Category
    enum Category: String, CaseIterable, Codable {
        case lakes = "Lakes"
        case rivers = "Rivers"
        case mountain = "Mountains"
    }
    
    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. ModelData.swift에 isFeatured가 true인 landmark 리스트를 반환하는 eatures 프로퍼티를 추가해주자.
final class ModelData: ObservableObject {
    @Published var landmarks: [Landmark] = load("landmarkData.json")
    
    var features: [Landmark] {
        landmarks.filter { $0.isFeatured }
    }
    
    var categories: [String: [Landmark]] {
        Dictionary(
            grouping: landmarks,
            by: { $0.category.rawValue }
        )
    }
}
  1. CategoryHome.swift안에서 첫 번째 추천 landmark의 이미지를 카테고리 리스트 위쪽에 추가하자. 그리고 컨텐츠들이 화면 가장자리까지 차지할 수 있도록 상단 이미지 뷰와 카테고리 뷰의 inset을 0으로 설정해주자.

    다음 과정에서 해당 view를 interactive한 캐러셀로 바꿀 것이다. 따라서 일단 지금은 크기를 적절하게 조절하고 자른 이미지를 보여주자.

struct CategoryHome: View {
    @EnvironmentObject var modelData: ModelData
    
    var body: some View {
        NavigationView {
            List {
                modelData.features[0].image
                    .resizable()
                    .scaledToFill()
                    .frame(height: 200)
                    .clipped()
                    .listRowInsets(EdgeInsets())
                
                ForEach(modelData.categories.keys.sorted(), id: \.self){ key in
                    CategoryRow(categoryName: key, items: modelData.categories[key]!)
                }
                .listRowInsets(EdgeInsets())
            }
            .navigationTitle("Featured")
        }
    }
}

짠!

Section 5. 섹션 사이에 navigation 추가하기

  1. CategoryRow.swift에서 CategoryItem을 NavigationLink로 감싸서 해당 뷰를 클릭하면 LandmarkDetail로 이동할 수 있게 해주자.
struct CategoryRow: View {
    var categoryName: String
    var items: [Landmark]
    
    var body: some View {
        VStack(alignment: .leading) {
            Text(categoryName)
                .font(.headline)
                .padding(.leading, 15)
                .padding(.top, 5)
            
            ScrollView(.horizontal, showsIndicators: false) {
                HStack(alignment: .top, spacing: 0) {
                    ForEach(items) { landmark in
                        NavigationLink(destination: LandmarkDetail(landmark: landmark)){
                            CategoryItem(landmark: landmark)
                        }
                    }
                }
            }
            .frame(height: 185)
        }
    }
}
  1. CategoryItem.swift에서 category item의 navigation 생김새를 바꾸기 위해 renderingMode(:) 및 foregroundColor(:) modifier를 적용하자.
struct CategoryItem: View {
    var landmark: Landmark
    
    var body: some View {
        VStack(alignment: .leading) {
            landmark.image
                .renderingMode(.original)
                .resizable()
                .frame(width: 155, height: 155)
                .cornerRadius(5)
            Text(landmark.name)
                .foregroundColor(.primary)
                .font(.caption)
        }
        .padding(.leading, 15)
    }
}
  1. 사용자가 카테고리 뷰와 landmark 리스트 뷰 사이에 원하는 것을 선택할 수 있도록 tab 뷰를 만들기 위해, ContentView에 Tab enum을 추가하자. 그리고 어떤 탭을 선택했는지 저장해놓는 State 변수를 추가하고, 디폴트 값을 설정해주자.

  2. CategoryHome 뷰를 추가하고, CategoryHome 뷰와 LandmarkList 뷰를 Tabview로 감싼 뒤, 각 탭에 label을 주자.

struct ContentView: View {
    @State private var selection: Tab = .featured
    
    enum Tab {
        case featured
        case list
    }
    
    var body: some View {
        TabView(selection: $selection) {
            CategoryHome()
                .tabItem {
                    Label("Featured", systemImage: "star")
                }
                .tag(Tab.featured)
            
            LandmarkList()
                .tabItem {
                    Label("List", systemImage: "list.bullet")
                }
                .tag(Tab.list)
        }
    }
}
  1. 프리뷰를 live 모드로 바꾸면 끝 !

참고
https://developer.apple.com/tutorials/swiftui/composing-complex-interfaces

https://velog.io/@budlebee/SwiftUI-ObservableObject

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

0개의 댓글