TCA

TCA 1.0 - Navigation (ch.08)

GREEN.1229 2024. 2. 27. 19:10

안녕하세요. 그린입니다 🍏

이번 포스팅에서는  TCA의 Navigation에 대해 알아보겠습니다🙋🏻

 

항상 포스팅에서도 소개했지만, TCA 1.0 시리즈 학습은 아래 학습자료를 기반으로 하고 있습니다.

해당 레퍼를 기반으로 학습하면서 제 나름대로 정리해보는 포스팅이기에, 주관적인 사견이 추가됩니다 🙋🏻

 

 

Chapter 8. Navigation | Notion

8.1 Navigation이란?

axiomatic-fuschia-666.notion.site

 


Navigation이란?

  • 글에선 SwiftUI의 sheet와 fullScreenCover도 네비게이션으로 볼 수 있따는데, 방식 자체는 해당 두개는 모달 방식이고 흔히 네비게이션 방식은 화면 흐름 자체가 전환되어 넘어가기에 조금 다르지 않나 개인적으로 생각됨
  • 해당 자료에서 팝오버, 얼럿, 다이얼로그 같은 모든것들을 다 네비게이션의 일종이라고 말함
    • 이 부분은 이게 맞다 저게 맞다가 아니라 다양하게 해석될 수 있다고 생각됨
    • 우선 이 자료의 취지로 이해해보자 😃
  • 네비게이션은 앱 모드를 변경하는것!
    • 모드 변경은 없는 상태에서 있는 상태가 되거나 그 반대의 경우를 의미함
  • TCA에서 네비게이션은 앱 화면 전환 관리 기능을 의미함
  • State, Action을 사용하여 네비게이션을 처리함
  • 상태 기반 네비게이션에선 트리 기반과 스택 기반으로 구분할 수 있음

트리 기반 Navigation

  • drill-down Navigation 방식에서 사용되는 코드를 보자!
struct MainFeature: Reducer {
  struct State {
    @PresentationState var item: ItemFeature.State?
  }
}

struct ItemFeature: Reducer {
  struct State {
    @PresentationState var alert: AlertSTate<AlertAction>?
  }
}
  • 이처럼 파고 들어가는 구조가 충분히 생길 수 있음
MainView(
  store: Store(
    initialState: MainFeature.State(
      item: ItemFEature.State(
        alert: AlertSTate {
          TextState("Alert!!")
        }
      )
    )
  )
) { 
  MainFeature()
}
  • 이런식으로 뷰에서 초기화 시 구성할 수 있음
  • 도메인 모델링의 트리 구조를 가짐
  • 상태에 대한 존재 여부를 가지고 옵셔널이나 열거형 타입을 사용하는 경우 이러한 트리 기반 구조
  • 더 자세한것은 아래에서 확인할 예정 😄

스택 기반 Navigation

  • 상태 존재 여부를 컬렉션 타입으로 표현하는 방식
  • SwiftUI의 NavigationStack에서 활용되는것과 동일
  • 스택 내 네비게이션 가능한 모든것들을 열거형으로 정의할 수 있음
enum Path { 
  case item(ItemFeature.State)
  case edit(EditFeature.State)
}

let path: [Path] = [ 
  .item(ItemFeature.State(item: item)),
  .edit(EditFeature.State(item: item)),
]
  • Path는 스택에 표시되는 기능 모음
  • 이런 형태가 기본적인 스택 기반 네비게이션
  • 더 자세한건 마찬가지로 아래에서..!

