SwiftUI Tutorials - Landmarks(3)
이번 시간에는 사용자가 즐겨 찾는 장소에 플래그를 지정해놓고 즐겨찾기만 표시하도록 목록을 필터링할 수 있도록 해볼 것이다.
이 기능을 추가하려면 목록에 스위치를 추가 한 다음 사용자가 스위치를 누르면 즐겨찾기를 한 목록만 볼 수 있도록 해준다.
바로 시작해보자.
1. Mark the User’s Favorite Landmarks
가장 먼저 해야 할 것은 새로운 기능에 대한 프로젝트 파일 landmark에 속성 값을 추가해주자.
//Landmark.swift
struct Landmark: Hashable, Codable, Identifiable {
var id: Int
var name: String
var park: String
var state: String
var description: String
var isFavorite: Bool // 즐겨찾기 속성 추가
그리고 landmarkRow파일에서 if 문 안에 별 이미지를 추가하여 현재 랜드마크가 즐겨찾기인지 테스트합니다.
그리고 별 안의 색상값을. foregroundColor를 이용해 추가하여 isFavorite 속성이 true일 때마다 별이 표시됩니다.
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)
}
}
}
}
2. Filter the List View
이제 전체 목록에서 즐겨찾기만을 표시하는 작업을 한번 해보자. 그러려면 landmak 파일과 같이 속성 값을 추가해주어야 한다.
이때는 @State를 이용하여 뷰에 상태를 추가하면 된다. 순서대로 한번 진행해보자.
- @State private var showFavoritesOnly = false 속성을 추가한다.
- showFavoritesOnly 속성과 각 Landmark.isFavorite 값을 확인하여 랜드마크 목록의 필터링된 버전을 계산한다.
- 목록에서 랜드마크 목록의 필터링된 버전을 사용한다.
이에 대한 코드는 아래와 같으며, 자세한 내용은 아래에서 정리해보았다.
//LandmarkList.swift
import SwiftUI
struct LandmarkList: View {
@State private var showFavoritesOnly = true
var filteredLandmarks: [Landmark] {
landmarks.filter { landmark in
(!showFavoritesOnly || landmark.isFavorite)
}
}
var body: some View {
NavigationView {
List(filteredLandmarks) { landmark in
NavigationLink {
LandmarkDetail(landmark: landmark)
} label: {
LandmarkRow(landmark: landmark)
}
}
.navigationTitle("Landmarks")
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}
@State 속성을 사용하는 이유는 상태 속성을 사용하여 뷰 및 해당 하위 뷰와 관련된 정보를 보유하기 때문에 항상 상태를 비공개로 만든다. 왜냐? 지금은 Bool값이 아니라 false라고 이미 내가 지정해놨기 때문이다. 만약 목록이 어떻게 변하는지 확인하고 싶다면 속성 초기값을 true로 바꾸면 되겠지요? 바꾸게 되면 아래와 같이 true로 지정되어있는 요소들만 목록에서 확인할 수 있다.
3. Add a Control to Toggle the State
목록의 필터에 대해 사용자에게 제어 권한을 부여하기 위해서는 위에서 추가한 showFavoritesOnly의 값을 변경할 수 있는 컨트롤을 추가해야 한다. 이번 챕터에서 바인딩을 토글 컨트롤에 전달해 수행한다. 여기서 바인딩은 변경 가능한 상태에 대한 참고 역할을 하게 된다.
사용자가 꺼짐과 켜짐을 전환하면 컨트롤은 바인딩을 사용하여 뷰의 상태를 업데이트하게 된다.
이전 챕터에서는 우리가 수동으로 showFavoritesOnly의 값을 true나 false로 지정하여 확인하였다면 이번에는 토글을 추가하여 이 작업을 사용자에게 권한을 부여하는 것이라고 생각하면 된다.
가장 먼저 LandmarkList 파일에서 내비게이션 뷰 아래에 ForEach를 이용하여 그룹을 만들어 행으로 변환해보자.
그리고 그 안에 Toggle을 추가하게 되고 여기서 showFavoritesOnly에 바인딩을 전달하게 만든다. 이에 따라 showFavoritesOnly의 디폴트 값은 false로 바꿔준다. 해당 코드는 아래와 같다.
import SwiftUI
struct LandmarkList: View {
@State private var showFavoritesOnly = false
var filteredLandmarks: [Landmark] {
landmarks.filter { landmark in
(!showFavoritesOnly || landmark.isFavorite)
}
}
var body: some View {
NavigationView {
List {
Toggle(isOn: $showFavoritesOnly) { //showFavoritesOnly에 바인딩 전달
Text("Favorites only")
}
ForEach(filteredLandmarks) { landmark in
NavigationLink {
LandmarkDetail(landmark: landmark)
} label: {
LandmarkRow(landmark: landmark)
}
}
}
.navigationTitle("Landmarks")
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}
4. Use an Observable Object for Storage
사용자가 즐겨 찾는 특정한 랜드마크를 제어하려면 먼저 랜드마크 데이터를 observable object로 저장해야 한다.
그래서 이전에 추가한 Json 파일과 그것을 닮고 있는 ModelData 파일을 중점적으로 수정을 해줄 것이다.
먼저 ModelData 파일에서 Combine을 임포트 해주고 Combine 프레임워크에서 ObservableObject 프로토콜을 준수하는 새 모델 유형을 선언한다. 여기서 Combine이라는 새로운것이 등장하게 되는데 import를 해주는것을 보아 프레임워크인것은 알겠는데 처음 보는것이라 따로 한번 알아 보자.
Combine
https://developer.apple.com/documentation/combine
Apple Developer Documentation
developer.apple.com
자세한 사항들은 위 Apple 공식 문서를 보면 확인 할 수 있다. 대략적으로 요약하면 Combine은 시간의 흐름에 따라 값을 처리할때 필요한Declarative Swift API를 제공하는 프레임워크이다. 그리고 중복되는 클로저나 가독성이 좋지 못한 코드를 제거하여 유지부수에 용이하다. 결론적으로는 필수까지는 아니지만 사용하게 된다면 코드의 수준을 높일수 있다는것이다.
이제 계속해서 코드를 이어가보자.
랜드마크 배열을 모델로 이동을 시키고, 여기에 @Published 속성을 추가하였다. ObservableObject는 사용자가 변경 사항을 선택 할 수있도록 데이터에 대한 변경 사항을 게시하여야 한다. 따라서 Published속성을 사용하는것이다.
//ModelData.swift
import Foundation
import Combine
final class ModelData: ObservableObject {
@Published var landmarks: [Landmark] = load("landmarkData.json")
}
5. Adopt the Model Object in Your Views
ModelData 객체를 생성했으니깐 이젠 뷰를 업데이트 해서 앱의 데이터 저장소로 채택해야한다.
1. LandmarkList 파일에서 @EnvironmentObject 속성을 뷰에 추가하고 environmentObject 수정자를 preview에 추가한다. 그리고 랜드마크를 필터링 할때에는 modelData.landmarks를 데이터로 사용한다.
struct LandmarkList: View {
@EnvironmentObject var modelData: ModelData
@State private var showFavoritesOnly = false
var filteredLandmarks: [Landmark] {
modelData.landmarks.filter { landmark in
(!showFavoritesOnly || landmark.isFavorite)
}
}
~~
~~
~~
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
.environmentObject(ModelData())
}
}
2. environment에서 ModelData 개체와 함께 작동하도록 LandmarkDetail과 LandmarkRow 파일의 Preview 부분을 업데이트 해야한다.
//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))
}
}
3. ContentView 미리보기를 업데이트하여 환경에 모델 개체를 추가하면 모든 하위 보기에서 개체를 사용할 수 있다. 또한 LandmarksApp을 업데이트하여 모델 인스턴스를 생성하고 이를 environmentObject 수정자를 사용해 ContentView에 제공하자.
//ContentView
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
.environmentObject(ModelData())
}
}
//LandmarksApp
import SwiftUI
@main
struct LandmarksApp: App {
@StateObject private var modelData = ModelData()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(modelData)
}
}
}
6. Create a Favorite Button for Each Landmark
랜드마크의 필터링된 View와 필터링 되지 않은 View 사이를 전환할 수는 있지만 즐겨찾기 목록은 아직까지 하드 코딩 되어있다. 따라서 사용자가 즐겨찾기를 추가 및 제거 할 수있도록 하기 위해 DetailView에 즐겨찾기 단추를 추가해야한다.
1. FaivoriteButton 파일을 하나 새롭게 만들어주고 버튼의 현재 상태를 나타내는 isSet이름의 바인딩을 추가하고 이에 대한 상수 값을 preview에 제공해준다. 그리고 isSet을 토글화 하고 상태에 따라 모양을 변경하는 작업을 위해 Buttond을 추가한다.
import SwiftUI
struct FavoriteButton: View {
@Binding var isSet: Bool // 바인딩 추가
var body: some View {
Button { // 버튼추가
isSet.toggle() //isSet상태 토글화
} label: {
Label("Toggle Favorite", systemImage: isSet ? "star.fill" : "star")
.labelStyle(.iconOnly) // 삼항 연산자를 이용해 상태에 따라 모양 변경하기
.foregroundColor(isSet ? .yellow : .gray)
}
}
}
struct FavoriteButton_Previews: PreviewProvider {
static var previews: some View {
FavoriteButton(isSet: .constant(true))
}
}
2. LandmarkDetail파일에서 랜드마크의 인덱스를 모델 데이터와 비교하여 계산하자. 그리고 방금 1번 과정에서 만든 버튼을 HStack에 포함한다. 이때 $을 사용하여 isFavorite속성에 대한 바인딩을 제공한다.
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 {
//생략
VStack(alignment: .leading) {
HStack {
Text(landmark.name)
.font(.title)
FavoriteButton(isSet: $modelData.landmarks[landmarkIndex].isFavorite)
}
//생략
struct LandmarkDetail_Previews: PreviewProvider {
static let modelData = ModelData()
static var previews: some View {
LandmarkDetail(landmark: modelData.landmarks[0])
.environmentObject(modelData)
}
}
modelData 객체와 함께 LandmarkIndex를 사용하여 버튼이 모델 객체에 저장된 랜드마크의 isFavorite 속성을 업데이트하도록 한다.
기존 list에 새로운 기능인 즐겨찾기 기능을 추가 함으로서 앱의 새로운 기능이 사용자에게 얼마나 영향을 줄 수있는지를 알 수있는 시간이였다. 이후 시간전까지 충분히 해당 파트를 익히고 넘어가보도록 노력해야겠다.