apple/TCA

[TCA] SharedState

lgvv 2023. 9. 27. 15:39

[TCA] SharedState 

 

목차

 - SharedState란?

 - SharedState 예제 코드 알아보기

 - SharedState를 통해 통합관리 vs 각각의 Reducer를 통합

 

- SharedState란?

여러 개의 독립된 화면이 합성 가능한 아키텍처에서 상태를 공유할 수 있는 방법을 제시.

 

카운터, 프로필

 

 

- SharedState 예제 코드 알아보기

// MARK: - Feature domain

struct SharedState: Reducer {
    enum Tab { case counter, profile }
    
    struct State: Equatable {
        var counter = Counter.State()
        var currentTab = Tab.counter
        
        /// The Profile.State can be derived from the Counter.State by getting and setting the parts it
        /// cares about. This allows the profile feature to operate on a subset of app state instead of
        /// the whole thing.
        /// Profile.State는 Counter.State에서 관심있는 부분을 가져와서 설정함으로써 유도할 수 있음. 이를 통해 프로필 기능은 전체가 아닌 앱 상태의 하위 집합에서 작동할 수 있음.
        var profile: Profile.State {
            get {
                // 6. ✅ 하위 뷰에서 get에 정의된 부분을 통해 상위 Reducer의 데이터를 가져갈 수 있음
                Profile.State(
                    currentTab: self.currentTab,
                    count: self.counter.count,
                    maxCount: self.counter.maxCount,
                    minCount: self.counter.minCount,
                    numberOfCounts: self.counter.numberOfCounts
                )
            }
            set {
                // 7. ✅ 하위 뷰에서 발생한 데이터 변경사항을 상위 뷰로도 전달
                self.currentTab = newValue.currentTab
                self.counter.count = newValue.count
                self.counter.maxCount = newValue.maxCount
                self.counter.minCount = newValue.minCount
                self.counter.numberOfCounts = newValue.numberOfCounts
            }
        }
    }
    
    enum Action: Equatable {
        case counter(Counter.Action) // 8. ✅ Counter에서 들어올 액션
        case profile(Profile.Action) // 9. ✅ Profile에서 들어올 액션
        case selectTab(Tab)
    }
    
    var body: some Reducer<State, Action> {
        // 9. ✅ counter를 스코프를 통해 연결 (하위뷰를 먼저 연결)
        Scope(state: \.counter, action: /Action.counter) {
            Counter()
        }
        
        // 10. ✅ profile를 스코프를 통해 연결 (하위뷰를 먼저 연결)
        Scope(state: \.profile, action: /Action.profile) {
            Profile()
        }
        
        // 11. ✅ 상위 뷰
        Reduce { state, action in
            switch action {
            case .counter, .profile:
                return .none
            case let .selectTab(tab):
                state.currentTab = tab
                return .none
            }
        }
    }
    
    // 12. ✅ Counter에서 사용할 리듀서를 정의
    struct Counter: Reducer {
        struct State: Equatable {
            @PresentationState var alert: AlertState<Action.Alert>?
            var count = 0
            var maxCount = 0
            var minCount = 0
            var numberOfCounts = 0
        }
        
        enum Action: Equatable {
            case alert(PresentationAction<Alert>)
            case decrementButtonTapped
            case incrementButtonTapped
            case isPrimeButtonTapped
            
            enum Alert: Equatable {}
        }
        
        var body: some Reducer<State, Action> {
            Reduce { state, action in
                switch action {
                case .alert:
                    return .none
                    
                case .decrementButtonTapped:
                    state.count -= 1
                    state.numberOfCounts += 1
                    state.minCount = min(state.minCount, state.count)
                    return .none
                    
                case .incrementButtonTapped:
                    state.count += 1
                    state.numberOfCounts += 1
                    state.maxCount = max(state.maxCount, state.count)
                    return .none
                    
                case .isPrimeButtonTapped:
                    state.alert = AlertState {
                        TextState(
                            isPrime(state.count)
                            ? "👍 The number \(state.count) is prime!"
                            : "👎 The number \(state.count) is not prime :("
                        )
                    }
                    return .none
                }
            }
            .ifLet(\.$alert, action: /Action.alert)
        }
    }
    
    // 12. ✅ Profile에서 사용할 리듀서를 정의
    struct Profile: Reducer {
        struct State: Equatable {
            private(set) var currentTab: Tab
            private(set) var count = 0
            private(set) var maxCount: Int
            private(set) var minCount: Int
            private(set) var numberOfCounts: Int
            
            fileprivate mutating func resetCount() {
                self.currentTab = .counter
                self.count = 0
                self.maxCount = 0
                self.minCount = 0
                self.numberOfCounts = 0
            }
        }
        
        enum Action: Equatable {
            case resetCounterButtonTapped
        }
        
        func reduce(into state: inout State, action: Action) -> Effect<Action> {
            switch action {
            case .resetCounterButtonTapped:
                state.resetCount()
                return .none
            }
        }
    }
}

// MARK: - Feature view

