...
본문 바로가기

아키텍처

TCA(1.14) - StackState & NavigationStack 간단 구현

 

요즘 TCA를 공부하고있습니다.

혹시나 TCA를 공부하려고 한다면, TCA 공식문서에 컴포넌트 개념만 보고, TCA 공식 튜토리얼 보는게 가장 낫다고 생각합니다. 튜토리얼이 굉장히 잘나와있습니다. ( 튜토리얼보면서 알게된 개념들을 간단하게 정리하려는 글도 쓰려고해요 ! )

https://pointfreeco.github.io/swift-composable-architecture/main/tutorials/meetcomposablearchitecture

 

Documentation

 

pointfreeco.github.io

 

TCA 지원이 상당하여 버전업이 굉장히 많이 이루어지고 있고, 그에따라 코드도 굉장히 많이 바뀌었습니다. 

구글링해서 나오는 자료들보면 TCA 0.40 이하 버전도 많고,  (8월27일 1.14버전 배포) 무작정 TCA 패키지 임포트해서 따라해보다가는 수많은 에러를 보게됩니다. 물론 현업에서는 최근 TCA버전을 쓰진 않겠지만, 공부목적으로 한다면 최근 문서가 좋을듯해요. 

 

 

 

 

관련 내용 추천 

TCA공식문서가면 많이 있습니다. 튜토리얼보면서 대충 어떤식으로 구현하는지 보고, 더 궁금한점은 아래 문서들 찾아보면 좋습니다

 

네비게이션 튜토리얼 

https://pointfreeco.github.io/swift-composable-architecture/main/tutorials/meetcomposablearchitecture#navigation

 

Documentation

 

pointfreeco.github.io

 

네비게이션 개념

https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/navigation

 

StackState 관련 개념 및 간단 구현

https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/stackbasednavigation

 

 

개념 

StackState라하여, TCA에서 독자적으로 만든 타입입니다. SwifUI에서 NavigationStack에서 NavigationPath를 이용하여 관리를 하는데, TCA에서는 TCA의 목적에 부합하도록 NavigationPath를 이용하지않고 StackState를 만들었다라고 보면 될것 같아요.

위의 문서에서도 SwiftUI의 NavigationPath를 사용하지 않고 StackState를 만든 이유도 포함되어있습니다. ( 타입한정적이고, for문 불가하고, 중간 삽입불가하고... 등등 여러 이유로 만듬 )

 

 

튜토리얼에서는 하나의 타입만으로 StackState에 넣어서 관리했는데, 실제 앱을 고려한다면, 다양한 타입이 들어올수 있을것 같아서 문서를 찾아보고 간단한 샘플 코드 짜봤습니다. 

 

 

 

샘플코드 

 

RootView가 있고, RootView에는 버튼들이 있고, 각각 버튼을 통하여 Push가 되는 구조입니다. 

RootView가 NavigationStack을 가지고있습니다. 

RootView -> RedView 

RootView -> BlueView -> OrangeView

 

 

 

각 뷰에 대하여 Feature ( = Reducer) 가 필요하니, 간단하게 만들어둡니다. 

 

RootFeature, RedFeature, BlueFeature, OrangeFeature 별로 만들어둡니다. 

 

@Reducer 매크로를 이용했고, 이 매크로를 통하여 ReducerProtocol을 채택하게 해줄뿐만 아니라 필요한 action, state 등등을 자동으로 만들어줍니다. 편리해요. 

@Reducer
struct RootFeature {
    
    @ObservableState
    struct State {

    }
    

    enum Action {

    }
    
    var body: some ReducerOf<Self> {
        
        Reduce { state, action in
            return .none
        }
    }
    
}


@Reducer
struct RedFeature {
    
    @ObservableState
    struct State {
        
    }
    
    enum Action {
        case clickBackButton
    }
    
    
    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .clickBackButton:
                return .none
            }
        }
    }
}



@Reducer
struct BlueFeature {
    
    @ObservableState
    struct State {
        
    }
    
    enum Action {
        case clickBackButton
        case clickNextButton
    }
    
    
    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .clickBackButton:
                return .none
            case .clickNextButton:
                return .none
            }
        }
    }
}


