apple/TCA

[TCA] Navigation (화면전환 총 정리)

lgvv 2023. 10. 9. 21:26

[TCA] Navigation

 

TCA의 Navigation에 대해서 공부하고 정리해보자.

서비스 개발에서 사용하는 화면전환 방식으로는 몇 가지가 있는데, 우선적으로 자주 사용하는 것들을 정리하고, 추가적으로 필요한 것들이 있을 때마다 포스팅을 업데이트 해보자!

 

이번에 알아볼 목차!

 - 일반적인 sheet 화면전환 

    - navigationDestination, popover, sheet 사용
 - NavigationStack (feat. Path & destination)

    - 여러개의 뷰를 한번에 이동시켜야 할 경우
    - struct Path: Reducer { ... }

    - NavigationStack의 View 단에서 처리

    - StackElementID
  - ScreenA

     - 스스로 dismiss 

  - ScreenB
     - 부모 feature가 이벤트 가로채감

  - ScreenC 

     - long-living effect in stack

 - 디테일을 살리는 몇가지 기술들
    - concatenate(with: ...)

 - 프로젝트를 실습하기 위한 준비 코드
   - CounterView

   - 전체 코드

 

 

# 일반적인 sheet 화면전환

How To Use

1. Reducer를 채택한 Destination 만들기. (이름은 Destination이 아니어도 된다.)
2. Destination을 구현해주기. (body 부분을 주목!)

3. MultipleDestinations에서 State에 @PresentationState var destination: Destination.State을 선언

   - Optional 상태로 선언하여 외부에서 처음 화면을 넣도록 함.

4. MultipleDestinations에서 Action에 case destination(PresentationAction<Destination.Action>)을 선언

5. MultipleDestinations에서 ifLet을 통해 Destination을 통합.

6. MultipleDestinations의 body 구현부에서 case destination: return .none으로 반드시 처리

 

 

// 여러화면 전환을 관리하기 위해서는 UIKit에서 코디네이터 패턴을 쓰듯이 분리해서 사용할 수 있다.
struct MultipleDestinations: Reducer {
    // 화면전환 Destination! MVStatePattern을 학습할 때 Coordinator처럼 본 Reducer에서 분리 가능
    public struct Destination: Reducer {
        public enum State: Equatable {
            case drillDown(Counter.State)
            case popover(Counter.State)
            case sheet(Counter.State)
        }
        
        public enum Action {
            case drillDown(Counter.Action)
            case popover(Counter.Action)
            case sheet(Counter.Action)
        }
        
        public var body: some Reducer<State, Action> {
            // 화면 전환 리듀서 구현 및 연결
            Scope(state: /State.drillDown, action: /Action.drillDown) {
                Counter()
            }
            Scope(state: /State.sheet, action: /Action.sheet) {
                Counter()
            }
            Scope(state: /State.popover, action: /Action.popover) {
                Counter()
            }
        }
    }
    
    struct State: Equatable {
        @PresentationState var destination: Destination.State? // 화면전환 State
    }
    
    enum Action {
        case destination(PresentationAction<Destination.Action>) // 화면 전환 Action을 든다.
        case showDrillDown
        case showPopover
        case showSheet
    }
    
    var body: some Reducer<State, Action> {
        Reduce { state, action in
            switch action {
            case .showDrillDown:
                state.destination = .drillDown(Counter.State())
                return .none
            case .showPopover:
                state.destination = .popover(Counter.State())
                return .none
            case .showSheet:
                state.destination = .sheet(Counter.State())
                return .none
            case .destination:
                return .none
            }
        }
        .ifLet(\.$destination, action: /Action.destination) {
            // ✅ 리듀서 통합 (화면전환)
            Destination()
        }
    }
}

struct MultipleDestinationsView: View {
    let store: StoreOf<MultipleDestinations>
    
    var body: some View {
        WithViewStore(self.store, observe: { $0 }) { viewStore in
            Form {
                Section {
                    AboutView(readMe: readMe)
                }
                Button("Show drill-down") {
                    viewStore.send(.showDrillDown)
                }
                Button("Show popover") {
                    viewStore.send(.showPopover)
                }
                Button("Show sheet") {
                    viewStore.send(.showSheet)
                }
            }
            .navigationDestination(
                store: store.scope(state: \.$destination, action: { .destination($0) }),
                state: /MultipleDestinations.Destination.State.drillDown,
                action: MultipleDestinations.Destination.Action.drillDown
            ) {
                CounterView(store: $0)
            }
            .popover(
                store: store.scope(state: \.$destination, action: { .destination($0) }),
                state: /MultipleDestinations.Destination.State.popover,
                action: MultipleDestinations.Destination.Action.popover
            ) {
                CounterView(store: $0)
            }
            .sheet(
                store: store.scope(state: \.$destination, action: { .destination($0) }),
                state: /MultipleDestinations.Destination.State.sheet,
                action: MultipleDestinations.Destination.Action.sheet
            ) {
                CounterView(store: $0)
            }
        }
    }
}

 

 

