새벽의 기록

[iOS] TCA Zero-to-Hero #5 - Composition: 쪼개진 뷰와 리듀서 합치기 (Scope) 본문

[iOS]

[iOS] TCA Zero-to-Hero #5 - Composition: 쪼개진 뷰와 리듀서 합치기 (Scope)

OneTen 2025. 12. 27. 23:13

목표: 지금껏 만든 카운터를 앱의 첫 번째 탭에 넣고, 두 번째 탭에는 새로운 기능을 넣어서 진짜 앱다운 구조를 만든다.


ProfileFeature.swift 만들기

import SwiftUI
import ComposableArchitecture

@Reducer
struct ProfileFeature {
    @ObservableState
    struct State: Equatable {
        var nickname = "Guest"
    }
    
    enum Action: BindableAction {
        case binding(BindingAction<State>)
    }
    
    var body: some Reducer<State, Action> {
        BindingReducer()
    }
}

struct ProfileView: View {
    @Bindable var store: StoreOf<ProfileFeature>
    
    var body: some View {
        Form {
            Section {
                TextField("닉네임", text: $store.nickname)
                Text("반갑습니다, \(store.nickname)님! 👋")
            } header: {
                Text("내 정보")
            }
        }
    }
}

 

 

CounterFeature ProfileFeature를 관리할 상위 리듀서 AppFeature.swift 만들기

import SwiftUI
import ComposableArchitecture

@Reducer
struct AppFeature {
    @ObservableState
    struct State: Equatable {
        var tab1 = CounterFeature.State()
        var tab2 = ProfileFeature.State()
    }
    
    enum Action {
        case tab1(CounterFeature.Action)
        case tab2(ProfileFeature.Action)
    }
    
    var body: some Reducer<State, Action> {
        Scope(state: \.tab1, action: \.tab1) {
            CounterFeature()
        }
        
        Scope(state: \.tab2, action: \.tab2) {
            ProfileFeature()
        }
        
        // 자식들이 뭘 하든 부모가 감시하고 싶을 때 여기에 작성.
        Reduce { (state: inout State, action: Action) in
            switch action {
            // 자식(tab1)에게서 incrementButtonTapped 액션이 발생하면
            case .tab1(.incrementButtonTapped):
                // 부모가 개입해서 다른 자식(tab2)의 State를 수정
                if state.tab1.count >= 10 {
                    state.tab2.nickname = "숫자 세기 고수 🏅"
                } else {
                    state.tab2.nickname = "게스트"
                }
                return .none
                
            default:
                return .none
            }
        }
    }
}

struct AppView: View {
    let store: StoreOf<AppFeature>
    
    var body: some View {
        TabView {
            CounterView(store: store.scope(state: \.tab1, action: \.tab1))
                .tabItem {
                    Label("카운터", systemImage: "number.circle")
                }
            
            ProfileView(store: store.scope(state: \.tab2, action: \.tab2))
                .tabItem {
                    Label("프로필", systemImage: "person.circle")
                }
        }
    }
}

 

scope: 부모와 자식을 연결하는 다리

 "tab1 액션이 들어오면 -> tab1 State를 가지고 -> CounterFeature 로직을 돌려라"

 

 

 

근데 이 부분에서 

 

 

이런 식으로 타입추론을 밤티처럼 해서 클로저에 타입을 직접 명시하긴 했는데, tca의 고질병인가??

이거 생각보다 되게 불편하네. 매번 타입을 명확하게 명시해줘야 하는건가.... 흠

 

 

 

앱 시작점 바꾸기

import SwiftUI
import ComposableArchitecture

@main
struct TCA_Simple_TutorialApp: App {
    static let store = Store(initialState: AppFeature.State()) {
        AppFeature()
            ._printChanges()
    }
    
    var body: some Scene {
        WindowGroup {
            AppView(store: TCA_Simple_TutorialApp.store)
        }
    }
}

 

 

 

Comments