@Reducer
struct OrangeFeature {
    
    @ObservableState
    struct State {
        
    }
    
    enum Action {
        case clickBackButton
    }
    

    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .clickBackButton:
                return .none
            }
        }
    }
}

 

기본동작은 push, pop만 구현할거라  우선 간단하게 뼈대만 만들어두었습니다. 

 

 

 

이제 RootFeature에서 Red, Blue, Orange를 관리하도록 하는 StackState를 추가해봅니다. 

 

@Reducer
struct RootFeature {
    
    @ObservableState
    struct State {
        var path = StackState<Path.State>()
    }
    

    enum Action {
        case clickRedButton
        case clickBlueButton
        case path(StackActionOf<Path>)
    }
    
    var body: some ReducerOf<Self> {
        
        Reduce { state, action in
            return .none
        }
        .forEach(\.path, action: \.path)
    }
    
    @Reducer
    enum Path {
        case redFeature(RedFeature)
        case blueFeature(BlueFeature)
        case orangeFeature(OrangeFeature)
    }
}

 

 

 

RootFeature를 보면 하단에 내부적으로 Path enum타입을 만들었습니다.

이를통해 StackState가 이 타입을 바라보게했습니다.

Path case를 보면  Red, Blue, Orange가 있죠. 

 

직관적으로 Root에서 Path와 StackState를 이용하여 Red, Blue, Orange별로 이동가능하다라고 보면 됩니다. 

 

Path도 @Reducer 매크로를 붙였습니다. 

따라서 Path도 Reducer타입이 되었고,  여기서 재밌는점은 case별로 자식(Red, Blue, Orange)의 Feature (=Reducer)를 갖는데요, 

그렇기때문에  별도로 State, Action을 지정하지않아도 Path.State, Path.Action이 자동으로 생성됩니다. 

 

Add a case for for the “Add contact” feature. Note that we are holding onto the actual AddContactFeature reducer in the case, not the state. The Reducer() will fill in all the requirements for the reducer protocol for us automatically. (공식문서 내용)

 

 

그리고 RootFeature에, State, Action에 Path타입을 갖도록 StackState, StackActionOf를 추가해둡니다. 

Action에 clickRedButton, clickBlueButton은  RootView에서 명시적으로 액션을 전달해서 자식뷰로 넘어가려고 두개작성해놨습니다. 

 

그리고 마지막으로 body안에 Reduce에 .forEach 를 추가해두었는데요, 

이를 통해서 자식리듀서들을 생성할수있게 합니다. 

여기서 또 재밌는점은, forEach나, Scope나 , ifLet이나 클로저로 Reducer가 필요한데, 이번엔 필요하지 않습니다.

아까 위에서 보았듯이 Path case에 리듀서들을 이미 명시해두었기때문이에요. 

 

You do not need to specify `Path()` in a trailing closure of `forEach` because it can be automatically inferred from `@Reducer enum Path`.  (공식문서 내용)

 

 

 

 

 

그러면 뷰도 간단하게 코드로 작성해봅니다. 

 

import SwiftUI
import ComposableArchitecture

struct RootView: View {
    
    var store: StoreOf<RootFeature>
    
    var body: some View {
        ...
    }
}




struct RedView: View {
    
    var store: StoreOf<RedFeature>
    
    var body: some View {
        
        
        ZStack {
            Color.red
            
            VStack {
                Button("back") {
                    store.send(.clickBackButton)
                }.frame(height: 30)
                    .foregroundStyle(.black)
            }
            .navigationTitle("RedView")
        }
    }
}


struct BlueView: View {
    
    var store: StoreOf<BlueFeature>
    
    var body: some View {
        
        ZStack {
            Color.blue
            
            VStack {
                Button("back") {
                    store.send(.clickBackButton)
                }.frame(height: 30)
                    .foregroundStyle(.black)
                
                Button("Next") {
                    store.send(.clickNextButton)
                }.frame(height: 30)
                    .foregroundStyle(.black)
            }
            .navigationTitle("BlueView")
        }
        
        
    }
}


struct OrangeView: View {
    
    var store: StoreOf<OrangeFeature>
    