트리 기반 VS 스택 기반

  • 대부분은 혼합되어 사용
  • 시트, 팝오버, 얼럿 같은 경우는 모달의 형태이기에 트리 기반 네비게이션을 사용
  • 트리 기반 장/단점
    • 간결한것이 가장 특징
    • 앱에서 가능한 모든 네비게이션 경로를 정적으로 선언하고 유효하지 않은 네비게이션 경로에 대해선 사용하지 않도록 보장될 수 있음
    • 앱에서 지원되는 네비게이션 경로 수를 제한할 수도 있음 (최대 몇개까지 뜰지 등)
    • 모듈화 시 기능 모듈은 트리 기반 네비게이션을 사용해 구축되면 더욱 독립적으로 갈 수 있음
    • 기능 간 밀접한 결합 특성으로 유닛 테스트 작성도 간단
    • 모든 형태의 네비게이션을 간결한 단일 스타일의 API로 통합
    • 복잡하거나 재귀적인 네비게이션 경로에서 사용 시 번거로울 수 있음
    • 트리 기반은 서로 기능들이 결합되기에 컴파일 시간을 늦출 수 있음 (하위까지 모두 빌드해야하기에)
  • 스택 기반 장/단점
    • 재귀적인 네비게이션 경로 처리가 쉬움
    • 스택에 포함된것들은 각 실제로 완전히 다른 화면과 분리될 수 있음
    • 서로 의존성이 없기에 다른 부분을 컴파일하지 않아도 독립적으로 컴파일이 가능
    • SwiftUI의 NavigationLink 방식인 기존 트리 기반보다 버그가 적어 안정적
    • 비논리적 네비게이션 경로를 표현할 수 있어 실제로 실수할 여지가 존재
    • 앱 모듈화를 통해 분리하여 실행 시 독립적이기에 다른 기능들은 비활성화 상태를 가짐 (전체 앱을 컴파일하고 실행하지 않는한)
    • 단위 테스트 진행이 어려움 (상호작용을 쉽게 테스트하기가 어려움)
    • drill-down 방식에서만 적용되고 시트, 팝오버, 얼럿등의 모달 형태로 뜨는것에는 대응되지 않음
      • 개인적으로 이건 단점이라기보다 특성이라고 생각됨
let path: [Path] = [
  .edit(),
  .edit(),
  .edit(),
]
해당 경우가 비논리적 네비게이션 경로로 표현되는 경우 🥲


트리 기반 Navigation 톺아보기

  • 옵셔널과 열거형 상태를 사용해 네비게이션을 모델링하는 방식
  • 깊이 중첩된 상태를 가볍게 구성해 SwiftUI에 전달하여 처리는 SwiftUI가 담당하도록함
  • 또한, 해당 트리 기반 네비게이션 방식으로 앱의 어떤 상태로든 딥링크를 생성하는 역할을 가질 수 있음
struct MainFeature: Reducer {
  struct State: Equatable {
    @PresentationState var item: ItemFeature.State?
  }
  
  enum Action: Equatable {
    case addItem(PresentationAction<ItemFeature.Action>)
  }
  
  var body: some ReducerOf<Self> {
    Reduce<State, Action> { state, action in 
      switch action {
      case .addButtonTapped:
        state.addItem = ItemFeature.State()
        return .none
      }
    }
    .ifLEt(\.$addItem, action: /Action.addItem) {
      ItemFeature()
    }
  }
}

struct MainView: View {
  let store: StoreOf<MainFeature>
  
  var body: some View {
    List {
      ...
    }
    .sheet(
      store: self.store.scope(state: \.$addItem, action: { .addItem($0) }) { store in
        ItemView(store: store)
      }
    )
  }
}
  • 전체적인 흐름은 위에 코드처럼 사용될 수 있음
  • PresentationState와 PresentationAction 타입을 활용해 통합
  • ifLet(_:action:then:fileID:line:) Redcuer 연산자를 사용해 리듀서를 결합
  • sheet(store:) 모디파이어를 사용하여 뷰에서 노출시켜줌
  • alert, confirmDialog, sheet, popover, fullScreenCover 등 SwiftUI의 모든 Navigation API에 대한 오버로드가 포함되어 있음

열거형 상태

  • 옵셔널 상태로 네비게이션을 제어하는것도 좋지만, 간혹 한 화면에 대해 옵셔널 값을 동시에 가질때 nil이 아닌 상태가 발생할 여지도 있음
  • 이런 경우를 방지하고자 열거형으로 모델링하는것이 더 나을 수 있음
