Library

TCACoordinators

GREEN.1229 2022. 8. 15. 18:32

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

이번 포스팅에서는 TCACoordinators라는 외부 라이브러리에 대해 알아보겠습니다🙋🏻

해당 라이브러리의 깃 주소는 아래와 같습니다!

https://github.com/johnpatrickmorgan/TCACoordinators

 

GitHub - johnpatrickmorgan/TCACoordinators: Powerful navigation in the Composable Architecture via the coordinator pattern

Powerful navigation in the Composable Architecture via the coordinator pattern - GitHub - johnpatrickmorgan/TCACoordinators: Powerful navigation in the Composable Architecture via the coordinator p...

github.com

 

TCA와 Coordinators?

뭔가 이름부터 TCA와 Coordinators가 따로 있는것이고 합성어 같은 구성의 느낌이 오죠?

그럼 우선 TCACoordinators를 알아보기전에 TCA와 Coordinators가 무엇인지부터 짚고 넘어가는게 순서에 맞을것 같네요!

TCA라는건 Flux, Redux를 기반으로 만든 상태 기반의 단방향 아키텍쳐입니다.

자세한 설명은 일전에 포스팅으로 소개한적이 있으니 참고하시면 좋을것 같습니다🙌

https://green1229.tistory.com/138

 

Composable Architecture

안녕하세요. 그린입니다🟢 이번 포스팅에서는 Composable Architecture에 대해 학습해보겠습니다🧑🏻‍💻 왜 알아보게 되었는지? 앞으로는 SwiftUI와 사용자 이벤트를 통한 뷰의 업데이트 등 상태 값

green1229.tistory.com

그다음 Coordinators라는건 많이 들어보셨을거에요.

일명 Coordinator 패턴이라고 해서 화면 전환을 담당하는 역할을 만들어 쉽게 해줄 수 있도록 하는 패턴입니다.

우리는 앱을 개발하면서 당연히 화면 전환에 대해 더 매끄럽고 쉽게 관리될 수 있도록 늘 생각하고 고민합니다.

이럴때 하나의 패턴으로 채택해서 사용하는게 바로 Coordinator 패턴입니다.

 

자 그렇다면, 이 TCA와 Coordinator를 합친것인 어떤 라이브러리인지 이제 알아보시죠🕺🏻

 

TCACoordinators?

쉽게 요약하자면 TCA에서 Coordinator 패턴을 사용할 수 있도록 도와주는 라이브러리입니다.

당연히 Coordinator 패턴은 직접 구현할 수 도 있고 원하는 방향으로 커스텀하게도 가능하죠.

이 라이브러리는 TCA라는 특수한 아키텍쳐 환경에서 Coordinator 패턴을 쉽게 사용할 수 있도록 해주고 또 여러 화면 전환의 메서드를 구현하여 사용할 수 있도록 제공해주는 편리한 이점이 있습니다!

여기까지는 짧은 요약이고 해당 라이브러리 공식 리드미의 설명은 이렇습니다.

TCA를 사용한 SwiftUI 환경에서 탐색에 대한 유연한 접근 방식을 제공해줍니다.

이를 통해 상위 수준 조정자로 연결된 단일 상태로 한 곳에서 복잡한 탐색 및 화면 전환 흐름을 모두 관리할 수 있습니다.

이 패턴을 통해 각 모듈별 격리된 화면 기능을 구현할 수 있습니다.

이 패턴을 위해서는 리듀서에 대해 forEach와 pullback을 통해 가져오고 뷰에서는 SwitchStore 내부에 담아 보여줍니다.

 

TCACoordinators의 장점

1. 앱에서 딥하게 중첩된 탐색 경로, 즉 화면 전환에 대해 딥링크를 지원해줍니다.

2. 다양한 탐색을 가능하게하는 화면 기능을 통해 쉽게 재사용할 수 있습니다.

3. 루트 뷰나 특정 뷰로 쉽게 전환할 수 있습니다.

4. 모든 전환 로직을 한곳에서 관리할 수 있습니다.

5. 필요에 따라 화면 전환에 재사용 가능한 여러 코디네이터로 나눠 구성할 수 있습니다.

6. UIKit에 의존되지 않습니다.

7. 화면 제거 입력을 위한 AnyView와 같은것을 사용하지 않습니다.