# NavigationStack (feat. Path & destination)

How To Use

1. Reducer를 채택한 Path 만들기. (이름은 Path이 아니어도 된다.)
2. Path을 구현해주기. (body 부분을 주목!)

3. NavigationDemo에서 State에 StackState<Path.State>()를 선언

4. NavigationDemo에서 Action에 case path(StackAction<Path.State, Path.Action>)을 선언

5. NavigationDemo에서 forEach을 통해 Path을 통합.
  - Path는 위의 예제와 다르게 배열이므로 이렇게 통합해야한다.

6. NavigationDemo의 body 구현부에서 case path: return .none으로 반드시 처리

 

 

struct NavigationDemo: Reducer {
    struct State: Equatable {
        var path = StackState<Path.State>()
    }
    
    enum Action: Equatable {
        case goBackToScreen(id: StackElementID)
        case goToABCButtonTapped
        case path(StackAction<Path.State, Path.Action>)
        case popToRoot
    }
    
    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:
                // 패쓰는 여러개를 순차적으로 배열에 추가하므로써 컨트롤 가능
                // 추가하는 것 만으로도 NavigationLink가 작동
                state.path.append(.screenA())
                state.path.append(.screenB())
                state.path.append(.screenC())
                return .none
                
            case let .path(action):
                switch action {
                case .element(id: _, action: .screenB(.screenAButtonTapped)):
                    state.path.append(.screenA())
                    return .none
                    
                case .element(id: _, action: .screenB(.screenBButtonTapped)):
                    state.path.append(.screenB())
                    return .none
                    
                case .element(id: _, action: .screenB(.screenCButtonTapped)):
                    state.path.append(.screenC())
                    return .none
                    
                default:
                    return .none
                }
                
            case .popToRoot:
                state.path.removeAll()
                return .none
            }
        }
        .forEach(\.path, action: /Action.path) { // Path는 배열 형태니까 연결
            Path()
        }
    }
    
    struct Path: Reducer {
        enum State: Codable, Equatable, Hashable {
            case screenA(ScreenA.State = .init())
            case screenB(ScreenB.State = .init())
            case screenC(ScreenC.State = .init())
        }
        
        enum Action: Equatable {
            case screenA(ScreenA.Action)
            case screenB(ScreenB.Action)
            case screenC(ScreenC.Action)
        }
        
        var body: some Reducer<State, Action> {
            Scope(state: /State.screenA, action: /Action.screenA) {
                ScreenA()
            }
            Scope(state: /State.screenB, action: /Action.screenB) {
                ScreenB()
            }
            Scope(state: /State.screenC, action: /Action.screenC) {
                ScreenC()
            }
        }
    }
}

struct NavigationDemoView: View {
    let store: StoreOf<NavigationDemo>
    
    var body: some View {
        NavigationStackStore(
            self.store.scope(state: \.path, action: NavigationDemo.Action.path)
        ) {
            Form {
                Section { Text(template: readMe) }
                
                Section {
                    NavigationLink(
                        "Go to screen A",
                        state: NavigationDemo.Path.State.screenA()
                    )
                    NavigationLink(
                        "Go to screen B",
                        state: NavigationDemo.Path.State.screenB()
                    )
                    NavigationLink(
                        "Go to screen C",
                        state: NavigationDemo.Path.State.screenC()
                    )
                }
                
                Section {
                    Button("Go to A → B → C") {
                        self.store.send(.goToABCButtonTapped)
                    }
                }
            }
            .navigationTitle("Root")
        } destination: {
            // destination으로 처리하려면 CaseLet을 사용
            switch $0 {
            case .screenA:
                CaseLet(
                    /NavigationDemo.Path.State.screenA,
                     action: NavigationDemo.Path.Action.screenA,
                     then: ScreenAView.init(store:)
                )
            case .screenB:
                CaseLet(
                    /NavigationDemo.Path.State.screenB,
                     action: NavigationDemo.Path.Action.screenB,
                     then: ScreenBView.init(store:)
                )
            case .screenC:
                CaseLet(
                    /NavigationDemo.Path.State.screenC,
                     action: NavigationDemo.Path.Action.screenC,
                     then: ScreenCView.init(store:)
                )
            }
        }
        .safeAreaInset(edge: .bottom) {
            FloatingMenuView(store: self.store)
        }
        .navigationTitle("Navigation Stack")
    }
}

 

