본문 바로가기
iOS/SwiftUI

[SwiftUI Tutorials] Handling user input

by Kelly Chui 2025. 8. 23.

랜드마크 앱에서 사용자는 좋아하는 장소를 플래그하고, 그들이 좋아하는 장소만 필터해서 볼 수 있다. 이런 기능을 만들기 위해서, 리스트에 스위치를 추가하여 유저들이 그들이 좋아하는 장소에만 포커스를 맞출 수 있도록 해야한다. 그리고 별 모양 버튼을 추가하고, 탭하면 유저들이 랜드마크에 favorite 플래그를 할 수 있도록 한다.

Section 1. Mark favorite landmarks

리스트를 개선해서 사람들이 한 눈에 favorite를 찾을 수 있도록 시작한다. Landmark 스트럭처에 프로퍼티를 하나 추가해서, 이 랜드마크가 favorite인지 초기 상태를 읽을 수 있도록 한다. 그리고 favorite 표시된 랜드마크에는 별 아이콘을 보여준다.

Step 1, 2

isFavorite 프로퍼티를 Landmark 스트럭처에 추가한다.

struct Landmark: Hashable, Codable, Identifiable {
        ...
    var isFavorite: Bool
    ...
}

landmarkData 파일은 이러한 이름의 키를 각각의 landmark마다 가지고 있다. LandmarkCodable을 컨펌하기 때문에, 해당 키와 동일한 이름을 가진 프로퍼티를 새로 생성함으로써 key와 연관된 값을 읽어올 수 있다.

Step 3, 4

LandmarkRow에서 현재 랜드마크가 favorite인지 아닌지 판단하기 위해 if 구문 안에 별 이미지를 넣는다.

struct LandmarkRow: View {
    var landmark: Landmark
    var body: some View {
        HStack {
            ...
            Spacer()
            if landmark.isFavorite {
                Image(systemName: "star.fill")
            }
        }
    }
}

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

Step 5

시스템 이미지들은 벡터 기반이기 때문에, foregroundSytle(_:) modifier를 이용해 색상을 변경할 수 있다.

if landmark.isFavorite {
    Image(systemName: "star.fill")
        .foregroundColor(Color.yellow)
}

랜드마크의 isFavorite 프로퍼티가 true일 때, 별이 표시된다.

Section 2. Filter the list view

리스트 뷰를 모든 랜드마크를 보여주거나, 유저의 favorite만 보여주도록 커스터마이즈 할 수 있다. 이를 위해 LandmarkList 타입에 state를 추가해야 한다.

State는 시간에 따라 변화할 수 있는 값 혹은 값들의 셋이다. 그리고 그 변화는 뷰의 동작, 컨텐트 혹은 레이아웃에 영향을 끼치게 된다. 프로퍼티에 @State를 사용해서 뷰에 state를 추가할 수 있다.

Step 1, 2

false로 초기화 된 @State 프로퍼티 showFavoritesOnlyLandmarkList에 추가한다.

struct LandmarkList: View {
    @State private var showFavoritesOnly = false
    var body: some View {
            ...
    }
}

state 프로퍼티를 해당 뷰와 그 서브뷰에서 쓰는 정보를 저장하기 때문에, state를 항상 private 로 선언한다.

Step 3

프로퍼티를 더하거나 수정한 것과 같이 뷰의 구조에 변화를 주면, 캔버스는 자동으로 리프레시 된다.

Step 4, 5

showFavoritesOnly 프로퍼티와 각 landmark.isFavorite 값을 확인해서 필터된 랜드마크 리스트를 계산한다.

그리고 필터된 랜드마스크 리스트를 List에서 사용한다.

import SwiftUI

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

Step 6

showFavoriteOnly 의 초기 값을 true로 변경시키면서 리스트가 어떻게 반응하는지 확인한다.

Section 3. Add a control to toggle the state

사용자가 리스트의 필터를 컨트롤 할 수 있게 하려면, showFavoriteOnly의 값을 변화시킬 수 있는 컨트롤을 추가할 필요가 있다. 이는 바인딩을 토클 컨트롤에 전달해서 할 수 있다.

바인딩은 변화 가능한 상태의 참조처럼 동작한다. 유저가 토글을 off에서 on으로, 그리고 다시 off로 탭했을 때, 컨트롤은 바인딩을 통해 뷰의 상태를 그에 맞게 업데이트한다.

Step 1

landmark를 row로 바꾸기 위해 중첩된 ForEach 그룹을 생성한다.

struct LandmarkList: View {
        ...
    var body: some View {
        NavigationSplitView {
            List {
                ForEach(filteredLandmarks) { landmark in
                    NavigationLink {
                        LandmarkDetail(landmark: landmark)
                    } label: {
                        LandmarkRow(landmark: landmark)
                    }
                }
            }
            .navigationTitle("Landmarks")
        } detail: {
            Text("Select a Landmark")
        }
    }
}