8. NavigaionView를 처음부터 다시 만들지 않습니다.

또한, 해당 라이브러리는 화면 배열을 중첩 탐색 링크 및 화면 호출의 계층 구조로 변환해 작동합니다.

 

TCACoordinators 설치

SPM과 Cocoapods 모두 가능합니다.

우선 해당 포스팅에서는 편의를 위해 SPM으로 사용합니다!

당연히 TCACoordinators는 TCA를 import하고 있는점 참고하시면 됩니다.

 

TCACoordinators 사용하기

해당 라이브러리 공식 리드미의 사용법을 참고해서 제가 커스텀하게 만든 예시를 가지고 하나씩 따라해보시죠🙌

 

1. 스크린 리듀서 생성

여기서 스크린이란 홈과 디테일 처럼 나눠져 있는 각 뷰들을 하나의 스크린으로 통합하여 볼 수 있습니다.

즉 하나의 피쳐를 구성하는 단어로 사용됩니다.

예를들어 홈화면에서 리스트가 주어지고 해당 리스트의 인덱스를 클릭하면 디테일 화면으로 전환되는것을 하나의 피쳐라고 볼 수 있습니다.

여기서 이 해당 피쳐가 스크린이라는 네이밍으로 사용되었습니다.

이 네이밍들을 각자의 업무와 코드 컨벤션에 맞게 응용하면 됩니다.

자 그럼 시작해보죠!

우선 저는 홈 화면과 디테일 화면을 아래와 같이 간단하게 구성했습니다.

// 홈 뷰 (HomeView)

import SwiftUI
import ComposableArchitecture

public struct HomeView: View {
  var store: Store<HomeState, HomeAction>
  
  public var body: some View {
    WithViewStore(store) { viewStore in
      List {
        ForEach(viewStore.nums, id: \.self) { num in
          Button(
            action: {
              ViewStore(store).send(.itemTapped(num))
            },
            label: { Text("\(num)") }
          )
        }
      }
    }
  }
}

// 홈 코어 (HomeCore)

import ComposableArchitecture

public struct HomeState: Equatable {
  public var nums: [Int]
  
  public init(nums: [Int] = Array(1...10)) {
    self.nums = nums
  }
}

public enum HomeAction {
  case itemTapped(Int)
}

public struct HomeEnvironment {
}

public let homeReducer = Reducer<HomeState, HomeAction, HomeEnvironment> { state, action, env in
  switch action {
  case let .itemTapped(num):
    return .none
  }
}

// 디테일 뷰 (DetailView)

import SwiftUI
import ComposableArchitecture

public struct DetailView: View {
  var store: Store<DetailState, DetailAction>
  
  public var body: some View {
    WithViewStore(store) { viewStore in
      Text("\(viewStore.title)")
      Button(
        action: { ViewStore(store).send(.backButtonTapped) },
        label: { Text("Go back to list view") }
      )
    }
    .navigationBarHidden(true)
  }
}

// 디테일 코어 (DetailCore)

import ComposableArchitecture

public struct DetailState: Equatable {
  public var title: Int
  
  public init(title: Int) {
    self.title = title
  }
}

public enum DetailAction {
  case backButtonTapped
}

public struct DetailEnvironment {
}

public let detailReducer = Reducer<DetailState, DetailAction, DetailEnvironment> { state, action, env in
  switch action {
  case .backButtonTapped:
    return .none
  }
}

해당 코드는 TCA에 익숙하다는 전제를 가짐으로 따로 세부적까지는 설명은 스킵하겠습니다.

단순히 홈 화면에서 주어진 숫자가 리스트 버튼으로 나타나고 이 버튼을 클릭하면 해당하는 디테일뷰로 전환되는 아주 기초적인 네비게이션 방식의 뷰를 구현하기 위한 각 홈과 디테일의 모듈입니다.

 

이 두 모듈을 담을 수 있는 ScreenState와 ScreenAction 그리고 screenReducer가 필요합니다.

뷰가 필요없는 이유는 여기서의 Screen 개념은 단지 각 모듈을 가져와서 리듀서를 만들어주고 관리해주기 위할 뿐임으로 뷰는 필요하지 않습니다.

// ScreenCore

import ComposableArchitecture

public enum ScreenState: Equatable {
  case home(HomeState)
  case detail(DetailState)
}