여기서 짚어봐야 할 포인트
 1. 여러개의 뷰를 한번에 이동시켜야 할 경우.

// 패쓰는 여러개를 순차적으로 배열에 추가하므로써 컨트롤 가능
// 추가하는 것 만으로도 NavigationLink가 작동
state.path.append(.screenA())
state.path.append(.screenB())
state.path.append(.screenC())

// 혹은 한번에 root로 갈 경우
state.path.removeAll()

// 배열이므로 특정 조건에따라 원하는 대로 뺴내기도 가능

 2. struct Path: Reducer { ... } 
   sheet는 Destination: Reducer { ... } 로 위의 예제에서 구현

struct Path: Reducer {
        enum State: Codable, Equatable, Hashable {
            case screenA(ScreenA.State = .init())
            case screenB(ScreenB.State = .init())
            case screenC(ScreenC.State = .init())
        }
        
        enum Action: Equatable {
            case screenA(ScreenA.Action)
            case screenB(ScreenB.Action)
            case screenC(ScreenC.Action)
        }
        
        var body: some Reducer<State, Action> {
            Scope(state: /State.screenA, action: /Action.screenA) {
                ScreenA()
            }
            Scope(state: /State.screenB, action: /Action.screenB) {
                ScreenB()
            }
            Scope(state: /State.screenC, action: /Action.screenC) {
                ScreenC()
            }
        }
    }

위처럼 Path을 통하여 네비게이션에 대한 관심사를 분리하여 처리할 수 있음.

 

다만, 현재 예제는 해당 Reducer안에 포함되어 있는 형태.


1. 해당 Reducer에 위치시키기
 - 밖에 위치시켜서 컨트롤 가능함. 다만, 이렇게 할 경우 주입 전에 Path를 결합해 주어야 함.
   - 내가 생각하는 장점: TCA에서 화면 전환을 외부로 빼내어 관리할 수 있다는 점.
   - 나의 생각:
      - 특정 상태에 따라서 이동할 수 있는 경로 자체에 제한을 두는 옵션이 있다면 그럴 수도 있겠다 싶으면서도 사실 TCA에서 굳이 화면전환에 있어서 분리하지 않아도 코드가 복잡해지지 않는다는 생각이 들었음. 코드가 massive하다면 View에 속한 다른 기능들은 @Dependency나 ViewState 등 더 잘게 쪼개는게 러닝커브도 낮고 더 나은 방향이라고 생각

 2. TCACoordinator 사용
  - 오픈소스 사용
   - 내가 생각하는 장점: UIKit의 Coordinator와 비슷한 사고 플로우를 가져갈 수 있다는 점.
   - 나의 생각:

     - 학습은 진행했으나, 굳이 도입하지 않아도 기본적으로 제공하는 것만으로도 작업이 편리함. 개발팀에서 오픈소스를 학습에 시간을 할애하지 않아도 되는것 같아서 안쓰는게 더 나은 방향이라고 생각

 

 3. NavigationStack의 View 단에서 처리

// 이전까지 우리가 일반적으로 사용
ViewWithStore

