공식문서보고 SwiftUI 따라하기 (1) ~ (3)을 통해 SwiftUI Tutorials의 Chapter 1인 SwiftUI Essentials를 끝냈다. 이제 SwiftUI의 기초를 어느정도 해봤으니, 이제는 Chapter 3 App Design and Layout 에서 SwiftUI로 구축된 더 복잡한 인터페이스 구조와 레이아웃을 살펴보자!
(Chapter 2인 Drawing and Animation은 일단은 넘어가도록 하겠다.)
이번에도 소스코드는 Github에서! 🐱
이전에 만들었던 Landmarks 앱에 카테고리 view를 추가하고, 가로로 스크롤되는 랜드마크들을 세로로 스크롤 할 수 있는 리스트뷰를 만들어보자. 해당 작업을 통해 만들어진 view들이 다양한 기기의 크기 및 방향에따라 어떻게 표현되는지 살펴볼 수 있을 것이다.
뷰 상단에 주요 landmark를 강조하면서 카테고리 별로 landmark를 정렬하는 view를 만들어서 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()
}
}
카테고리 view는 쉬운 탐색을 위해 모든 category를 세로 row로 정렬한다. vertical과 horizontal stack을 이용해 목록에 스크롤을 추가해보자.
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" }
final class ModelData: ObservableObject {
@Published var landmarks: [Landmark] = load("landmarkData.json")
var categories: [String: [Landmark]] {
Dictionary(
grouping: landmarks,
by: { $0.category.rawValue }
)
}
}
현재 ModelData 클래스는 Observable 객체로 만들어져 있고, Observable 객체의 정의는 다음과 같다.
- Observable 객체는 SwiftUI의 저장소에서 view에 바인딩할 수 있는 데이터의 사용자 지정 개체이다. SwiftUI는 view에 영향을 줄 수 있는 Observable 객체의 변경사항을 감시하고 변경 후 알맞은 view를 보여준다.
- Observable 객체를 Environment 객체로 변환하면, 모든 뷰에서 Environment 객체에 접근이 가능하다.
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())
}
}
우리가 만들 Landmarks 앱은 가로로 스크롤되는 row에 각 카테고리에 해당하는 landmark들을 표시한다. row를 나타내는 새로운 view를 추가한 다음 해당 view에서 한 카테고리에 해당하는 모든 랜드마크를 띄우자.
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))
)
}
}
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)
}
}
}
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])
}
}
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)
}
}
}
짠!
row들과 이미지들을 추가해서 category home 페이지를 완성해봅시다-!
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")
}
}
}
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
}
}
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 }
)
}
}
다음 과정에서 해당 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")
}
}
}
짠!
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)
}
}
}
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)
}
}
사용자가 카테고리 뷰와 landmark 리스트 뷰 사이에 원하는 것을 선택할 수 있도록 tab 뷰를 만들기 위해, ContentView에 Tab enum을 추가하자. 그리고 어떤 탭을 선택했는지 저장해놓는 State 변수를 추가하고, 디폴트 값을 설정해주자.
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)
}
}
}
참고
https://developer.apple.com/tutorials/swiftui/composing-complex-interfaces