SwiftUI Essentials 챕터의 3번째 과정, 유저 인풋 값을 다뤄보자!
공식문서 링크는 여기 클릭 !
이번에도 프로젝트는 Github에 업로드 해놓았다. 🐱
랜드마크 앱에서 사용자는 즐겨찾는 장소를 표시해놓을 수 있고, 즐겨찾는 장소를 필터링해서 볼 수 있다. 해당 기능을 만들기위해서는 즐겨찾는 장소보기만 볼 수 있게 하는 switch를 추가하고, 별 모양의 버튼을 만들어서 즐겨찾기를 할 수 있게 해야한다.
사용자가 즐겨찾기를 한눈에 볼 수 있도록 하기위해 Landmark 모델에 즐겨찾는 장소인지 아닌지를 저장하는 property를 추가하자.
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
}
}
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)
}
}
}
}
리스트에서 모든 랜드마크를 보여줄 수도 있고, 사용자가 즐겨찾은 것만 보여줄 수도 있다. 이를 위해서 LandmarkList 타입에 상태를 추가해야한다. 상태(state)는 시간이 지남에 따라 바뀔수도 있고, 뷰의 동작, 콘텐츠, 레이아웃에 영향을 줄 수 있는 값 또는 값의 집합이다. 뷰에 상태를 추가하려면 @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")
}
}
}
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")
}
}
}
@State private var showFavoritesOnly = true
사용자가 리스트 필터링을 할 수 있게 하려면 showFavoritesOnly 값을 변경할 수 있는 control을 추가해야한다. 따라서 toggle control에 바인딩을 전달해야한다. 바인딩은 변경 가능한 상태에 대한 참조 역할을 하며, 사용자가 토글을 끄거나 킬때 마다 그에 따라 view의 상태를 업데이트 한다.
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")
}
}
}
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")
}
}
}
사용자가 즐겨찾는 랜드마크들을 제어하기 위해서는 먼저 랜드마크 데이터를 Observable 객체에 저장해야한다.
Observable 객체는 SwiftUI의 저장소에서 view에 바인딩할 수 있는 데이터의 사용자 지정 개체이다. SwiftUI는 view에 영향을 줄 수 있는 Observable 객체의 변경사항을 감시하고 변경 후 알맞은 view를 보여준다.
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)")
}
}
Observable 객체는 구독자가 변경사항을 선택할 수 있도록 데이터에 대한 변경사항을 게시(publish)해야한다.
@Published var landmarks: [Landmark] = load("landmarkData.json")
ModelData 객체를 만들었으니, 이제 앱의 데이터 저장소로 ModelData를 채택하고 View를 업데이트 해보자.
부모에게 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())
}
}
struct LandmarkDetail_Previews: PreviewProvider {
static var previews: some View {
LandmarkDetail(landmark: ModelData().landmarks[0])
}
}
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))
}
}
하위 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())
}
}
@StateObject 속성을 사용하여 앱의 수명주기 동안 한 번만 지정된 속성에 대한 model 객체를 초기화 시킬 수 있다.
import SwiftUI
@main
struct LandmarksApp: App {
@StateObject private var modelData = ModelData()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(modelData)
}
}
}
이제 Landmarks 앱은 즐겨찾는 랜드마크 리스트를 필터링할 수 있다. 하지만 즐겨찾는 랜드마크들은 여전히 하드코딩된 상태이다. 유저가 즐겨찾기를 추가하고 삭제할 수 있도록 상세 화면에 즐겨찾기 버튼을 만들어보자.
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))
}
}
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)
}
}
}
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)
}
}
버튼이 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)
}
}
참고
https://developer.apple.com/tutorials/swiftui/handling-user-input