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

Yang Si Yeon·2021년 2월 26일
0

SwiftUI

목록 보기
3/5
post-custom-banner

SwiftUI Essentials 챕터의 3번째 과정, 유저 인풋 값을 다뤄보자!
공식문서 링크는 여기 클릭 !

이번에도 프로젝트는 Github에 업로드 해놓았다. 🐱

Handling User Input

랜드마크 앱에서 사용자는 즐겨찾는 장소를 표시해놓을 수 있고, 즐겨찾는 장소를 필터링해서 볼 수 있다. 해당 기능을 만들기위해서는 즐겨찾는 장소보기만 볼 수 있게 하는 switch를 추가하고, 별 모양의 버튼을 만들어서 즐겨찾기를 할 수 있게 해야한다.

Section 1. 즐겨찾는 장소 표시하기

사용자가 즐겨찾기를 한눈에 볼 수 있도록 하기위해 Landmark 모델에 즐겨찾는 장소인지 아닌지를 저장하는 property를 추가하자.

  1. Landmark.swift 파일을 열고 Bool 타입의 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 longitude: Double
        var latitude: Double
    }
}
  1. LandmarkRow.swift 파일에서 spacer 뒤에 isFavorite 값이 true일 때 별모양 이미지를 추가하는 코드를 작성한다.

    SwiftUI 블록에서는 if 문을 사용해서 조건부로 뷰를 포함시킬 수 있다.

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")
                    .foregroundColor(.yellow)
            }
        }
    }
}

Section 2. 리스트 뷰 필터링하기

리스트에서 모든 랜드마크를 보여줄 수도 있고, 사용자가 즐겨찾은 것만 보여줄 수도 있다. 이를 위해서 LandmarkList 타입에 상태를 추가해야한다. 상태(state)는 시간이 지남에 따라 바뀔수도 있고, 뷰의 동작, 콘텐츠, 레이아웃에 영향을 줄 수 있는 값 또는 값의 집합이다. 뷰에 상태를 추가하려면 @State 속성을 사용해야한다.

  1. LandmarkList.swift 파일을 열고 showFavoriteOnly 이름의 @State 프로퍼티를 추가하자.
struct LandmarkList: View {
    @State private var showFavoritesOnly = false
    
    var body: some View {
        NavigationView {
            List(landmarks) { landmark in
                NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
                    LandmarkRow(landmark: landmark)
                }
            }
            .navigationTitle("Landmarks")
        }
    }
}
  1. showFavoriteOnly 프로퍼티와 각 landmark.isFavorite 프로퍼티를 확인해서 landmarks를 필터링하자. 그리고 필터링 된 landmarks를 List의 요소로 넣어보자.
struct LandmarkList: View {
    @State private var showFavoritesOnly = false
    
    var filteredLandmarks: [Landmark] {
        landmarks.filter { landmark in
            (!showFavoritesOnly || landmark.isFavorite)
        }
    }
    
    var body: some View {
        NavigationView {
            List(filteredLandmarks) { landmark in
                NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
                    LandmarkRow(landmark: landmark)
                }
            }
            .navigationTitle("Landmarks")
        }
    }
}
  1. showFavoritesOnly를 true 값으로 바꾸고 프리뷰를 보면 즐겨찾기된 랜드마크들만 리스트에 정렬된다 !
  @State private var showFavoritesOnly = true

Section 3. 상태를 전환하는 Control 추가하기

사용자가 리스트 필터링을 할 수 있게 하려면 showFavoritesOnly 값을 변경할 수 있는 control을 추가해야한다. 따라서 toggle control에 바인딩을 전달해야한다. 바인딩은 변경 가능한 상태에 대한 참조 역할을 하며, 사용자가 토글을 끄거나 킬때 마다 그에 따라 view의 상태를 업데이트 한다.

  1. 중첩된 ForEach 그룹을 만들어서 landmarks를 row로 변환하자.

    List에서 정적 및 동적인 view들을 결합하거나, 2개 이상의 다른 동적인 view 그룹을 결합하려면 데이터 컬렉션을 List가 아닌 ForEach을 사용해야한다.

struct LandmarkList: View {
    @State private var showFavoritesOnly = true
    
    var filteredLandmarks: [Landmark] {
        landmarks.filter { landmark in
            (!showFavoritesOnly || landmark.isFavorite)
        }
    }
    
    var body: some View {
        NavigationView {
            List {
                ForEach(filteredLandmarks) { landmark in
                    NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
                        LandmarkRow(landmark: landmark)
                    }
                }
            }
            .navigationTitle("Landmarks")
        }
    }
}
  1. List view의 첫번째 자식으로 Toggle을 추가하고, showFavoritesOnly과 바인딩하자.
struct LandmarkList: View {
    @State private var showFavoritesOnly = true
    
    var filteredLandmarks: [Landmark] {
        landmarks.filter { landmark in
            (!showFavoritesOnly || landmark.isFavorite)
        }
    }
    