스태틱 뷰와 다이나믹 뷰를 조합하거나, 혹은 두 개 이상의 그룹의 다이나믹 뷰를 조합하려면, List에 데이터의 컬렉션을 전달하는 대신에 ForEach 타입을 사용한다.

Step 2

List의 뷰의 첫 번째 차일드 뷰로 Toggle 뷰를 추가하고, showFavoritesOnly 를 전달한다.

struct LandmarkList: View {
    ...
    var body: some View {
        NavigationSplitView {
            List {
                Toggle(isOn: $showFavoritesOnly) {
                    Text("Favorites only")
                }
                ...
            }
            .navigationTitle("Landmarks")
        } detail: {
            Text("Select a Landmark")
        }
    }
}

$ 프리픽스는 상태 변수나, 그 상태 변수의 프로퍼티에 대한 바인딩에 접근할 때 사용한다.

Step 3

animation(_:) modifier를 추가해서 filteredLandmarks 값이 변할 때 시작되는 필터링 애니메이션을 향상시킨다.

struct LandmarkList: View {
        ...
    var body: some View {
        NavigationSplitView {
            List {
                                ...
            }
            .animation(.default, value: filteredLandmarks)
            .navigationTitle("Landmarks")
        } detail: {
            Text("Select a Landmark")
        }
    }
}

Step 4, 5

showsFavoritesOnly 를 다시 false 로 바꾸고, 프리뷰를 이용해서 토글을 탭하여 새 기능을 시도해본다.

Section 4. Use observation for storage

사용자가 특정 랜드마크를 즐겨찾기로 설정할 수 있도록 하려면, 먼저 랜드마크 데이터를 Observable() 매크로를 사용해서 저장한다.

Observation을 사용하면, SwiftUI 뷰는 프로퍼티 래퍼나 바인딩 없이 데이터 변경을 처리할 수 있다. SwiftUI는 뷰에 영향을 줄 수 있는 observable 프로퍼티의 변경을 감지하고, 변경 이후에 알맞은 버전의 뷰를 표시한다.

Step 1, 2

ModelData 파일에서 Observable() 매크로를 사용해서 새로운 모델 타입을 선언한다.

@Observable
class ModelData {
}

SwiftUI는 observable 프로퍼티가 변하고, 뷰의 body가 직접 읽고 있을 때만 뷰를 업데이트한다.

Step 3

landmarks 배열을 모델 안으로 이동시킨다.

@Observable
class ModelData {
    var landmarks: [Landmark] = load("landmarkData.json")
}

Section 5. Adopt the model object in your views

만든 ModelData 객체를 앱의 데이터 저장소로 사용하도록 뷰들을 업데이트 해야한다.

Step 1

LandmarkList 에서 @Environment 프로퍼티 래퍼를 뷰에 추가한다. 그리고 environment(_:) modifier를 프리뷰에 추가한다.

struct LandmarkList: View {
    @Environment(ModelData.self) var modelData
    ...
}

#Preview {
    LandmarkList()
        .environment(ModelData())
}

environment(_:) modifier가 부모에 적용되어 있으면, modelData 프로퍼티는 자동으로 값을 받는다. @Environment 프로퍼티 래퍼는 현재 뷰에서 모델 데이터를 읽게 해준다. environment(_:) modifier를 추가하면, 데이터 객체가 environment을 통해서 하위 뷰로 전달된다.

Step 2

modelData.landmarks 를 랜드마크를 필터링 하는 데이터로 사용한다.

struct LandmarkList: View {
    @Environment(ModelData.self) var modelData
    ...
    var filteredLandmarks: [Landmark] {
        modelData.landmarks.filter { landmark in
            !showFavoritesOnly || landmark.isFavorite
        }
    }
    ...
}

Step 3, 4

LandmarkDetail 뷰를 environment에 있는 ModelData 객체와 동작하도록 업데이트 한다.

#Preview {
    LandmarkDetail(landmark: ModelData().landmarks[0])
}

LandmarkRow 프리뷰를 ModelData 객체와 동적하도록 업데이트 한다.

#Preview {
    let landmarks = ModelData().landmarks
    return Group {
        LandmarkRow(landmark: landmarks[0])
        LandmarkRow(landmark: landmarks[1])
    }
}

Step 5

ContentView 프리뷰를 environment에 있는 모델 객체를 추가해서 업데이트한다. 해당 객체를 모든 하위 뷰에서 사용할 수 있도록 한다.

#Preview {
    ContentView()
        .environment(ModelData())
}

프리뷰에 있는 뷰가 environment(_:) modifier를 적용하지 않았는데, 해당 뷰의 서브 뷰가 environment에 등록된 모델 객체를 필요로 하면, 프리뷰가 실패하게 된다.

