본문 바로가기
iOS/SwiftUI Tutorial

SwiftUI Tutorial 4 - Handling user input

by 6cess 2024. 7. 6.

1. Mark favorite landmarks

모델 클래스에 isFavorite 프로퍼티를 추가한다.

struct Landmark: Hashable, Codable, Identifiable {
    var id: Int
    var name: String
    var park: String
    var state: String
    var description: String
    var isFavorite: Bool
    
    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 latitude: Double
        var longitude: Double
    }
}

 

LandmarkRow 에서 isFavorite 값에 따라 별 이미지를 표시

struct LandmarkRow: View {
    var landmark: Landmark
    var body: some View {
        HStack {
            landmark.image
                .resizable()
                .frame(width: 50, height: 50)
            Text(landmark.name)
            Spacer()
            if landmark.isFavorite {
                Image(systemName: "star.fill")
                    .foregroundStyle(.yellow)
            }
        }
    }
}

 

2. Filter the list view

@State property 를 통해 filter 할지 말지 여부 정보를 담는 변수를 선언하고 이에 대한 상태 관리가 된다.

상태 관리가 된다는 것은 이 값이 바뀔 경우 이 값과 관련된 화면이 새로 렌더링된다는 것.

또한 List 에서 표시할 Landmark 데이터를 필터링하여 담는 computed property를 선언한다. 이 computed property도 showFavoritesOnly property를 참조하고 있기 때문에 showFavoritesOnly 값이 변경되면 fliteredLandmarks 값도 변경된다.

 

computed property에 대한 것은 문서를 읽어볼 것

https://docs.swift.org/swift-book/documentation/the-swift-programming-language/properties/

 

Documentation

 

docs.swift.org

struct LandmarkList: View {
    @State private var showFavoritesOnly = true
    
    var filteredLandmarks: [Landmark] {
        landmarks.filter { landmark in
            (!showFavoritesOnly || landmark.isFavorite)
        }
    }
    
    var body: some View {
        NavigationSplitView {
            List(filteredLandmarks) { landmark in
                NavigationLink {
                    LandmarkDetail(landmark: landmark)
                } label: {
                    LandmarkRow(landmark: landmark)
                }
            }
            .navigationTitle("Landmarks")
        } detail: {
            Text("Select a Landmark")
        }
    }
}

 

3. Add a control to toggle the state

리스트 안에서 정적 뷰와 동적인 뷰를 결합할 때는 forEach를 추천한다고 한다.

var body: some View {
        NavigationSplitView {
            List {
                Toggle(isOn: $showFavoritesOnly) {
                    Text("Favorites only")
                }

                ForEach(filteredLandmarks) { landmark in
                    NavigationLink {
                        LandmarkDetail(landmark: landmark)
                    } label: {
                        LandmarkRow(landmark: landmark)
                    }
                }
            }
            .animation(.default, value: filteredLandmarks)
            .navigationTitle("Landmarks")
        } detail: {
            Text("Select a Landmark")
        }
    }

 

4. Use observation for storage

@Observable을 사용해서 모델 데이터의 변경사항을 바로 view 에 반영할 수 있게 한다.

@Observable
class ModelData {
    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("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)")
    }
}

 

5. Adopt the model object in your views

@Observable로 선언한 ModelData를 @Environment property로 선언한다. @Environment property는 부모에게 environment modifier가 적용되어 있을 경우 값을 가져오게 되어 있다.

struct LandmarkList: View {
    @Environment(ModelData.self) var modelData
    @State private var showFavoritesOnly = false


    var filteredLandmarks: [Landmark] {
        modelData.landmarks.filter { landmark in
            (!showFavoritesOnly || landmark.isFavorite)
        }
    }
    

...

 

ContentView 에서 environment modifier를 추가한다.

@main
struct LandmarksApp: App {
    @State private var modelData = ModelData()


    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(modelData)
        }
    }
}

preview에서 필요한 경우에도 environment modifier를 추가해준다.

 

@Environment를 사용하게 되면 상위 뷰에서 데이터를 주입한 다음 필요한 하위 뷰에서 간편하게 상위 뷰에서 주입한 데이터에 접근해 상태관리가 용이함!

 

6. Create a favorite button for each landmark

FavoritButton 을 구현한다.

struct FavoriteButton: View {
    @Binding var isSet: Bool

    var body: some View {
        Button {
            isSet.toggle()
        } label: {
            Label("Toggle Favorite", systemImage: isSet ? "star.fill" : "star")
                .labelStyle(.iconOnly)
                .foregroundStyle(isSet ? .yellow : .gray)
        }
    }
}

LandmarkDetail 뷰에서 @Envaironment 로 modelData에 접근해서  Favorite Button 에 전달한다.

여기서 사용되는 @Environment, @Binding, @Bindable 등이 헷갈린다면 아래 링크를 참조

https://forums.developer.apple.com/forums/thread/735416

 

Why @Bindable and not @Binding in … | Apple Developer Forums

Interesting about the change from the early betas! This is my first testing of it so I don't have any earlier experience. I did a quick test and you're absolutely right with @ObservedObject--if I pass a @StateObject into a subview and I don't prefix the va

forums.developer.apple.com

 

struct LandmarkDetail: View {
    @Environment(ModelData.self) var modelData
    var landmark: Landmark


    var landmarkIndex: Int {
        modelData.landmarks.firstIndex(where: { $0.id == landmark.id })!
    }


    var body: some View {
        @Bindable var modelData = modelData


        ScrollView {
            MapView(coordinate: landmark.locationCoordinate)
                .frame(height: 300)


            CircleImage(image: landmark.image)
                .offset(y: -130)
                .padding(.bottom, -130)


            VStack(alignment: .leading) {
                HStack {
                    Text(landmark.name)
                        .font(.title)
                    FavoriteButton(isSet: $modelData.landmarks[landmarkIndex].isFavorite)
                }


                HStack {
                    Text(landmark.park)
                    Spacer()
                    Text(landmark.state)
                }
                .font(.subheadline)
                .foregroundStyle(.secondary)


                Divider()


                Text("About \(landmark.name)")
                    .font(.title2)
                Text(landmark.description)
            }
            .padding()
        }
        .navigationTitle(landmark.name)
        .navigationBarTitleDisplayMode(.inline)
    }
}