// NavigationStack을 사용한다면
NavigationStackStore(
...
) destination: {
// 여기서 네비게이션 방식의 전환을 구현
// destination으로 처리하려면 CaseLet을 사용
	switch $0 {
		case .screenA:
        // CaseLet으로 처리
   			CaseLet(
				/NavigationDemo.Path.State.screenA,
				action: NavigationDemo.Path.Action.screenA,
				then: ScreenAView.init(store:)
			)
		case .screenB:
			CaseLet(
				/NavigationDemo.Path.State.screenB,
				action: NavigationDemo.Path.Action.screenB,
				then: ScreenBView.init(store:)
			)
		case .screenC:
			CaseLet(
				/NavigationDemo.Path.State.screenC,
				action: NavigationDemo.Path.Action.screenC,
				then: ScreenCView.init(store:)
			)
}

주석에 설명이 포함되어 있음.

destination에서 CaseLet을 통해 전환할 뷰에 데이터를 건네줄 수도 있음

 

4. StackElementID

TCA에서 NavigationStack을 사용할 때 이용할때 각 화면을 StackElementID로 사용

for (id, element) in zip(state.path.ids, state.path) {
   if element.isDeleted {
      state.path.pop(from: id)
      break
   }
}

 

id를 가지고 한번에 지정 위치로 pop할 수도 있고 Delegate를 사용할 수도 있다.

 

# Screen A

주목할 부분

1. dismiss를 dependency를 통해 처리하는 부분
 - dismiss를 Screen A에서 직접 처리하는게 포인트! Screen B와 비교해보자

// MARK: - Screen A

struct ScreenA: Reducer {
    struct State: Codable, Equatable, Hashable {
        var count = 0
        var fact: String?
        var isLoading = false
    }
    
    enum Action: Equatable {
        case decrementButtonTapped
        case dismissButtonTapped
        case incrementButtonTapped
        case factButtonTapped
        case factResponse(TaskResult<String>)
    }
    
    @Dependency(\.dismiss) var dismiss
    @Dependency(\.factClient) var factClient
    
    func reduce(into state: inout State, action: Action) -> Effect<Action> {
        switch action {
        case .decrementButtonTapped:
            state.count -= 1
            return .none
            
        case .dismissButtonTapped:
            return .run { _ in
                // dismiss는 .run을 통해 처리한다.
                await self.dismiss()
            }
            
        case .incrementButtonTapped:
            state.count += 1
            return .none
            
        case .factButtonTapped:
            state.isLoading = true
            return .run { [count = state.count] send in
                await send(.factResponse(.init { try await self.factClient.fetch(count) }))
            }
            
        case let .factResponse(.success(fact)):
            state.isLoading = false
            state.fact = fact
            return .none
            
        case .factResponse(.failure):
            state.isLoading = false
            state.fact = nil
            return .none
        }
    }
}

struct ScreenAView: View {
    let store: StoreOf<ScreenA>
    
    var body: some View {
        WithViewStore(self.store, observe: { $0 }) { viewStore in
            Form {
                Text(
          """
          This screen demonstrates a basic feature hosted in a navigation stack.
          
          You can also have the child feature dismiss itself, which will communicate back to the \
          root stack view to pop the feature off the stack.
          """
                )
                
                Section {
                    HStack {
                        Text("\(viewStore.count)")
                        Spacer()
                        Button {
                            viewStore.send(.decrementButtonTapped)
                        } label: {
                            Image(systemName: "minus")
                        }
                        Button {
                            viewStore.send(.incrementButtonTapped)
                        } label: {
                            Image(systemName: "plus")
                        }
                    }
                    .buttonStyle(.borderless)
                    
                    Button {
                        viewStore.send(.factButtonTapped)
                    } label: {
                        HStack {
                            Text("Get fact")
                            if viewStore.isLoading {
                                Spacer()
                                ProgressView()
                            }
                        }
                    }
                    
                    if let fact = viewStore.fact {
                        Text(fact)
                    }
                }
                
                Section {
                    Button("Dismiss") {
                        viewStore.send(.dismissButtonTapped)
                    }
                }
                
                Section {
                    // Screen B와 다르게 NavigationLink로 직접 연결도 가능
                    NavigationLink(
                        "Go to screen A",
                        state: NavigationDemo.Path.State.screenA(.init(count: viewStore.count))
                    )
                    NavigationLink(
                        "Go to screen B",
                        state: NavigationDemo.Path.State.screenB()
                    )
                    NavigationLink(
                        "Go to screen C",
                        state: NavigationDemo.Path.State.screenC(.init(count: viewStore.count))
                    )
                }
            }
        }
        .navigationTitle("Screen A")
    }
}



# Screen B

주목할 부분

1. 스크린 B는 자체적으로 화면전환 등을 구현하지 않아도, 부모 리듀서에 걸린 부분에 따라서 처리가 되고 있음.
 - 부모 feature가 액션을 가로채서 처리한다는게 포인트

// MARK: - Screen B
// 스크린 B는 화면전환에 대해 자체적으로 특별하게 구현하고 있지 않음.
// 하지만 스크린 B에서는 부모 feature가 액션을 가로채서 처리함

struct ScreenB: Reducer {
    struct State: Codable, Equatable, Hashable {}
    
    enum Action: Equatable {
        case screenAButtonTapped
        case screenBButtonTapped
        case screenCButtonTapped
    }
    
    func reduce(into state: inout State, action: Action) -> Effect<Action> {
        switch action {
        case .screenAButtonTapped:
            
            return .none
        case .screenBButtonTapped:
            return .none
        case .screenCButtonTapped:
            return .none
        }
    }
}

struct ScreenBView: View {
    let store: StoreOf<ScreenB>
    
    var body: some View {
        WithViewStore(self.store, observe: { $0 }) { viewStore in
            Form {
                Section {
                    Text(
            """
            This screen demonstrates how to navigate to other screens without needing to compile \
            any symbols from those screens. You can send an action into the system, and allow the \
            root feature to intercept that action and push the next feature onto the stack.
            """
                    )
                }
                Button("Decoupled navigation to screen A") {
                    viewStore.send(.screenAButtonTapped)
                }
                Button("Decoupled navigation to screen B") {
                    viewStore.send(.screenBButtonTapped)
                }
                Button("Decoupled navigation to screen C") {
                    viewStore.send(.screenCButtonTapped)
                }
            }
            .navigationTitle("Screen B")
        }
    }
}

 

# Screen C

주목할 부분

1. 스크린 C는 stack안에서 long-living effect 있을 때 어떻게 처리되는가를 보여줌

 - dismiss될때 같이 사라짐

// MARK: - Screen C

struct ScreenC: Reducer {
    struct State: Codable, Equatable, Hashable {
        var count = 0
        var isTimerRunning = false
    }
    
    enum Action: Equatable {
        case startButtonTapped
        case stopButtonTapped
        case timerTick
    }
    
    @Dependency(\.mainQueue) var mainQueue
    enum CancelID { case timer }
    
    func reduce(into state: inout State, action: Action) -> Effect<Action> {
        switch action {
        case .startButtonTapped:
            state.isTimerRunning = true
            return .run { send in
                // 메인큐에서 1초의 interval로 타이머 기능을 활용할 수 있음.
                for await _ in self.mainQueue.timer(interval: 1) {
                    await send(.timerTick)
                }
            }
            .cancellable(id: CancelID.timer)
            .concatenate(with: .send(.stopButtonTapped)) // complete되거나 cancel 된 후에 해당 이벤트를 발생시킴
            
        case .stopButtonTapped:
            state.isTimerRunning = false
            return .cancel(id: CancelID.timer)
            
        case .timerTick:
            state.count += 1
            return .none
        }
    }
}

struct ScreenCView: View {
    let store: StoreOf<ScreenC>
    
    var body: some View {
        WithViewStore(self.store, observe: { $0 }) { viewStore in
            Form {
                Text(
          """
          This screen demonstrates that if you start a long-living effects in a stack, then it \
          will automatically be torn down when the screen is dismissed.
          """
                )
                Section {
                    Text("\(viewStore.count)")
                    if viewStore.isTimerRunning {
                        Button("Stop timer") { viewStore.send(.stopButtonTapped) }
                    } else {
                        Button("Start timer") { viewStore.send(.startButtonTapped) }
                    }
                }
                
                Section {
                    NavigationLink(
                        "Go to screen A",
                        state: NavigationDemo.Path.State.screenA(.init(count: viewStore.count))
                    )
                    NavigationLink(
                        "Go to screen B",
                        state: NavigationDemo.Path.State.screenB()
                    )
                    NavigationLink(
                        "Go to screen C",
                        state: NavigationDemo.Path.State.screenC()
                    )
                }
            }
            .navigationTitle("Screen C")
        }
    }
}

 

# 디테일을 살리는 몇가지 기술들

예제에 나온 몇가지만 알아보자.

 

1. concatenate(with: )

해당 부분은 complete 혹은 cancel 된 후에 한번만 실행된다.

case .startButtonTapped:
            state.isTimerRunning = true
            return .run { send in
                // 메인큐에서 1초의 interval로 타이머 기능을 활용할 수 있음.
                for await _ in self.mainQueue.timer(interval: 1) {
                    await send(.timerTick)
                }
            }
            .cancellable(id: CancelID.timer)
            .concatenate(with: .send(.stopButtonTapped)) // complete되거나 cancel 된 후에 해당 이벤트를 발생시킴

 

 

 

 

# 프로젝트를 실습하기 위한 준비 코드

1. CounterView

// MARK: - Feature domain

struct Counter: Reducer {
    struct State: Equatable {
        var count = 0
    }
    
    enum Action: Equatable {
        case decrementButtonTapped
        case incrementButtonTapped
    }
    
    func reduce(into state: inout State, action: Action) -> Effect<Action> {
        switch action {
        case .decrementButtonTapped:
            state.count -= 1
            return .none
        case .incrementButtonTapped:
            state.count += 1
            return .none
        }
    }
}

// MARK: - Feature view

struct CounterView: View {
    let store: StoreOf<Counter>
    
    var body: some View {
        WithViewStore(self.store, observe: { $0 }) { viewStore in
            HStack {
                Button {
                    viewStore.send(.decrementButtonTapped)
                } label: {
                    Image(systemName: "minus")
                }
                
                Text("\(viewStore.count)")
                    .monospacedDigit()
                
                Button {
                    viewStore.send(.incrementButtonTapped)
                } label: {
                    Image(systemName: "plus")
                }
            }
        }
    }
}

 

2. 전체코드

import ComposableArchitecture
import SwiftUI

private let readMe = """
  This screen demonstrates how to use `NavigationStack` with Composable Architecture applications.
  """

struct NavigationDemo: Reducer {
    struct State: Equatable {
        var path = StackState<Path.State>()
    }
    
    enum Action: Equatable {
        case goBackToScreen(id: StackElementID)
        case goToABCButtonTapped
        case path(StackAction<Path.State, Path.Action>)
        case popToRoot
    }
    
    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:
                // 패쓰는 여러개를 순차적으로 배열에 추가하므로써 컨트롤 가능
                // 추가하는 것 만으로도 NavigationLink가 작동
                state.path.append(.screenA())
                state.path.append(.screenB())
                state.path.append(.screenC())
                return .none
                
            case let .path(action):
                switch action {
                case .element(id: _, action: .screenB(.screenAButtonTapped)):
                    state.path.append(.screenA())
                    return .none
                    
                case .element(id: _, action: .screenB(.screenBButtonTapped)):
                    state.path.append(.screenB())
                    return .none
                    
                case .element(id: _, action: .screenB(.screenCButtonTapped)):
                    state.path.append(.screenC())
                    return .none
                    
                default:
                    return .none
                }
                
            case .popToRoot:
                state.path.removeAll()
                return .none
            }
        }
        .forEach(\.path, action: /Action.path) {
            Path()
        }
    }
    
    struct Path: Reducer {
        enum State: Codable, Equatable, Hashable {
            case screenA(ScreenA.State = .init())
            case screenB(ScreenB.State = .init())
            case screenC(ScreenC.State = .init())
        }
        
        enum Action: Equatable {
            case screenA(ScreenA.Action)
            case screenB(ScreenB.Action)
            case screenC(ScreenC.Action)
        }
        
        var body: some Reducer<State, Action> {
            Scope(state: /State.screenA, action: /Action.screenA) {
                ScreenA()
            }
            Scope(state: /State.screenB, action: /Action.screenB) {
                ScreenB()
            }
            Scope(state: /State.screenC, action: /Action.screenC) {
                ScreenC()
            }
        }
    }
}

