나의 발자취

[SwiftUI] Apple 공식 SwiftUI Tutorials: Landmarks 프로젝트 클론 본문

앱 개발/iOS

[SwiftUI] Apple 공식 SwiftUI Tutorials: Landmarks 프로젝트 클론

달모드 2024. 10. 30. 16:50

tip) XCode 자동완성 옵션 선택

 

지금은 radius 인자만 살아있어서, 그냥 엔터를 치면 shadow(radius: )이렇게만 나타나는데

여기서 alt를 누르고 엔터를 치면 모든 인자값이 다 들어간 메서드가 된다!

 

 

CircleImage offset 설정 후 레이아웃 조절하기

 

써클이미지를 오프셋 준다고 해서 Turtle Rock 글씨는 안따라올라간다.

그도 그렇듯이, 원래 CircleImage()는 MapView() 아래에 VStack으로 쌓여서 지금 밑의 공간을 차지하고 있고, 뷰에 보여지는것만 겹치도록 offset을 준것이기 때문이다.

 

따라서 line 15처럼 padding을 음수로 주어서 Turtle Rock 이 포함된 VStack의 위치를 올려준다.

 

이게 끝이 아니다. 맵뷰를 제일 최상단으로 끌어올려야하기 때문에, line 26처럼 VStack() 아래에 Spacer()를 주어서 밑에 Description을 입력할 공간을 만들어준다.

 

 

컴포넌트 하나 하나가 아닌, Stack 전체에 대한 컴포넌트 속성을 부여하고 싶을 때에는 메서드 체이닝으로 Stack의 끝에 . 접근제어자로 속성을 정의해준다. Turtle Rock 밑에 적혀있는 상세 text들의 디자인 속성들을 VStack 전체에 부여하면서, 각각에 대한 속성은 지워버렸다.

 

 

 

구조체

coordinates 구조체는 Landmark 구조체 안에서만 사용해줄 것이기 때문에, 굳이 바깥에서 쓰지 않고 구조체 안에 들어가있어도 된다.

 

 

 

ModelData.swift 에서 JSON 데이터 처리하는 모델 만들기 - 제네릭 활용, JSONDecoder

이제 JSON을 다 받아와서 처리할 수 있는 일반적인 모델을 생성해줄 것이라 새로운 swift 파일을 생성해준다.

ModelData.swift 에서 이와 같이 할 수 있지만, 우리는 제네릭으로 만들어줄 것이기 때문에 

 

[Landmark] 타입이 아닌 T로 output type을 해준다.

그러면서 동시에 T 타입에 제약사항을 걸어주는데, T 타입에는 Decodable한 타입만 올 수 있다는 의미이다.

 

그리고, 지금 JSON 데이터파일은 'Bundle'에 있을 것이다. 따라서 아래와 같이 처리하는 변수를 선언한다.

 

여기서 forResource 인자는 optional이다.(Cmd 클릭해서 확인해보면 그렇다.)

 

그러므로 guard문으로 옵셔널 처리를 해준다.

guard let file = Bundle.main.url(forResource: filename, withExtension: nil) else {
        fatalError("메인번들에서 파일을 찾을 수 없습니다.")
    }

 

 

JSON 파일을 가져오려면, do 문 안에 try-catch문을 써서 Data객체를 가지고 왔었다. 

Data 객체를 가지고 올 수 있는 변수인 data를 선언해주고 작업을 해준다.

func load<T: Decodable>(filename: String) -> T {
    let data: Data
    guard let file = Bundle.main.url(forResource: filename, withExtension: nil) else {
        fatalError("메인번들에서 파일을 찾을 수 없습니다.")
    }
    
    do {
        data = try Data(contentsOf: file)
    } catch {
        fatalError("메인번들에서 파일을 읽을 수 없습니다.")
    }
}

 

 

이렇게 해주고, 디코딩을 해주어야하기 때문에 또 아래에 추가로 JSONDecoder()를 활용한 decoder를 선언해주는 do문 안의 try-catch문을 써준다.

    do {
        let decoder = JSONDecoder()
        return try decoder.decode(T.self, from: data)
    } catch {
        fatalError("디코딩을 할 수 없습ㄴ디ㅏ")
    }

우리는 위에서 [Landmark] 타입을 가져오기 위한 제네릭 타입인 T로 선언했기 때문에, 접근할 때는 T.self로 해준다.

 

 

