[Swift] TCA Navigation (StackState)

건희·2024년 12월 30일
0

Swift에서 Navigation은 사용자가 앱 내에서 화면을 탐색하고 다양한 계층 구조를 통해 이동할 수 있도록 지원하는 중요한 요소이다.

애플의 SwiftUI 프레임워크에서는 NavigationStack과 NavigationLink를 사용하여 내비게이션을 구현할 수 있다.

앱 내에서 계층적 구조의 스택을 관리하는 컨테이너 역할을 수행한다. 화면을 스택에 push 하거나 pop하면서 이동 가능하며, 이러한 구조를 통해 이전 화면으로 돌아가는 기능을 제공한다.

NavigationStack {
	// MARK: View 정의, NavigationLink를 통해 다음 뷰로 이동 가능
}

사용자가 특정 뷰를 탭하거나 선택 시, 다른 화면으로 이동하도록 연결해주는 역할을 수행한다.
NavigationLink(destination:) 을 사용하여 이동할 대상 뷰를 지정할 수 있다.

NavigationLink(destination: ExampleView()) {
	Text("뷰 이동")
}

TCA에서의 NavigationStack

ComposableArchitecture 라이브러리는 NavigationStack 에 extension을 통해 새로운 생성자를 제공한다.
이 생성자는 아래와 같다.

public init<State, Action, Destination: View, R>(
    path: Binding<Store<StackState<State>, StackAction<State, Action>>>,
    root: () -> R,
    @ViewBuilder destination: @escaping (Store<State, Action>) -> Destination,
    fileID: StaticString = #fileID,
    filePath: StaticString = #filePath,
    line: UInt = #line,
    column: UInt = #column
  )
  where
    Data == StackState<State>.PathView,
    Root == ModifiedContent<R, _NavigationDestinationViewModifier<State, Action, Destination>>
  {
    self.init(
      path: path[
        fileID: _HashableStaticString(rawValue: fileID),
        filePath: _HashableStaticString(rawValue: filePath),
        line: line,
        column: column
      ]
    ) {
      root()
        .modifier(
          _NavigationDestinationViewModifier(
            store: path.wrappedValue,
            destination: destination,
            fileID: fileID,
            filePath: filePath,
            line: line,
            column: column
          )
        )
    }
  }

일반적으로는 아래와 같이 쓰인다.

NavigationStack(path: self.$store.scope(state:\.path, action: \.path)) {
                    VStack {
                    
                    }
                    .toolbar(.hidden, for: .navigationBar)
                } destination: { store in
                    switch store.case {
                    case .login(let store) :
                        LoginView(store: store)
                            .toolbar(.hidden, for: .navigationBar)
                    case .profileSetting(let store):
                        ProfileSettingView(store: store)
                            .toolbar(.hidden, for: .navigationBar)
                    case .aljoTab(let store):
                        AppTabView(store: store)
                            .toolbar(.hidden, for: .navigationBar)
                    }
                }
            }

여기에서 store.path 가 뭐지? 라는 생각이 든다. 다음으로는 이 store.path에 대해 보도록 하자.

  1. 이 path라는 녀석은 아래와 같이 store의 State 중 하나로, StackState<Path.State>() 로 초기화 된다.
@Reducer
@ObservableState
struct State {
    var path = StackState<Path.State>()
}
  1. Action에는 StackActionOf<Path>를 추가해준다.
@CasePathable
enum Action: BindableAction {
    case path(StackActionOf<Path>)
    case binding(BindingAction<State>)
}
  1. Path는 다음과 같이 destination으로 사용될 뷰의 상태를 정의하는 열거형으로 구성된다.
@Reducer
enum Path {
    case login(LoginReducer)
    case profileSetting(ProfileSettingReducer)
    case appTab(AppTabReducer)
}

기존 Reducer + Scope 방식과 Path 방식의 차이

기존 Reducer에서 Scope()를 사용하여 여러 하위 Reducer를 연결할 때는 각 하위 Reducer에 대해 case를 명시적으로 정의하고, action을 해당 case와 연결해야 한다.

기존 Scope 방식 예시:

struct AppReducer: Reducer {
    struct State: Equatable {
        var loginState: LoginState?
        var profileSettingState: ProfileSettingState?
    }

    enum Action: Equatable {
        case login(LoginAction)
        case profileSetting(ProfileSettingAction)
    }

    var body: some Reducer<State, Action> {
        Scope(state: \State.loginState, action: /Action.login) {
            LoginReducer()
        }
        Scope(state: \State.profileSettingState, action: /Action.profileSetting) {
            ProfileSettingReducer()
        }
    }
}

Path 방식의 장점:
Path 방식을 사용하면 내비게이션 상태와 화면 전환을 일관되게 상태로 관리할 수 있으며, 상태 기반으로 동적으로 화면을 추가하거나 제거하는 것이 쉽다.

@Reducer
struct AppReducer {
    struct State: Equatable {
        var path: StackState<Path.State> = .init()
    }

    enum Action: Equatable {
        case path(StackActionOf<Path>)
    }

    var body: some Reducer<State, Action> {
        Reduce { state, action in
            return .none
        }
        .forEach(\.path, action: \.path) {
            Path()
        }
    }
}

store의 Reduce 클로저에 .forEach(.path, action: .path) 를 붙여주는데, 이 코드가 없으면, 마치 Reducer body 내부에 BindingReducer()가 없는 것과 같이 바인딩이 제대로 이루어지지 않는다.

profile
💻 🍎

0개의 댓글