struct NavigationDemoView: View {
    let store: StoreOf<NavigationDemo>
    
    var body: some View {
        NavigationStackStore(
            self.store.scope(state: \.path, action: NavigationDemo.Action.path)
        ) {
            Form {
                Section { Text(template: readMe) }
                
                Section {
                    NavigationLink(
                        "Go to screen A",
                        state: NavigationDemo.Path.State.screenA()
                    )
                    NavigationLink(
                        "Go to screen B",
                        state: NavigationDemo.Path.State.screenB()
                    )
                    NavigationLink(
                        "Go to screen C",
                        state: NavigationDemo.Path.State.screenC()
                    )
                }
                
                Section {
                    Button("Go to A → B → C") {
                        self.store.send(.goToABCButtonTapped)
                    }
                }
            }
            .navigationTitle("Root")
        } destination: {
            // destination으로 처리하려면 CaseLet을 사용
            switch $0 {
            case .screenA:
                CaseLet(
                    /NavigationDemo.Path.State.screenA,
                     action: NavigationDemo.Path.Action.screenA,
                     then: ScreenAView.init(store:)
                )
            case .screenB:
                CaseLet(
                    /NavigationDemo.Path.State.screenB,
                     action: NavigationDemo.Path.Action.screenB,
                     then: ScreenBView.init(store:)
                )
            case .screenC:
                CaseLet(
                    /NavigationDemo.Path.State.screenC,
                     action: NavigationDemo.Path.Action.screenC,
                     then: ScreenCView.init(store:)
                )
            }
        }
        .safeAreaInset(edge: .bottom) {
            FloatingMenuView(store: self.store)
        }
        .navigationTitle("Navigation Stack")
    }
}

