[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 |