public enum ScreenAction {
  case home(HomeAction)
  case detail(DetailAction)
}

public struct ScreenEnvironment {
}

public let screenReducer = Reducer<ScreenState, ScreenAction, ScreenEnvironment>.combine([
  homeReducer.pullback(
    state: /ScreenState.home,
    action: /ScreenAction.home,
    environment: { _ in HomeEnvironment() }
  ),
  detailReducer.pullback(
    state: /ScreenState.detail,
    action: /ScreenAction.detail,
    environment: { _ in DetailEnvironment() }
  )
])

이렇게 스크린에 대해 리듀서를 구성해줍니다.

State, Action은 해당 모듈들을 가져오고 리듀서에서는 각 모듈을 pullback 받습니다!

즉 하나의 리듀서로 결합하는 TCA 사용자에게 익숙한 그림입니다.

 

2. 코디네이터 리듀서 생성

이제 해당 스크린을 가지고 탐색 흐름에서 여러 화면을 관리할 수 있도록 코디네이터 리듀서라는 친구를 만들어봅시다.

우선 Core 단에서는 State, Action, (Environment), Reducer의 구조가 필요합니다.

먼저 코드로 보고 설명하겠습니다.

// CoordinatorCore

import ComposableArchitecture
import TCACoordinators

public struct CoordinatorState: Equatable, IndexedRouterState {
  public var routes: [Route<ScreenState>]
  
  public init(routes: [Route<ScreenState>] = [.root(.home(.init()), embedInNavigationView: true)]) {
    self.routes = routes
  }
}

public enum CoordinatorAction: IndexedRouterAction {
  case routeAction(Int, action: ScreenAction)
  case updateRoutes([Route<ScreenState>])
}

public struct CoordinatorEnvironment {
}

public let coordinatorReducer: Reducer<CoordinatorState, CoordinatorAction, CoordinatorEnvironment> = screenReducer
  .forEachIndexedRoute(environment: { _ in ScreenEnvironment() })
  .withRouteReducer(
    Reducer { state, action, environment in
      switch action {
      case let .routeAction(_, action: .home(.itemTapped(num))):
        state.routes.push(.detail(.init(title: num)))
        return .none
        
      case .routeAction(_, action: .detail(.backButtonTapped)):
        state.routes.pop()
        return .none
        
      default:
        return .none
      }
    }
  )

자 보시면 State에는 routes라는 Route 배열 타입이 들어갑니다.

그전에 우선 IndexedRouterState 프로토콜 채택은 필수입니다.

해당 프로토콜의 구현부를 살펴보면 Route 배열 타입을 사용할 수 있게 해주기 위함입니다.

import Foundation
import FlowStacks

public protocol IndexedRouterState {
  associatedtype Screen

  var routes: [Route<Screen>] { get set }
}

아마 저 FlowStacks라는 라이브러리를 까보면 더 자세히 알 수 있겠지만 그건 다음 포스팅에서 다뤄볼께요🙌

그래서 정리하자면 해당 타입은 탐색 스택을 나타내기위한 타입이죠.

제네릭하게 어떤 ScreenState를 가질지 정해줘야합니다.

즉 이 배열에 새 화면 상태를 추가하면 해당 화면이 푸쉬되는것이고 제거되면 팝되는 그런 트리거가 됩니다.

 

그 다음 Action으로는 필수적인 IndexedRouterAction 프로토콜을 따릅니다.

이 프로토콜의 구현은 아래와 같습니다.

import Foundation
import FlowStacks

public protocol IndexedRouterAction {

  associatedtype Screen
  associatedtype ScreenAction

  /// Creates an action that allows routes to be updated whenever the user navigates back.
  /// - Returns: An `IndexedScreenCoordinatorAction`, usually a case in an enum.
  static func updateRoutes(_ screens: [Route<Screen>]) -> Self

  /// Creates an action that allows the action for a specific screen to be dispatched to that screen's reducer.
  /// - Returns: An `IndexedScreenCoordinatorAction`, usually a case in an enum.
  static func routeAction(_ index: Int, action: ScreenAction) -> Self
}

보시면 아시겠지만 updateRoutes, routeAction 메서드 선언 및 구현은 필수 입니다.