// MARK: - Floating menu

struct FloatingMenuView: View {
    let store: StoreOf<NavigationDemo>
    
    // ViewState를 활용하는 방법
    struct ViewState: Equatable {
        struct Screen: Equatable, Identifiable {
            let id: StackElementID
            let name: String
        }
        
        var currentStack: [Screen]
        var total: Int
        
        init(state: NavigationDemo.State) {
            self.total = 0
            self.currentStack = []
            for (id, element) in zip(state.path.ids, state.path) {
                // insert를 통해서 항상 가장 최상단에 위치해야 함.
                switch element {
                case let .screenA(screenAState):
                    self.total += screenAState.count
                    self.currentStack.insert(Screen(id: id, name: "Screen A"), at: 0)
                case .screenB:
                    self.currentStack.insert(Screen(id: id, name: "Screen B"), at: 0)
                case let .screenC(screenBState):
                    self.total += screenBState.count
                    self.currentStack.insert(Screen(id: id, name: "Screen C"), at: 0)
                }
            }
        }
    }
    
    var body: some View {
        WithViewStore(self.store, observe: ViewState.init) { viewStore in
            if viewStore.currentStack.count > 0 {
                VStack(alignment: .center) {
                    Text("Total count: \(viewStore.total)")
                    Button("Pop to root") {
                        viewStore.send(.popToRoot, animation: .default)
                    }
                    Menu("Current stack") {
                        ForEach(viewStore.currentStack) { screen in
                            Button("\(String(describing: screen.id))) \(screen.name)") {
                                viewStore.send(.goBackToScreen(id: screen.id))
                            }
                            .disabled(screen == viewStore.currentStack.first)
                        }
                        Button("Root") {
                            viewStore.send(.popToRoot, animation: .default)
                        }
                    }
                }
                .padding()
                .background(Color(.systemBackground))
                .padding(.bottom, 1)
                .transition(.opacity.animation(.default))
                .clipped()
                .shadow(color: .black.opacity(0.2), radius: 5, y: 5)
            }
        }
    }
}