    var body: some View {
        NavigationView {
            List {
                Toggle(isOn: $showFavoritesOnly) {
                    Text("Favorites only")
                }
                ForEach(filteredLandmarks) { landmark in
                    NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
                        LandmarkRow(landmark: landmark)
                    }
                }
            }
            .navigationTitle("Landmarks")
        }
    }
}
  1. showFavoritesOnly 프로퍼티의 디폴트 값을 false로 바꾸고, 프리뷰를 live 모드로 전환해서 잘 동작하는지 확인해보자!

Section 4. 저장을 위해 Observable 객체 사용하기

사용자가 즐겨찾는 랜드마크들을 제어하기 위해서는 먼저 랜드마크 데이터를 Observable 객체에 저장해야한다.
Observable 객체는 SwiftUI의 저장소에서 view에 바인딩할 수 있는 데이터의 사용자 지정 개체이다. SwiftUI는 view에 영향을 줄 수 있는 Observable 객체의 변경사항을 감시하고 변경 후 알맞은 view를 보여준다.

  1. ModelData.swift 파일을 열고, ObservableObject 프로토콜을 채택하는 새 모델을 선언하자. 해당 프로토콜은 Combine 프레임워크에 포함되어 있다. 그리고 landmarks 배열을 ModelData 클래스 안으로 옮기자.

    SwiftUI는 Observable 객체를 구독하고 데이터가 변경될 때 refresh 해야하는 view를 업데이트 한다.

import Foundation
import Combine

final class ModelData: ObservableObject {
    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)")
    }
}
  1. landmarks 배열에 @Published 속성 추가하기

    Observable 객체는 구독자가 변경사항을 선택할 수 있도록 데이터에 대한 변경사항을 게시(publish)해야한다.

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

Section 5. View에서 모델 객체 채택하기

ModelData 객체를 만들었으니, 이제 앱의 데이터 저장소로 ModelData를 채택하고 View를 업데이트 해보자.

  1. LandmarkList.swift 파일에서, View에 @EnvironmentObject 프로퍼티를 선언하고 previewProvider안에서 environmentObject(_:) modifier를 달아주자.

    부모에게 environmentObject(_:) modifier가 달려있기 때문에, modelData 프로퍼티는 자동으로 값을 가져올 수 있다.

import SwiftUI

struct LandmarkList: View {
    @EnvironmentObject var modelData: ModelData
    @State private var showFavoritesOnly = false
    
    var filteredLandmarks: [Landmark] {
        landmarks.filter { landmark in
            (!showFavoritesOnly || landmark.isFavorite)
        }
    }
    
    var body: some View {
        NavigationView {
            List {
                Toggle(isOn: $showFavoritesOnly) {
                    Text("Favorites only")
                }
                
                ForEach(filteredLandmarks) { landmark in
                    NavigationLink(destination: LandmarkDetail(landmark: landmark)) {
                        LandmarkRow(landmark: landmark)
                    }
                }
            }
            .navigationTitle("Landmarks")
        }
    }
}

struct LandmarkList_Previews: PreviewProvider {
    static var previews: some View {
        LandmarkList()
            .environmentObject(ModelData())
    }
}
  1. 자 이제 landmarks 배열이 ModelData 클래스의 프로퍼티로 들어갔기 때문에, View들에서 쓰이는 landmarks를 모두 ModelData().landmarks로 바꿔주자.
  • LandmarkDetail
struct LandmarkDetail_Previews: PreviewProvider {
    static var previews: some View {
        LandmarkDetail(landmark: ModelData().landmarks[0])
    }
}
  • LandmarkRow
struct LandmarkRow_Previews: PreviewProvider {
    static var landmarks = ModelData().landmarks
    
    static var previews: some View {
        Group {
            LandmarkRow(landmark: landmarks[0])
            LandmarkRow(landmark: landmarks[1])
        }.previewLayout(.fixed(width: 300, height: 70))
    }
}
  1. ContentView에서 ModelData 객체를 environment에 추가하면, 모든 하위 view에서 ModelData 객체를 사용할 수 있다.

    하위 view들이 environment에서 ModelData 객체를 필요로하는데, 프리뷰에 environmentObject(_:) modifier가 없는 경우 프리뷰를 볼 수 없다.

import SwiftUI

struct ContentView: View {
    var body: some View {
        LandmarkList()
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
            .environmentObject(ModelData())
    }
}
  1. 이제 시뮬레이터 또는 기기에서 앱을 실행할 때 environment에 model 객체를 배치하도록 앱 인스턴스를 업데이트해보자. LandmarksApp을 업데이트 해서 model 인스턴스를 만들고 environmentObject(_:) modifier를 사용해서 ContentView에 제공하자.

    @StateObject 속성을 사용하여 앱의 수명주기 동안 한 번만 지정된 속성에 대한 model 객체를 초기화 시킬 수 있다.

import SwiftUI

@main
struct LandmarksApp: App {
    @StateObject private var modelData = ModelData()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(modelData)
        }
    }
}

Section 6. 각 랜드마크에 즐겨찾기 버튼 만들기