struct InventoryFeature: Reducer {
  struct State {
    @PresentationState var destination: Destination.State?
  }
  enum Action {
    case destination(PresentationAction<Destination.Action>)
  }
  
  ... // 리듀서 구성
  case .addButtonTapped:
    state.destination = .addItem(AddFeature.State())
    return .none
 ...

  struct Destination: Reducer {
    enum State {
      case addItem(AddFeature.State)
      case detailItem(DetailFeature.State)
      case editItem(EditFeature.State)
    }
    enum Action {
      case addItem(AddFeature.Action)
      case detailItem(DetailFeature.Action)
      case editItem(EditFeature.Action)
    }
    var body: some ReducerOf<Self> {
      Scope(state: /State.addItem, action: /Action.addItem) { 
        AddFeature()
      }
      Scope(state: /State.editItem, action: /Action.editItem) { 
        EditFeature()
      }
      Scope(state: /State.detailItem, action: /Action.detailItem) { 
        DetailFeature()
      }
    }
  }
}
  • 즉, 중첩될 일은 없음
  • Destination을 만들어 사용하는 형식
  • 이제 마지막 뷰에서는 아래와 같이 편리하게 사용할 수 있음
struct InventoryView: View {
  let store: StoreOf<InventoryFeature>

  var body: some View {
    List {
      /* code */
    }
    .sheet(
      store: self.store.scope(state: \.$destination, action: { .destination($0) }),
      state: /InventoryFeature.Destination.State.addItem,
      action: InventoryFeature.Destination.Action.addItem
    ) { store in 
      AddFeatureView(store: store)
    }
    .popover(
      store: self.store.scope(state: \.$destination, action: { .destination($0) }),
      state: /InventoryFeature.Destination.State.editItem,
      action: InventoryFeature.Destination.Action.editItem
    ) { store in 
      EditFeatureView(store: store)
    }
    .navigationDestination(
      store: self.store.scope(state: \.$destination, action: { .destination($0) }),
      state: /InventoryFeature.Destination.State.detailItem,
      action: InventoryFeature.Destination.Action.detailItem
    ) { store in 
      DetailFeatureView(store: store)
    }
  }
}
  • 결국 트리 기반에서 흐름이 꼬일일이 없고 옵셔널 상태보다 조금 더 안전한 느낌이 듬

API 통합

  • 트리 기반 네비게이션의 장점 중 하나는 모든 형태의 네비게이션을 단일 API 스타일로 통합한다는것에 있음
  • ifLet 연산자를 통해서 가능 (모든 형태의 옵셔널 기반 네비게이션을 지원)
  • 또한, 열거 타입에서 아래와 같이 추가적인 작업의 조합이 편리
  case .destination(.presented(.editItem(.saveButtonTapped))):
    guard case let .editItem(editItemState) = self.destination
    else { return .none }

    state.destination = nil
    return .fireAndForget {
      self.database.save(editItemState.item)
    }

 


Dismissal

  • 해당 네비게이션을 닫는 기능은 쉽게 nil로 처리하여 가능
case .closeButtonTapped:
  state.destination = nil
  return .none
  • 혹은 ChildView에서 dismiss 환경 변수를 이용해 부모와 소통없이도 닫을 수 있음
struct ChildView: View {
  @Environment(\.dismiss) var dismiss

  var body: some View {
    Button("close") { 
      self.dismiss() 
    }
  }
}
  • 해당 dismiss가 호출되면 상위에서 바인딩된 값에 nil을 자동으로 SwiftUI가 입력해 해제하는 방식
  • 유용하지만 뷰모델에서 유효성 검사나 비동기 작업과 같은 복잡한 로직들이 구현되어 있을때는 사용하기 현실적으로 어려움 (딱 뷰에 한정적)
  • TCA에선 리듀서에서 사용하기 적합한 DismissEffect를 제공 (리듀서 내부에서만 사용!)