struct SharedStateView: View {
    let store: StoreOf<SharedState>
    
    var body: some View {
        // 1. ✅ 해당 뷰에서는 Tab을 observe하고 아래 3번에서 state를 분기하여 주입
        WithViewStore(self.store, observe: \.currentTab) { viewStore in
            VStack {
                Picker(
                    "Tab",
                    selection: viewStore.binding(send: SharedState.Action.selectTab) // 2. ✅ selection을 선택한 탭 액션에 바인딩
                ) {
                    Text("Counter")
                        .tag(SharedState.Tab.counter)
                    
                    Text("Profile")
                        .tag(SharedState.Tab.profile)
                }
                .pickerStyle(.segmented)
                
                // 3. ✅ currentTab의 상태에 따라서 state로 분리
                if viewStore.state == .counter {
                    SharedStateCounterView(
                        store: self.store.scope(state: \.counter, action: SharedState.Action.counter))
                }
                
                if viewStore.state == .profile {
                    SharedStateProfileView(
                        store: self.store.scope(state: \.profile, action: SharedState.Action.profile))
                }
                
                Spacer()
            }
        }
        .padding()
    }
}

struct SharedStateCounterView: View {
    // 4. ✅ SharedState의 Counter
    let store: StoreOf<SharedState.Counter>
    
    var body: some View {
        WithViewStore(self.store, observe: { $0 }) { viewStore in
            VStack(spacing: 64) {
                Text(template: readMe, .caption)
                
                VStack(spacing: 16) {
                    HStack {
                        Button {
                            viewStore.send(.decrementButtonTapped)
                        } label: {
                            Image(systemName: "minus")
                        }
                        
                        Text("\(viewStore.count)")
                            .monospacedDigit()
                        
                        Button {
                            viewStore.send(.incrementButtonTapped)
                        } label: {
                            Image(systemName: "plus")
                        }
                    }
                    
                    Button("Is this prime?") { viewStore.send(.isPrimeButtonTapped) }
                }
            }
            .padding(.top)
            .navigationTitle("Shared State Demo")
            .alert(store: self.store.scope(state: \.$alert, action: { .alert($0) }))
        }
    }
}

struct SharedStateProfileView: View {
    // 5. ✅ SharedState의 Profile
    let store: StoreOf<SharedState.Profile>
    
    var body: some View {
        WithViewStore(self.store, observe: { $0 }) { viewStore in
            VStack(spacing: 64) {
                Text(
                    template: """
            This tab shows state from the previous tab, and it is capable of reseting all of the \
            state back to 0.
            
            This shows that it is possible for each screen to model its state in the way that makes \
            the most sense for it, while still allowing the state and mutations to be shared \
            across independent screens.
            """,
                    .caption
                )
                
                VStack(spacing: 16) {
                    Text("Current count: \(viewStore.count)")
                    Text("Max count: \(viewStore.maxCount)")
                    Text("Min count: \(viewStore.minCount)")
                    Text("Total number of count events: \(viewStore.numberOfCounts)")
                    Button("Reset") { viewStore.send(.resetCounterButtonTapped) }
                }
            }
            .padding(.top)
            .navigationTitle("Profile")
        }
    }
}

// MARK: - SwiftUI previews

struct SharedState_Previews: PreviewProvider {
    static var previews: some View {
        SharedStateView(
            store: Store(initialState: SharedState.State()) {
                SharedState()
            }
        )
    }
}

// MARK: - Private helpers

/// Checks if a number is prime or not.
private func isPrime(_ p: Int) -> Bool {
    if p <= 1 { return false }
    if p <= 3 { return true }
    for i in 2...Int(sqrtf(Float(p))) {
        if p % i == 0 { return false }
    }
    return true
}

 

SharedStateView는 CurrentTab에 대한 정보를 관리하고, ViewWithStore에서 SharedReducer의 tab 정보만 가지고 있는다.

 

SharedStateView 하위에 ProfileView와 CounterView를 두고 있다.

 

 

 

- SharedState를 통해 통합관리 vs 각각의 Reducer를 통합

위의 예제는 Reducer를 채택한 SharedState 통해서 통합적으로 관리된다. 

그러나 CounterView와 ProfileView가 각각 독립적으로 Reducer를 가지는 방법도 존재한다.

이 경우에는 Delegate를 통해서 위와 동일한 기능으로 구현할 수 있다.

 

Delegate를 사용하는 방식은 추후에 Effect를 학습하면서 기술할 예정이다.

이 부분이 궁금하다면 TCA Tutorial의 ContactFeature를 참고하기 바란다.

 

 

(참고)

https://github.com/pointfreeco/swift-composable-architecture/blob/main/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-SharedState.swift

 

'apple > TCA' 카테고리의 다른 글

[TCA] Effect #2 (Cancellation)  (0) 2023.10.07
[TCA] Effect #1 (Basics)  (1) 2023.10.07
[TCA] OptionalState (IfLetCase)  (0) 2023.09.27
[TCA] FocusState  (0) 2023.09.27
[TCA] Binding  (0) 2023.09.27