updateRoutes 메서드는 해당 CoordinatorState의 특정 상태를 트리거삼아 어떠한 액션을 해줄 수 있습니다.

routeAction에서 받는 Int 타입의 index와 ScreenAction 타입을 받습니다.

ScreenAction에서는 홈과 디테일 모듈의 액션을 가지고 있음으로 해당 액션을 트리거삼아 어떠한 액션을 해줄 수 있겠죠.

저기서 index는 해당 케이스를 트리거시킨 시점의 라우트에 담긴 데이터 수를 나타냅니다.

그래서 홈에서 리스트를 클릭하면 0의 데이터를 디테일에서 빠져나오는 버튼을 클릭하면 1의 데이터를 노출시킵니다.

 

마지막으로 코디네이터 리듀서는 배열의 각 화면에서 forEachIndexedRoute를 적용해 사용합니다.

위의 상태와 액션을 받아와 관리해주죠!

forEachIndexedRoute와 withRouteReducer 메서드를 통해 결국 화면을 관리해주는 핵심 구현부를 담당하죠.

 

3. 코디네이터 뷰 생성

마지막으로 해당 코디네이터 코어와 연계되어 화면을 나타낼 뷰를 만들어줍니다.

TCARouter안에 store를 담고 SwitchStore를 통해 해당 코디네이터가 어떤 뷰들을 가질 수 있는지 CaseLet으로 지정해 코드를 따라줍니다.

즉 적절하게 해당하는 뷰들이 바인딩되어 구성되는것이죠.

// CoordinatorView

import ComposableArchitecture
import SwiftUI
import TCACoordinators

public struct CoordinatorView: View {
  let store: Store<CoordinatorState, CoordinatorAction>
  
  public var body: some View {
    TCARouter(store) { screen in
      SwitchStore(screen) {
        CaseLet(
          state: /ScreenState.home,
          action: ScreenAction.home,
          then: HomeView.init
        )
        CaseLet(
          state: /ScreenState.detail,
          action: ScreenAction.detail,
          then: DetailView.init
        )
      }
    }
  }
}

 

4. 앱 뷰에 올리기

해당 만들어진 코디네이터 뷰를 노출시켜줄 루트뷰인 앱 뷰에서 해당 뷰를 올려줍니다.

// AppView

import SwiftUI

struct AppView: View {
  var body: some View {
    CoordinatorView(
      store: .init(
        initialState: .init(),
        reducer: coordinatorReducer,
        environment: CoordinatorEnvironment()
      )
    )
  }
}

 

자 이렇게 되면 구현은 끝났습니다!

시연 영상 한번 보실까요?

우리가 원하던 화면 전환이 아주 쉽게 이뤄졌습니다.

즉 SwiftUI에서 네비게이션 뷰를 우리가 직접적으로 코드로 구현하지 않고도 편리하게 구현되었습니다!

 

자 이제 그럼 화면 전환을 위한 메서드로 어떤게 있으며 더 리드미에서 자세한 정보들을 보시죠!

 

화면 전환 메서드

라우트 배열은 일반적으로 우리가 알고 있는 배열 메서드를 사용해 커스텀하게도 관리할 수 있습니다.

그리고 해당 라이브러리에서 정의해둔 여러 편의성을 가진 메서드들도 있어요.

물론 다 커스텀하게도 구현해도 무방하겠지만 기왕 라이브러리 쓰는거 컨베니언스 메서드를 사용하면 더 좋겠죠?

해당 메서드들로 왠만한 앱 구현에서는 다 커버될 수 있다고 생각합니다.

예를들어 presentSheet로 간단히 바텀 시트를 구현해주고 싶다면 아래와 같이 코드 한줄이면 됩니다.

case let .routeAction(_, action: .home(.itemTapped(num))):
  state.routes.presentSheet(.detail(.init(title: num)))
  return .none

그럼 이렇게 아주 편리하게 나타나요.

원하는거 잘 맞게 사용하시면 좋겠네요🙌

아 참고로 화면을 푸시하려면 embedInNavigationView: true의 옵션을 설정해줘야합니다.

저는 CoordinatorState의 이니셜라이저에서 해당 옵션을 주었어요.

public struct CoordinatorState: Equatable, IndexedRouterState {
  public var routes: [Route<ScreenState>]
  