// MARK: - Screen A

struct ScreenA: Reducer {
    struct State: Codable, Equatable, Hashable {
        var count = 0
        var fact: String?
        var isLoading = false
    }
    
    enum Action: Equatable {
        case decrementButtonTapped
        case dismissButtonTapped
        case incrementButtonTapped
        case factButtonTapped
        case factResponse(TaskResult<String>)
    }
    
    @Dependency(\.dismiss) var dismiss
    @Dependency(\.factClient) var factClient
    
    func reduce(into state: inout State, action: Action) -> Effect<Action> {
        switch action {
        case .decrementButtonTapped:
            state.count -= 1
            return .none
            
        case .dismissButtonTapped:
            return .run { _ in
                // dismiss는 .run을 통해 처리한다.
                await self.dismiss()
            }
            
        case .incrementButtonTapped:
            state.count += 1
            return .none
            
        case .factButtonTapped:
            state.isLoading = true
            return .run { [count = state.count] send in
                await send(.factResponse(.init { try await self.factClient.fetch(count) }))
            }
            
        case let .factResponse(.success(fact)):
            state.isLoading = false
            state.fact = fact
            return .none
            
        case .factResponse(.failure):
            state.isLoading = false
            state.fact = nil
            return .none
        }
    }
}

struct ScreenAView: View {
    let store: StoreOf<ScreenA>
    
    var body: some View {
        WithViewStore(self.store, observe: { $0 }) { viewStore in
            Form {
                Text(
          """
          This screen demonstrates a basic feature hosted in a navigation stack.
          
          You can also have the child feature dismiss itself, which will communicate back to the \
          root stack view to pop the feature off the stack.
          """
                )
                
                Section {
                    HStack {
                        Text("\(viewStore.count)")
                        Spacer()
                        Button {
                            viewStore.send(.decrementButtonTapped)
                        } label: {
                            Image(systemName: "minus")
                        }
                        Button {
                            viewStore.send(.incrementButtonTapped)
                        } label: {
                            Image(systemName: "plus")
                        }
                    }
                    .buttonStyle(.borderless)
                    
                    Button {
                        viewStore.send(.factButtonTapped)
                    } label: {
                        HStack {
                            Text("Get fact")
                            if viewStore.isLoading {
                                Spacer()
                                ProgressView()
                            }
                        }
                    }
                    
                    if let fact = viewStore.fact {
                        Text(fact)
                    }
                }
                
                Section {
                    Button("Dismiss") {
                        viewStore.send(.dismissButtonTapped)
                    }
                }
                
                Section {
                    // Screen B와 다르게 NavigationLink로 직접 연결도 가능
                    NavigationLink(
                        "Go to screen A",
                        state: NavigationDemo.Path.State.screenA(.init(count: viewStore.count))
                    )
                    NavigationLink(
                        "Go to screen B",
                        state: NavigationDemo.Path.State.screenB()
                    )
                    NavigationLink(
                        "Go to screen C",
                        state: NavigationDemo.Path.State.screenC(.init(count: viewStore.count))
                    )
                }
            }
        }
        .navigationTitle("Screen A")
    }
}