struct Feature: Reducer {
  struct State { ... }
  struct Action {
    case closeButtonTapped
  }
  
  @Dependency(\.dismiss) var dismiss
  
  ... // 리듀서 구성
   switch action {
   case .closeButtonTapped:
     return .run { send in 
       await self.dismiss()
     }
   }
}
  • 이와 같이 사용할 수 있는데, 해당 dismiss는 비동기 함수이기에 .run에서 호출해야함
  • 또한, dismiss 호출 후 다른 액션 처리를 하는것은 런타임 워닝이 됨

트리 기반 네비게이션 테스팅

  • 피쳐 테스트용 TestStore를 만듬
func testDismissal() {
  let store = TestStore(
    initialState: Feature.State(
      counter: CounterFeature.State(count: 3)
    )
  ) {
    CounterFeature()
  }
}

await store.send(.counter(.presented(.incrementButtonTapped))) {
  $0.counter?.count = 4
}

await store.receive(.counter(.dismiss)) {
  $0.counter = nil
}
  • 이와 같이 액션에 따른 상태 변경 테스트 및 dismiss 테스트를 해볼 수 있음
  • 아니면 실제로 중요하다고 판단되는 기능들의 일부들을 모아 검증할 수 있음
func testDismissal() {
  let store = TestStore(
    initialState: Feature.State(
      counter: CounterFeature.State(count: 3)
    )
  ) {
    CounterFeature()
  }
  store.exhaustivity = .off

  await store.send(.counter(.presented(.incrementButtonTapped)))
  await store.send(.counter(.presented(.incrementButtonTapped)))
  await store.receive(.counter(.dismiss)) 
}
  • 열거형 상태로 모델링이 되어 있다면, XCTModify를 활용할 수 있음
await store.send(.destination(.presented(.counter(.incrementButtonTapped)))) {
  XCTModify(&$0.destination, case: /Feature.Destination.State.counter) { 
    $0.count = 4
  }
}
  • 해당 함수는 첫번째 매개변수로 inout 형태의 enum 상태 변수를 받고 두번째로는 케이스패스를 받아 케이스패스를 활용해 해당 케이스의 payload를 추출하여 수정 및 데이터를 다시 enum에 재삽입하는 과정을 거침

스택 기반 Navigation 톺아보기

  • 컬렉션 상태를 사용해 네비게이션을 모델링하는 방식
  • 단순히 1차원 데이터 컬렉션을 구성하여 SwiftUI에 전달하여 동작하는 방식
struct RootFeature: Reducer {
  struct State {
    var path = StackState<Path.State>()
  }
  enum Action {
    case path(StackAction<Path.State, Path.Action>)
  }
  
  struct Path: Reducer {
    enum State {
      case addItem(AddFeature.State)
      case detailItem(DetailFeature.State)
    }
    enum Action {
      case addItem(AddFeature.Action)
      case detailItem(DetailFeature.Action)
    }
    var body: some ReducerOf<Self> {
      Scope(state: /State.addItem, action: /Action.addItem) {
        AddFeature()
      }
      Scope(state: /State.detailItem, action: /Action.detailItem) {
        DetailFeature()
      }
    }
  }
  
  var body: some ReducerOF<Self> {
    Reduce { state, action in 
      ...
    }
    .forEach(\.path, action: /Action.path) {
      Path()
    }
  }
}
  • 이와 같이 리듀서가 구성될 수 있음
  • StackState와 StackAction을 사용하고 forEach로 리듀서에 결합해줌
  • 여기서도 트리 기반 네비게이션의 열거형 상태를 만들때 하던것처럼 Path라는 새 리듀서를 정의하여 스택에 추가될 모든 기능들의 도메인을 포함하여 구성
  • 이제 뷰에서는 아래와 같이 사용
struct RootView: View {
  let store: StoreOf<RootFeature>
  