이제 Landmarks 앱은 즐겨찾는 랜드마크 리스트를 필터링할 수 있다. 하지만 즐겨찾는 랜드마크들은 여전히 하드코딩된 상태이다. 유저가 즐겨찾기를 추가하고 삭제할 수 있도록 상세 화면에 즐겨찾기 버튼을 만들어보자.

  1. 첫번째로 재사용가능한 FavoriteButton을 만들자. FavoriteButton.swift view 파일 생성!
    버튼의 현재 상태를 나타내는 isSet 바인딩을 추가하고 프리뷰에 상수 값을 제공하자.
import SwiftUI

struct FavoriteButton: View {
    @Binding var isSet: Bool
    
    var body: some View {
        Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
    }
}

struct FavoriteButton_Previews: PreviewProvider {
    static var previews: some View {
        FavoriteButton(isSet: .constant(true))
    }
}
  1. isSet의 상태를 바꿔주는 버튼을 만들고, 해당 상태에 따라 버튼의 생김새를 바꿀 수 있게해주자.

struct FavoriteButton: View {
    @Binding var isSet: Bool
    
    var body: some View {
        Button(action: {
            isSet.toggle()
        }) {
            Image(systemName: isSet ? "star.fill" : "star")
                .foregroundColor(isSet ? Color.yellow : Color.gray)
        }
    }
}
  1. 버튼에 대한 처리를 진행하기 전에, 점점 프로젝트가 커지므로 구조를 조금 정리해주자. CircleImage.swift, MapView.swift, FavoriteButton.swift 파일은 Helpers 그룹으로 넣어주고, landmark와 관련된 파일들은 Landmarks 그룹으로 넣어주자.

  1. 이제 FavoriteButton을 상세 화면에 추가하고, 버튼의 isSet 속성을 landmark의 isFavorite 프로퍼티에 바인딩해야한다. 먼저, LandmarkDetail.swift에서 modelData와 비교하여 해당 landmark의 인덱스 값을 계산하자.

import SwiftUI

struct LandmarkDetail: View {
    @EnvironmentObject var modelData: ModelData
    var landmark: Landmark
    
    var landmarkIndex: Int {
        modelData.landmarks.firstIndex(where: {$0.id == landmark.id})!
    }
    
    var body: some View {
        ScrollView {
            MapView(coordinate: landmark.locationCoordinate)
                .ignoresSafeArea(edges: .top)
                .frame(height: 300)

            CircleImage(image: landmark.image)
                .offset(y: -130)
                .padding(.bottom, -130)
            
            VStack(alignment: .leading) {
                Text(landmark.name)
                    .font(.title)
                HStack {
                    Text(landmark.park)
                      
                    Spacer()
                    Text(landmark.state)
                }
                .font(.subheadline)
                .foregroundColor(.secondary)
                
                Divider()
                
                Text("About \(landmark.name)")
                    .font(.title2)
                Text(landmark.description)
            }
            .padding()
        }
        .navigationTitle(landmark.name)
        .navigationBarTitleDisplayMode(.inline)
    }
}

struct LandmarkDetail_Previews: PreviewProvider {
    static let modelData = ModelData()
    
    static var previews: some View {
        LandmarkDetail(landmark: ModelData().landmarks[0])
            .environmentObject(modelData)
    }
}
  1. landmark의 이름을 HStack 안에 넣고 FavoriteButton도 함께 넣어주자. 그리고 isFavorite 프로퍼티를 $를 통해 바인딩 시켜주자.

    버튼이 model 객체에 저장된 landmark의 isFavorite 속성을 업데이트 시키려면 modelData 객체와 함께 landmarkIndex를 사용해야 한다.


struct LandmarkDetail: View {
    @EnvironmentObject var modelData: ModelData
    var landmark: Landmark
    
    var landmarkIndex: Int {
        modelData.landmarks.firstIndex(where: {$0.id == landmark.id})!
    }
    
    var body: some View {
        ScrollView {
            MapView(coordinate: landmark.locationCoordinate)
                .ignoresSafeArea(edges: .top)
                .frame(height: 300)

            CircleImage(image: landmark.image)
                .offset(y: -130)
                .padding(.bottom, -130)
            
            VStack(alignment: .leading) {
                HStack {
                    Text(landmark.name)
                        .font(.title)
                        .foregroundColor(.primary)
                    FavoriteButton(isSet: $modelData.landmarks[landmarkIndex].isFavorite)
                }
                HStack {
                    Text(landmark.park)
                      
                    Spacer()
                    Text(landmark.state)
                }
                .font(.subheadline)
                .foregroundColor(.secondary)
                
                Divider()
                
                Text("About \(landmark.name)")
                    .font(.title2)
                Text(landmark.description)
            }
            .padding()
        }
        .navigationTitle(landmark.name)
        .navigationBarTitleDisplayMode(.inline)
    }
}

  1. 이제 프리뷰를 live 모드로 바꿔서 실행시키면 상세화면에서 버튼 클릭에 따라 리스트의 즐겨찾기 여부도 바뀌는 것을 확인할 수 있다 !

참고
https://developer.apple.com/tutorials/swiftui/handling-user-input

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

0개의 댓글