// MARK: - Screen B
// 스크린 B는 화면전환에 대해 자체적으로 특별하게 구현하고 있지 않음.
// 하지만 스크린 B에서는 부모 feature가 액션을 가로채서 처리함

struct ScreenB: Reducer {
    struct State: Codable, Equatable, Hashable {}
    
    enum Action: Equatable {
        case screenAButtonTapped
        case screenBButtonTapped
        case screenCButtonTapped
    }
    
    func reduce(into state: inout State, action: Action) -> Effect<Action> {
        switch action {
        case .screenAButtonTapped:
            
            return .none
        case .screenBButtonTapped:
            return .none
        case .screenCButtonTapped:
            return .none
        }
    }
}

struct ScreenBView: View {
    let store: StoreOf<ScreenB>
    
    var body: some View {
        WithViewStore(self.store, observe: { $0 }) { viewStore in
            Form {
                Section {
                    Text(
            """
            This screen demonstrates how to navigate to other screens without needing to compile \
            any symbols from those screens. You can send an action into the system, and allow the \
            root feature to intercept that action and push the next feature onto the stack.
            """
                    )
                }
                Button("Decoupled navigation to screen A") {
                    viewStore.send(.screenAButtonTapped)
                }
                Button("Decoupled navigation to screen B") {
                    viewStore.send(.screenBButtonTapped)
                }
                Button("Decoupled navigation to screen C") {
                    viewStore.send(.screenCButtonTapped)
                }
            }
            .navigationTitle("Screen B")
        }
    }
}

// MARK: - Screen C

struct ScreenC: Reducer {
    struct State: Codable, Equatable, Hashable {
        var count = 0
        var isTimerRunning = false
    }
    
    enum Action: Equatable {
        case startButtonTapped
        case stopButtonTapped
        case timerTick
    }
    
    @Dependency(\.mainQueue) var mainQueue
    enum CancelID { case timer }
    
    func reduce(into state: inout State, action: Action) -> Effect<Action> {
        switch action {
        case .startButtonTapped:
            state.isTimerRunning = true
            return .run { send in
                // 메인큐에서 1초의 interval로 타이머 기능을 활용할 수 있음.
                for await _ in self.mainQueue.timer(interval: 1) {
                    await send(.timerTick)
                }
            }
            .cancellable(id: CancelID.timer)
            .concatenate(with: .send(.stopButtonTapped)) // complete되거나 cancel 된 후에 해당 이벤트를 발생시킴
            
        case .stopButtonTapped:
            state.isTimerRunning = false
            return .cancel(id: CancelID.timer)
            
        case .timerTick:
            state.count += 1
            return .none
        }
    }
}

struct ScreenCView: View {
    let store: StoreOf<ScreenC>
    
    var body: some View {
        WithViewStore(self.store, observe: { $0 }) { viewStore in
            Form {
                Text(
          """
          This screen demonstrates that if you start a long-living effects in a stack, then it \
          will automatically be torn down when the screen is dismissed.
          """
                )
                Section {
                    Text("\(viewStore.count)")
                    if viewStore.isTimerRunning {
                        Button("Stop timer") { viewStore.send(.stopButtonTapped) }
                    } else {
                        Button("Start timer") { viewStore.send(.startButtonTapped) }
                    }
                }
                
                Section {
                    NavigationLink(
                        "Go to screen A",
                        state: NavigationDemo.Path.State.screenA(.init(count: viewStore.count))
                    )
                    NavigationLink(
                        "Go to screen B",
                        state: NavigationDemo.Path.State.screenB()
                    )
                    NavigationLink(
                        "Go to screen C",
                        state: NavigationDemo.Path.State.screenC()
                    )
                }
            }
            .navigationTitle("Screen C")
        }
    }
}

// MARK: - Previews

struct NavigationStack_Previews: PreviewProvider {
    static var previews: some View {
        NavigationDemoView(
            store: Store(
                initialState: NavigationDemo.State(
                    path: StackState([
                        .screenA(ScreenA.State())
                    ])
                )
            ) {
                NavigationDemo()
            }
        )
    }
}

 

 

 

 

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

[TCA] HigherOrderReducers #2 (ReusableFavoriting)  (1) 2023.10.29
[TCA] HigherOrderReducers #1 (Recursion)  (0) 2023.10.11
[TCA] Effect #6 (WebSocket)  (0) 2023.10.08
[TCA] Effect #5 (Timers)  (0) 2023.10.07
[TCA] Effect #4 (Refreshable)  (0) 2023.10.07