  var body: some View {
    NavigationStackStore(
      path: self.store.scope(state: \.path, action: { .path($0) })
    ) { 
      // RootView 구성
    } destination: { state in 
      switch state {
      case .addITem:
        CaseLet(
          state: /RootFeature.Path.State.addItem,
          action: RootFeature.Path.Action.addItem,
          then: AddView.init(store:)
        )
      case .detailItem:
        CaseLet(
          state: /RootFeature.Path.State.detailItem,
          action: RootFeature.Path.Action.detailItem,
          then: DetailView.init(store:)
        )
      }
    }
  }
}
  • Path.State 열거형의 모든 케이스 처리에 매칭시켜 뷰를 구성

API 통합

  • 패턴 매칭을 통해 이후 동작들의 조합이 가능
case let .path(.element(id: id, action: .editItem(.saveButtonTapped))):
  guard case let .editItem(editItemState) = self.path[id: id]
  else { return .none }

  state.path.pop(from: id)
  return .run { _ in
    await self.database.save(editItemState.item)
  }
  • subscript(id:)나 pop(from:)을 이용하여 동작 구성도 가능

Dismissal

  • 스택 기반에서 쌓인것을 제거하기 위해서 popLast()나 pop(from:)을 이용하면 편리함
  • 트리 기반에서와 마찬가지로 뷰에서 dismiss 환경 변수를 사용하여 닫을 수도 있음
  • 또한 리듀서에서 DismissEffect를 사용하여도 동일하게 가능 (트리 기반에서 동일 내용 언급되어 참고!)

StackState VS NavigationPath

  • SwiftUI에선 NavigationPath라는 타입 자체가 있어 NavigationStack에서 데이터 모델링 시 사용
  • 근데 왜 TCA에선 StackState를 만들었을까?
    • 단순히 끝에 추가 및 제거만 되고 Path 내부 항목을 순회할 수 없었음
    • 스택 상태의 분석이나 데이터 집계하는것이 기본적인 NavigationPath의 기능으론 되지 않아 StackState로 보완하여 만듬
    • StackState는 완전한 정적의 타입화를 가짐
    • 그렇기에 데이터를 함부로 추가할 수 없다는 장단을 가짐
    • 그러나, Collection 프로토콜의 RandomAccessCollection, RangeReplaceableCollection의 요구사항을 만족하기에 컬렉션 조작과 스택 내부 접근에 대해 여러 방법을 사용할 수 있게 구성됨
    • 즉, 런타임의 유연성과 컴파일 타임에서 정적 타입 보장의 균형이 잘 유지되고 있음

스택 기반 네비게이션 테스팅

  • 트리 기반과 마찬가지로 테스트에 용이함 
  • 트리 기반에서 설명한 모든 내용과 코드가 거의 일치하여 큰 설명없이 하나의 참고 테스트 코드만 보고 넘어가봄~
func testDismissal() {
  let store = TestStore(
    initialState: Feature.State(
      path: StackState([
        CounterFeature.State(count: 3)
      ])
    )
  ) {
    CounterFeature()
  }
  store.exhaustivity = .off

  await store.send(.path(.element(id: 0, action: .incrementButtonTapped))) 
  await store.send(.path(.element(id: 0, action: .incrementButtonTapped))) 
  await store.receive(.path(.popFrom(id: 0)))
}
  • 테스트에 대해 간결한 코드로 이뤄지며 중요치 않은 기능의 변경에 더 유연하게 대응할 수 있음

소감

  • 네비게이션에 대해 확실히 이번에 좀 많이 신경을 쓴 느낌이 들었음
  • TCACoordinator라는 라이브러리를 자주 이용하는데 해당 라이브러리를 이용하면 이정도 원초적으로 사용까지 하진 않겠지만, 해당 라이브러리가 발전되는 속도가 더뎌 이제는 필요할지 의문이 생겼음

레퍼런스

 

Chapter 8. Navigation | Notion

8.1 Navigation이란?

axiomatic-fuschia-666.notion.site