  public init(routes: [Route<ScreenState>] = [.root(.home(.init()), embedInNavigationView: true)]) {
    self.routes = routes
  }
}

 

그 외에 편리하게 구현된 것들

그 외 리드미에서 소개하는 장점이자 편리성을 위한것들이 몇가지 있습니다.

 

첫째로, 경로 배열이 자동으로 업데이트 된다는것입니다.

즉, 사용자가 뒤로 가기 버튼을 탭하면 탐색 상태를 자동으로 반영해 해당 routes 배열이 자동 업데이트 됩니다.

스와이프 제스쳐나 버튼을 길게 눌러 루트 뷰로 가거나 하는 모든것들에도 자동 업데이트가 되니 너무 편리하겠네요!

 

둘째, withRouteReducer에 cancelEffectsOnDismiss: false 옵션 설정을 통해 특정 화면에서 시작된 모든 효과는 해당 화면이 팝업 되거나 해제될 때 자동으로 취소됩니다.

즉 이 작업에 효과를 제거하거나 중지하기 위한 기존의 다른 공수가 들지 않게되는것이죠.

 

셋째, SwiftUI에서 여러 화면을 푸시 혹은 표시, 해제하는것의 어려움을 해결해준다.

기존 SwiftUI에서는 여러 화면을 노출 시키는것을 허용하지 않았어요.

그에 반면 해당 라이브러리에서는 아주 간단히 이러한것을 가능하게 해줍니다.

return Effect.routeWithDelaysIfUnsupported(state.routes) {
  $0.goBackToRoot()
}

return Effect.routeWithDelaysIfUnsupported(state.routes) {
  $0.push(...)
  $0.push(...)
  $0.presentSheet(...)
}

한번에 모두 해제되거나 여러개를 푸쉬시키고 나타내주는것도 가능해지죠.

즉 이런게 가능해지는걸 짤로 보시죠!

 

넷째, 여러 코디네이터 구성에 편리합니다.

부모와 차일드 개념의 코디네이터를 각자 구성해 이를 또 코디네이터로 합쳐주기도 편리하고 쉽습니다.

즉 각자의 피쳐마다 코디네이터를 가질 수 있고 이 피쳐들이 모여 화면 전환을 위한 최상위 코디네이터의 구조를 가질 수 있는것이죠.

해당 구성이 된다면 아주 유연하고 재사용이 가능해지죠.

다만 구조가 꼬이지 않게 잘 구조적인걸 생각하고 구성해야 합니다🙌

 

단점이자 제한사항

현재는 스택 구조로만 해당 라우트 배열이 사용되는 단점이자 제한이 있습니다.

컬럼 구조로 사용할때 아직은 문제가 좀 있어 해결해보고 있다고 하네요!

 

느낀점

너무 편리하네요.

사실 코디네이터 패턴 없이 TCA만으로 구현하면서 상태 기반 관리를 해줄 수 있지만 조금 불편했어요.

어떤점이 불편했냐면 때로는 차일드 뷰가 부모 뷰를 다시 바라보고 하는 형태가 가끔 있어 cycle circulation 문제가 발생하기도 했습니다.

이를 조금 억지로 구현하다보니 자연스러움이 사라질때가 많아서 지금과 같은 TCACoordinators를 가져와 코디네이터 패턴을 사용한것이 참 많이 도움이 되고 편리하네요!

위 예시들을 리드미에서는 안 다뤄진 부분까지 조금 편리하고 이해하기 쉽도록 구현한 예제 코드를 같이 공유드립니다🙌

https://github.com/GREENOVER/playground/tree/main/TCACoordinatorsPractice

 

GitHub - GREENOVER/playground: 학습을 하며 간단한 예제들을 만들어 보는 작고 소중한 놀이터

학습을 하며 간단한 예제들을 만들어 보는 작고 소중한 놀이터. Contribute to GREENOVER/playground development by creating an account on GitHub.

github.com

 

[참고자료]

https://github.com/johnpatrickmorgan/TCACoordinators

 

GitHub - johnpatrickmorgan/TCACoordinators: Powerful navigation in the Composable Architecture via the coordinator pattern

Powerful navigation in the Composable Architecture via the coordinator pattern - GitHub - johnpatrickmorgan/TCACoordinators: Powerful navigation in the Composable Architecture via the coordinator p...

github.com