Step 6

시뮬레이터나 실제 기기에서 앱을 실행할 때, 모델 객체를 environment에 넣을 수 있도록 앱 인스턴스를 수정한다.

모델 인스턴스를 생성하고, environment(_:) modifier를 사용해서 ContentView 에 전달하도록 LandmarksApp 을 업데이트한다.

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

모델 객체를 초기화 할 때, 뷰 내부의 프로퍼티를 초기화 하는 것과 똑같은 방법으로 모델 오브젝트를 @State 어트리뷰트를 사용해서 초기화 한다.

SwiftUI가 뷰의 생명주기 동안 한 번만 초기화 하듯이, 앱의 state도 한 번만 초기화 된다.

Step 7

LandmarkList로 돌아와서 모든 것이 정확하게 동작하는지 확인한다.

 

Section 6. Create a favorite button for each landmark

랜드마크 앱은 이제 필터된 뷰와 필터되지 않은 뷰를 스위치 할 수 있다. 하지만 favorite 랜드 마크는 여전히 하드 코딩되어있다. 유저가 favorite를 추가하고 삭제할 수 있도록, 랜드마크의 상세 뷰에 favorite 버튼을 추가한다.

Step 1, 2

FavoriteButton.swift 뷰를 만들고, 버튼의 현재 상태를 나타내는 isSet 바인딩을 추가한다. 그리고 프리뷰에 상수 값을 제공한다.

import SwiftUI

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

#Preview {
    FavoriteButton(isSet: .constant(true))
}

binding 프로퍼티 래퍼는 데이터를 저장하는 프로퍼티와, 데이터를 표시하고 수정하는 뷰 사이에서 읽기와 쓰기를 가능하게 해준다. binding을 사용하기 때문에, 이 뷰 안에서 이루어진 변경은 원래 데이터 소스로 다시 전달된다.

Step 3

isSet state를 토글하는 액션을 가진 버튼을 만든다. 그리고 버튼의 모양을 isSet state에 따라 모양이 바뀌게 만든다.

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)
        }
    }
}

버튼의 레이블에 제공한 title 스트링은 iconOnly 레이블을 사용하면 UI에 표시되지 않는다, 하지만 보이스오버는 접근성을 향상시키기 위해 이를 이용한다.

Step 4

프로젝트가 성장하면, 계층을 추가하는 것이 좋은 아이디어다. 계속 진행하기 전에 그룹을 몇개 더 만들고 간다.

CircleImage, MapView, FavoriteButton 파일을 Helpers 그룹에, 그리고 랜드마크 뷰들을 Landmarks 그룹에 모은다.

Step 5

FavoriteButton 을 디테일 뷰에 추가한다. 버튼의 isSet 프로퍼티를 해당 랜드마크의 isFavorite 프로퍼티에 바인딩한다.

LandmarkDetail 뷰로 넘어가서, 모델 데이터와 비교하여 입력으로 받은 랜드마크의 인덱스를 계산한다.

struct LandmarkDetail: View {
    @Environment(ModelData.self) var modelData
    var landmark: Landmark
    var landmarkIndex: Int {
        modelData.landmarks.firstIndex(where: { $0.id == landmark.id })!
    }
    ...
}

#Preview {
    let modelData = ModelData()
    return LandmarkDetail(landmark: modelData.landmarks[0])
        .environment(modelData)
}

이를 위해서 environment의 모델 데이터에도 접근할 필요가 있다.

Step 6

body 프로퍼티 내부에서, Bindable 래퍼를 사용하는 모델 데이터를 추가한다. 랜드마크 이름을 HStack으로 감싸서 FavoriteButton과 함께 배치한다. 버튼에는 $를 사용해서 isFavorite 프로퍼티에 대한 바인딩을 전달한다.

import SwiftUI

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)
    }
}

modelData 객체의 landmarkIndex 를 사용해서 원본 모델 데이터의 isFavorite 프로퍼티가 업데이트 되도록 한다.

Step 7

LandmarkList 로 돌아와서, 라이브 프리뷰가 동작하는지 확인한다.

리스트에서 상세 뷰로 이동해서 버튼을 탭하면, 리스트로 돌아와도 그 변경사항이 유지된다. 두 뷰가 Environment에 있는 같은 모델 객체를 사용하고 있기 때문에 두 화면이 항상 일관성을 유지한다.


이 포스트는 SwiftUI Tutorial 내용을 직접 번역하고 실습한 내용을 주석을 달아 정리한 것입니다. 
출처: SwiftUI Tutorials

 

'iOS > SwiftUI' 카테고리의 다른 글

[SwiftUI Tutorials] Building lists and navigation  (0) 2025.08.23
[SwiftUI Tutorials] Creating and combining views  (0) 2025.08.23