ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • SwiftUI TCA: 내비게이션 페이지 이동 (패스, 스택)
    공부/Swift(프로그래밍 언어) 2026. 1. 31. 00:52
    반응형

    PointTreeCo 의 공식 예제를 바탕으로 설명합니다.

    SwiftUI Case Studies: Navigation Stack
     

    swift-composable-architecture/Examples/CaseStudies/SwiftUICaseStudies/04-NavigationStack.swift at main · pointfreeco/swift-comp

    A library for building applications in a consistent and understandable way, with composition, testing, and ergonomics in mind. - pointfreeco/swift-composable-architecture

    github.com

     

    구조

    • 맨 처음에 루트 뷰 (Navigation Demo) 가 있고, 그 아래로 스크린 A, B, C 가 있습니다.
    • 페이지는 A, B, C 순차적으로 이동할 수도 있고, A에서 C로 가거나, B 에서 A로 가는 등 스크린 A~C 내에서 다양한 이동 경로를 가질 수 있습니다.
    • 각 뷰에는 해당 뷰에서 사용하는 도메인(피처) 구조체가 있습니다. 각각은 상태 및 액션, 리듀서를 가집니다.

     

     

    NavigationDemo 

    도메인

    @Reducer
    struct NavigationDemo {
      @Reducer
      enum Path {
        case screenA(ScreenA)
        case screenB(ScreenB)
        case screenC(ScreenC)
      }
    
      @ObservableState
      struct State: Equatable {
        var path = StackState<Path.State>()
      }
    
      enum Action {
        case goBackToScreen(id: StackElementID)
        case goToABCButtonTapped
        case path(StackActionOf<Path>)
        case popToRoot
      }
    
      var body: some Reducer<State, Action> {
        Reduce { state, action in
          switch action {
            // ... //
          }
        }
        .forEach(\.path, action: \.path)
      }
    }
    
    // Path.State가 Equatable 준수하도록 함
    extension NavigationDemo.Path.State: Equatable {}

    @Reducer enum Path { ... }

    • NavigationDemo 리듀서 안에 미니 리듀서 Path를 넣습니다. enum에 @Reducer를 붙이면 자동으로 상태, 액션, 리듀서가 생성됩니다.
    • 각 케이스의 파라미터 값으로 하위 페이지에 대한 도메인을 지정합니다. (ScreenA는 도메인 구조체 struct ScreenA를 뜻함)

    var path = StackState<Path.State>()

    • 내비게이션의 경로(Path) 상태를 저장합니다.
    • 경로는 순차적일수도 있고, 다양하게 가질 수 있습니다. (예: A - A - C - B - C - A 등) 

    enum Action { }

    • goBackToScreen(id: StackElementID)
      • 뒤로가기 버튼에 대한 액션 (StackElementID는 TCA에서 제공되는 아이디 타입입니다.)
      • 팝(맨 끝 꺼내기): 리듀서 바디에서 state.path.pop(to: id)
    • goToABCButtonTapped
      • 스택에 A, B, C,를 순차적으로 넣습니다. 결과적으로 스크린 C로 이동합니다.
      • 삽입: 리듀서 바디에서 state.path.append()
    • path(StackActionOf<Path>)
      • 자식 화면에서 발생한 액션이 전달된 경우에 실행할 액션
    • popToRoot
      • 스택에 있는 모든 경로를 꺼내고 메인 페이지로 이동
      • 모두 제거: 리듀서 바디에서 state.path.removeAll()

     

    리듀서

    var body: some Reducer<State, Action> {
        Reduce { state, action in
          switch action {
          case let .goBackToScreen(id):
            state.path.pop(to: id)
            return .none
    
          case .goToABCButtonTapped:
            state.path.append(.screenA(ScreenA.State()))
            state.path.append(.screenB(ScreenB.State()))
            state.path.append(.screenC(ScreenC.State()))
            return .none
    
          case let .path(action):
            switch action {
            case .element(id: _, action: .screenB(.screenAButtonTapped)):
              state.path.append(.screenA(ScreenA.State()))
              return .none
    
            case .element(id: _, action: .screenB(.screenBButtonTapped)):
              state.path.append(.screenB(ScreenB.State()))
              return .none
    
            case .element(id: _, action: .screenB(.screenCButtonTapped)):
              state.path.append(.screenC(ScreenC.State()))
              return .none
    
            default:
              return .none
            }
    
          case .popToRoot:
            state.path.removeAll()
            return .none
          }
        }
        .forEach(\.path, action: \.path)
      }

    case let .path(action):

    • 지금 받은 액션이 NavigationStack경로 관련 액션임을 의미합니다.
    • 즉, 자식 화면에서 발생한 액션이 전달된 경우입니다.

    case .element(id: _, action: .screenB(.screenAButtonTapped))

    • 스택 안의 어떤 요소(element)의 액션
    • 그 액션이 screenB에서 발생한 .screenAButtonTapped 인 경우를 찾음
      • “ScreenB에서 A로 이동시키는 버튼이 눌렸다” 를 의미

    .forEach(\.path, action: \.path)

    • “path에 들어 있는 모든 화면을 각각의 Reducer로 돌려라” 라는 의미
    • 컬렉션 형태의 상태를 각 요소별 Reducer에 연결
    • path에 ScreenA, ScreenB, ScreenC가 쌓여 있으면 각각에 대해 Destination Reducer가 실행됨
    • path 안의 State, 해당 State를 처리할 Reducer를 이어주는 다리 역할
      • 이 부분이 없으면 State는 있으나 Reducer가 없고, Action을 받아줄 곳이 없음
      • scope와의 차이: scope를 미리 만든 것이 아니라 path에 쌓일 때마다 “자동으로” 리듀서를 할당

     

    뷰 (SwiftUI)

    struct NavigationDemoView: View {
      @Bindable var store: StoreOf<NavigationDemo>
    
      var body: some View {
        NavigationStack(path: $store.scope(state: \.path, action: \.path)) {
          Form {
            Section { Text(template: readMe) }
    
            Section {
              NavigationLink(
                "Go to screen A",
                state: NavigationDemo.Path.State.screenA(ScreenA.State())
              )
              NavigationLink(
                "Go to screen B",
                state: NavigationDemo.Path.State.screenB(ScreenB.State())
              )
              NavigationLink(
                "Go to screen C",
                state: NavigationDemo.Path.State.screenC(ScreenC.State())
              )
            }
    
            Section {
              Button("Go to A → B → C") {
                store.send(.goToABCButtonTapped)
              }
            }
          }
          .navigationTitle("Root")
        } destination: { store in
          switch store.case {
          case let .screenA(store):
            ScreenAView(store: store)
          case let .screenB(store):
            ScreenBView(store: store)
          case let .screenC(store):
            ScreenCView(store: store)
          }
        }
        .safeAreaInset(edge: .bottom) {
          FloatingMenuView(store: store)
        }
        .navigationTitle("Navigation Stack")
      }
    }

    NavigationStack

    • path에 scope를 지정 (상태 path, 액션 path 만 사용하겠다는 의미)
    • 두 개의 클로저가 있는데 첫번째(root)는 화면에 보여질 View, 두 번째(destination)에는 액션이 실행되었을 경우 어느 뷰로 이동할지 지정합니다.
      • store는 위에서 지정한 scope가 들어갑니다.

     

    NavigationLink

      NavigationLink(
        "스크린 A로",
        state: MainDomain.Path.State.screenA(ScreenADomain.State())
      )
    • 첫번째 파라미터: 표시할 텍스트
    • state: 해당 목적지 뷰에서 사용할 상태 지정 (도메인의 @Reducer enum Path 참조)
    • B, C의 경우에도 동일한 방식으로 작성

     

    스크린 A

    도메인

    일반적인 도메인입니다. (전체 코드 참조)

    struct ScreenAView: View {
      let store: StoreOf<ScreenA>
      
      var body: some View {
    	// .. //
        Section {
            NavigationLink(
              "Go to screen A",
              state: NavigationDemo.Path.State.screenA(ScreenA.State(count: store.count))
            )
            NavigationLink(
              "Go to screen B",
              state: NavigationDemo.Path.State.screenB(ScreenB.State())
            )
            NavigationLink(
              "Go to screen C",
              state: NavigationDemo.Path.State.screenC(ScreenC.State(count: store.count))
            )
          }
        }

    state: NavigationDemo.Path.State.screenA(ScreenA.State(count: store.count))

    • 현재 스토어에 있는 store.count를 다른 링크로 넘길 수 있습니다.

     

    스크린 B

    도메인

    일반적인 도메인입니다. (전체 코드 참조)

     

    내비게이션을 이동하는 다른 방식

      Button("Decoupled navigation to screen A") {
        store.send(.screenAButtonTapped)
      }
    • 시스템에 액션을 전송하고 루트 리듀서가 해당 액션을 가로채서 다음 기능을 스택에 푸시하도록 할 수 있습니다. (상단의 NavigationDemo > Reducer 참조)
    var body: some Reducer<State, Action> {
        Reduce { state, action in
          switch action {
          // ... //
    
          case let .path(action):
            switch action {
            case .element(id: _, action: .screenB(.screenAButtonTapped)):
              state.path.append(.screenA(ScreenA.State()))
              return .none
    • 루트 도메인의 리듀서에 있는 case .path(let action)으로 간 뒤, .element(... action: 스크린 B에 있는 screenAButtonTapped액션)를 찾아 실행합니다.

     

    스크린 C

    전체 코드 참조 (동작 원리는 스크린 A, B 와 비슷함)

    반응형
Designed by Tistory.