    var body: some View {
        
        ZStack {
            Color.orange
            VStack {
                Button("back") {
                    store.send(.clickBackButton)
                }.frame(height: 30)
                    .foregroundStyle(.black)
            }
            .navigationTitle("OrangeView")
        }
        
    }
}

 

 

 

 

각 뷰들이 Store를 가지도록 해두었고, 

자식뷰들은 간단합니다. 

버튼들을 두고, 뒤로가거나 푸시하거나 하는 코드들입니다. 

Blue -> Orange 로도 갈수있으니, Blue에는 버튼을 하나 더 두었습니다. 

Red는 뒤로가는 버튼만 두었구요.

 

 

 

이제 RootView를 손보면 되는데, 

NavigationStack을 가져야하고,  StackState를 바인딩하도록 하는 코드와,

자식뷰 코드들도 필요하죠 .

 

 

struct RootView: View {
    
    @Bindable var store: StoreOf<RootFeature>
    
    var body: some View {
        
        NavigationStack(path: $store.scope(state: \.path, action: \.path)) {
            

            VStack {
			// Push 1
                NavigationLink(state: RootFeature.Path.State.redFeature(RedFeature.State())) {
                    Text("RedView Push 1 (Link)")
                        .frame(height: 30)
                }
                
                NavigationLink(state: RootFeature.Path.State.blueFeature(BlueFeature.State())) {
                    Text("BlueView Push 1 (Link)")
                        .frame(height: 30)
                }
                
                Spacer()
                    .frame(height:50)
                    
           // Push 2     
                Button("RedView Push 2 (append)") {
                    store.send(.clickRedButton)
                }.frame(height: 30)
                
                Button("BlueView Push 2 (append)") {
                    store.send(.clickBlueButton)
                }.frame(height: 30)
                
            }
            .navigationTitle("RootFeature")
            
        } destination: { store in
            switch store.case {
            case .redFeature(let redStore):
                RedView(store: redStore)
            case .blueFeature(let blueStore):
                BlueView(store: blueStore)
            case .orangeFeature(let orangeStore):
                OrangeView(store: orangeStore)
            }
        }
    }
}

 

 

NavigationStack에는 바인딩타입이 필요하여, store를 @Bindable  프로퍼티레퍼로 수정했고, 

keyPath, CaseKeyPath를 이용하여 RootFeature의 Path을 명시했습니다. 

 ( CaseKeyPath는 TCA에서 enum - case에도 KeyPath를 적용할수있도록 만든 라이브러리입니다 ) 

 

push 방법에는 두가지가 있는데, 

하나는 NavigationLink를 이용하는 방법과 직접 StackState에 append해주는 방법이 있습니다. 

 

NavigationLink도 내부적으로 클릭이될때 StackState에 append가 됩니다. 

NavigationLink를 이용하는 방법의 단점은 View에서 자식들의 State 까지도 알아야한다는 점이 좋지 않다고 하네요. 모듈이 분리되어있을때를 고려하면 좋지 않은 구조가 된다고. 

 

그래서 아까 RootFeature Action에 clickRedButton, clickBlueButton을 두었는데, 

직접 RootView에서 액션을 보내도록 하는 방법이 2번째 방법되겠습니다.  액션을 보냈으니, RootFeature - Reduce에서도 코드수정이 필요한데, 밑에서 다루겠습니다. 

 

그리고 마지막으로 destination 클로저를 구현했는데, 

여기에 store.case를 통하여  자식들의 Store를 받을수있습니다. 

이덕분에 자식뷰가 필요한 Store를 간단하게 넘겨줄수 있습니다.  

 

push가 발생하면 destination내부 코드가 실행되면서 자식뷰로 넘어가는거죠 .

 

 

 

그럼 이제 다시 RootFeature로 돌아와서 Push, pop을 구현하도록 합니다. 

@Reducer
struct RootFeature {