그리고 나서 제일 위에 [Landmark]를 가지고 올 수 있는 변수를 선언해준다.

var landmarks: [Landmark] = load("landmarkData.json")

이제 json 파일에서 가져오는 모든 데이터는 타입이 [Landmark]로 될 것이다.

//
//  ModelData.swift
//  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("메인번들에서 파일을 찾을 수 없습니다.")
    }
    
    do {
        data = try Data(contentsOf: file)
    } catch {
        fatalError("메인번들에서 파일을 읽을 수 없습니다.")
    }
    
    do {
        let decoder = JSONDecoder()
        return try decoder.decode(T.self, from: data)
    } catch {
        fatalError("디코딩을 할 수 없습니다")
    }
}

 

 

Row 만들기

이제 LandmarkRow.swift 를 생성해서 리스트에 나타나는 셀같은 row를 만들어줄것이다.

 

그러나 이와 같이 생성하면 에러가 난다.Preview의 LandmarkRow()에 인스턴스값을 넣어줘야한다.여기에는 아까 JSON 데이터를 객체로 가져온 landmarks 를 넣어준다.

var landmarks: [Landmark] = load("landmarkData.json")

^ remember this..?

 

 

Preview 2개 보기

Preview를 두개를 만들어놓으면, 우측 프리뷰 영역에 버튼이 두개가 생긴다.

 

 

만든 Row를 List로 보여주기

SwiftUI 파일로 LandmarkList.swift를 생성한다. 

그리고 각 row 객체에 접근해서 가지고 올 수 있도록, List에도 element 하나하나에 접근하는 문법이 있다.

import SwiftUI

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

#Preview {
    LandmarkList()
}

 

나중에 row를 편집하려면 Hashable로 해주어야한다.

Landmark JSON 데이터를 보면, id가 유니크한걸 알 수 있다.

따라서, Landmark.swift에서 만든 구조체 모델에 가서 Hashable이 아닌, Identifiable로 바꾸어준다. (더 최적화되어서 더 빠르고 효율적임)

Identifiable를 적용해주려면, 'id'값을 가져야한다.(없으면 에러남)

 

수정된 코드

import SwiftUI

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

#Preview {
    LandmarkList()
}

 

 

 

LandmarkDetail.swift 생성

ContentView의 VStack을 모두 째로 잘라내서 붙여넣는다. 그러면서 비게 되는 메서드 안에 LandmarkList()를 넣어주었다.

 

 

ContentView.swift

 

다시 LandmarkDetail.swift로 돌아가서, 

제일 위에 var landmark:Landmark 선언 후

 

<전>

//
//  LandmarkDetail.swift
//  Landmarks
//
//  Created by Lia An on 10/30/24.
//

import SwiftUI

struct LandmarkDetail: View {
    var landmark: Landmark
    var body: some View {
        VStack {
            MapView().frame(height: 300)
            CircleImage().offset(y: -130)
                .padding(.bottom, -130)
            VStack(alignment: .leading) {
                Text("Turtle Rock")
                    .font(.title.bold())
                
                HStack {
                    Text("Joshua Tree National Park")
                    Spacer()
                    Text("California")
                }.font(.subheadline)
                    .foregroundStyle(.secondary)
                Divider()
                Text("About Turtle Rock").font(.title2)
                Text("more to come...")
            }.padding()
            Spacer()
        }
    }
}

#Preview {
    LandmarkDetail()
}

 

 

<처리중: 이 과정에서 하드코딩한 부분들은 모든 파일들을 거쳐 모두 리팩토링을 해주었기 때문에, 생략한다.>

 

이렇게 되면 아래의 preview에서 에러가 난다. 인자값을 안넣어주어서 그렇다. 넣어주면 된다.

 

<후>

 

#Preview{}의 landmarks[index] 값을 바꿔줄때마다 JSON 객체에서 id값에 맞는 값을 가져와서 뷰가 바뀌는 것을 볼 수 있다.

 

 

 

LandmarkList.swift에서 네비게이션링크 설정하기

 

이렇게 구현을 했는데,

NavigationSplitView{}의 짝은 detail: 이 있어야한다.(필수 인자값)

그래서 이 부분을 구현을 꼭 해주고,

 

NavigationLink{}는 아래 보이는 것처럼 label, destination을 인자값으로 받으므로 후행클로저에는 링크를 걸어주고, label 값 안에는 어떠한 컨텐츠를 로드시킬지 입력해준다.

Comments