범위

이번 정리에서 볼 흐름

Button tap
-> Action
-> destination State 설정
-> NavigationStack
-> Detail 화면 표시

SwiftUI에서는 보통 NavigationLink를 View에 바로 둘 수 있다

TCA에서는 화면 전환도 State로 처리할 수 있다.

이번 예제는 카운터 화면에서 상세 화면으로 이동한다

상세 화면은 현재 카운트 값을 가지고 열린다

다만 TCA 문서에서 말하는 Navigation은 NavigationStack의 push만 말하지 않는다

drill-down, sheet, popover, cover, alert, dialog 같은 것을 모두 앱의 mode change로 보고 Navigation이라고 부른다

이번 글에서는 그중 optional destination State로 상세 화면을 여는 tree-based navigation을 본다

SwiftUI의 NavigationStack 안에서 화면을 열지만 TCA 기준의 stack-based navigation은 아니다

TCA에서 stack-based navigation은 StackState와 StackAction으로 경로를 collection state로 관리하는 경우를 말한다

이번 글에서 볼 것은 세 가지

@Presents
PresentationAction
.ifLet

부모 Feature

카운터 화면이 상세 화면을 열 수 있도록 destination State를 추가한다

@Reducer
struct CounterNavigationFeature {
    @ObservableState
    struct State: Equatable {
        var count = 0
        @Presents var destination: Destination.State?
    }

    enum Action {
        case decrementButtonTapped
        case destination(PresentationAction<Destination.Action>)
        case detailButtonTapped
        case incrementButtonTapped
    }
}

count는 기존 카운터 값이다

destination은 현재 존재하는 내비게이션 State다

처음에는 들어간 mode가 없기 때문에 optional이다

@Presents var destination: Destination.State?

정리하면 이런 상태다

destination == nil
-> 상세 화면 닫힘

destination != nil
-> 상세 화면 열림

Destination

열 수 있는 화면은 Destination에 둔다

@Reducer
enum Destination {
    case detail(CounterDetailFeature)
}

extension CounterNavigationFeature.Destination.State: Equatable {}

지금은 상세 화면 하나만 있다

부모 State가 Equatable을 채택하고 있어서 Destination.State도 Equatable을 채택하게 둔다

나중에 화면이 늘어나면 여기에 case가 늘어난다

detail
settings
edit

이런 식으로 들어갈 수 있는 mode 후보를 State 구조 안에 둔다

상세 Feature

상세 화면은 열린 시점의 카운트 값을 가진다

@Reducer
struct CounterDetailFeature {
    @ObservableState
    struct State: Equatable {
        let count: Int
    }

    enum Action {}
}

이번 상세 화면은 값을 보여주기만 한다

그래서 Action은 비어 있다

화면 열기

상세 보기 버튼을 누르면 Action을 보낸다

Button {
    store.send(.detailButtonTapped)
} label: {
    Label("상세 보기", systemImage: "arrow.right.circle")
}

Reducer는 이 Action을 받아 destination을 채운다

case .detailButtonTapped:
    state.destination = .detail(
        CounterDetailFeature.State(count: state.count)
    )

이 시점에 tree-based navigation State가 바뀐다

destination
nil
-> .detail(CounterDetailFeature.State(count: state.count))

View가 직접 화면을 밀어 넣는 것이 아니다

View는 Action만 보낸다

어떤 mode로 들어갈지는 Reducer가 State로 결정한다

PresentationAction

부모 Action에는 destination Action도 들어간다

case destination(PresentationAction<Destination.Action>)

상세 화면에서 발생한 Action은 부모 입장에서 destination Action으로 들어온다

Detail Action
-> Destination.Action
-> PresentationAction
-> Parent Action

이번 예제에서는 상세 화면 Action이 없어서 직접 처리할 내용은 없다

case .destination:
    break

하지만 상세 화면에 버튼이나 비동기 작업이 생기면 이 흐름으로 부모까지 올라온다

.ifLet

부모 Reducer는 .ifLet으로 destination Reducer를 연결한다

var body: some ReducerOf<Self> {
    Reduce { state, action in
        switch action {
        case .decrementButtonTapped:
            state.count -= 1

        case .destination:
            break

        case .detailButtonTapped:
            state.destination = .detail(
                CounterDetailFeature.State(count: state.count)
            )

        case .incrementButtonTapped:
            state.count += 1
        }

        return .none
    }
    .ifLet(\.$destination, action: \.destination)
}

.ifLet이 하는 일

destination이 nil이면 자식 Reducer를 실행할 State가 없음
destination이 있으면 destination State와 destination Action을 자식 Reducer에 전달함

Scope와 비교하면 이렇게 볼 수 있다

Scope
-> 항상 존재하는 자식 Feature 연결

.ifLet
-> optional로 존재하는 자식 Feature 연결

Tree-based navigation은 optional이나 enum State로 해당 mode에 들어갈 때만 자식 State가 생긴다

그래서 Scope보다 .ifLet이 자연스럽다

View에서 navigationDestination

View는 Store를 bindable하게 가진다

struct CounterNavigationView: View {
    @Perception.Bindable var store: StoreOf<CounterNavigationFeature>
}

destination이 바뀌면 SwiftUI의 navigationDestination이 상세 화면을 연다

여기서는 NavigationStack을 쓰지만 TCA의 stack-based navigation은 아니다

navigationDestination(item:)에 optional destination State를 연결하기 때문이다

.navigationDestination(
    item: $store.scope(
        state: \.destination?.detail,
        action: \.destination.detail
    )
) { store in
    CounterDetailView(store: store)
}

여기서 store.scope는 부모 Store를 상세 화면 Store로 좁힌다

StoreOf<CounterNavigationFeature>
-> StoreOf<CounterDetailFeature>

$store.scope는 Navigation 상태를 SwiftUI Binding으로 연결한다

그래서 뒤로 가기를 하면 destination도 다시 nil이 된다

Back
-> destination nil
-> 상세 화면 닫힘

전체 흐름

상세 보기 버튼을 누른 흐름

Button tap
-> store.send(.detailButtonTapped)
-> Reducer
-> state.destination = .detail(...)
-> navigationDestination
-> CounterDetailView 표시

뒤로 가기 흐름

Back
-> destination nil
-> .ifLet 연결 해제
-> Detail 화면 사라짐

현재 기준

Navigation은 View에서 화면을 직접 여는 방식만 말하지 않는다

Reducer가 어떤 mode로 들어갈지 State로 정하고 View는 그 State를 보고 push, sheet, alert 같은 UI를 표시한다

카운터 예제 기준

count
-> 현재 카운터 값

destination
-> 현재 들어가 있는 mode

detailButtonTapped
-> 상세 mode로 들어가라는 Action

정리

현재 이해한 Navigation

@Presents = optional navigation State를 presentation 흐름으로 연결
PresentationAction = destination mode에서 올라오는 Action
.ifLet = optional destination State와 Action을 자식 Reducer에 전달하는 연결
navigationDestination = destination State를 보고 화면 표시

Navigation은 push 화면 전환만 말하지 않는다

현재 어떤 mode가 존재하는지를 State로 표현한다

Tree-based와 stack-based를 나누는 기준은 SwiftUI 컨테이너 이름이 아니라 State 모양이다

@Presents var destination: Destination.State?
-> tree-based navigation

var path = StackState<Path.State>()
-> stack-based navigation

참고