    var body: some ReducerOf<Self> {
        
        Reduce { state, action in
            switch action {
                
                
// 부모뷰에서 직접 push
            case .clickRedButton:
                state.path.append(.redFeature(RedFeature.State()))
                return .none
            case .clickBlueButton:
                state.path.append(.blueFeature(BlueFeature.State()))
                return .none

// 자식 이벤트 받아서 push 
            case .path(.element(id: _, action: .blueFeature(.clickNextButton))):
                state.path.append(.orangeFeature(OrangeFeature.State()))
                return .none
                
// 자식 이벤트 받아서 pop
            case .path(.element(id: let id, action: .redFeature(.clickBackButton))):
                state.path.pop(from: id)
                return .none
            
            case .path(.element(id: let id, action: .blueFeature(.clickBackButton))):
                state.path.pop(from: id)
                return .none
                
// OrangeFeature에서 Dependency dismiss 으로 대체가능
            case .path(.element(id: let id, action: .orangeFeature(.clickBackButton))):
                state.path.pop(from: id)
                return .none
            case .path:
                return .none
            }
        }
        .forEach(\.path, action: \.path)
    }
}

 

 

Root에서 직접 액션을 전달이 왔을경우 (clickRedButton, clickBlueButton) 은

state.path에 append 하면됩니다.  자식뷰의 State를 초기화해주면 됩니다. 

 

그리고 BlueView에서 OrangeView로 push하는 경우를 가로채서 orange도 추가가능합니다. 

 

마찬가지로 Blue,Red에서 뒤로가는 액션에 대해서도 액션을 가로채어 

state.path.pop을 할수있습니다. 

튜토리얼에서는 이러한 방법보다는 자식리듀서에도 Delegate패턴을 적용하는 코드도 있으니 참고하여 리팩토링도 가능할것 같아요. 

 

 

또한 재밌는점은, pop을 할때 명시적으로 state.path.pop을 하지않고도 자식Reducer에서 처리가 가능합니다. 

 

우선 주석처리하고, 

    var body: some ReducerOf<Self> {
        
        Reduce { state, action in
            switch action {
                
			...
                
// OrangeFeature에서 Dependency dismiss 으로 대체가능
//            case .path(.element(id: let id, action: .orangeFeature(.clickBackButton))):
//                state.path.pop(from: id)
//                return .none
            case .path:
                return .none
            }
        }
        .forEach(\.path, action: \.path)
    }

 

 

OrangeFeature로 가서 아래처럼 수정합니다.

@Reducer
struct OrangeFeature {
    
    @ObservableState
    struct State {
        
    }
    
    enum Action {
        case clickBackButton
    }
    
    @Dependency(\.dismiss) var dismiss
    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .clickBackButton:
                
                return .run { _ in
                    await self.dismiss()
                }
            }
        }
    }
}

 

 

TCA에서 의존성관리를 도와주는, 프로퍼티래퍼인 @Dependency 를 이용하여 dismiss를 이용하면 stackState를 고려하지않아도 자동으로 관리하여 사라지게해줍니다.

대신 비동기이므로  return 에 Effect 타입인 .run 으로 return하면서 self.dismiss()를 호출합니다. 

@Dependency는 SwiftUI의 @Environment를 참고하여 TCA에서 만든 타입같아요. 

 

튜토리얼에는 의존성들을 @Dependency에 추가하는 방법들도 나와있으니, 튜토리얼 무조건 봐야합니다. 

 

 

궁금증..

NavigationStack은 앱에서 하나로 존재해야한다고 하는데, 하단 탭을두어서, 각각 탭에navigationStack을 두는건 가능하다고 하고.

암튼, 그렇게되면 RootFeature에서 StackState를 관리해야하는데, 그러면 뎁스가 깊어지는 경우, 모든 path관리를 RootFeature에서 해야하는건가... ?  그러면 RootFeature가 엄청 비대해질것 같다는 생각이 드는데... 실제 업무에서는 어떻게 사용하고 계신지 궁금하네요..

 

또 TCA가 Feature(리듀서)별로 모듈분리도 가능한데, 각 모듈별로 NaviationStack을 두고 개발을 하다가, 하나의 앱으로만들려면 navigationStack을 다 빼줘야하는건지.. 의문도 들긴하네요. 

 

 

 

 

 

처음부터 무식하게 TCA를 접근해서.. 여러모로 힘들었었는데, 튜토리얼보면서 조금씩 이해가 되고 있는것 같아요. 

화이팅!!