Intro

  • 본 글은 iOS 14 기준으로 작성 되었습니다.

Coordinator Pattern은 화면전환에 관련된 코드를 Coordinator 라고 하는 별도의 모델로 책임을 분리하는 디자인 패턴 중 하나입니다. 아키텍처로 고도화 할 경우, 모든 화면 전환 로직, 화면 전환 애니메이션 등을 관리할 수 있어서 굉장히 유용한 디자인 패턴 중 하나입니다.

기존 전통적인 iOS 어플리케이션에서는 UIKit의 UIViewController와 UINavigationController가 존재했고, 이를 이용해서 손쉬운 Coordinator 구현이 가능했습니다. Coordinator가 UIViewController와 UINavigationController의 참조만 가지고 있다면 현재 뷰가 어디에 있든 화면 전환이 가능했습니다.

SwiftUI에서는 이를 구현하기 위해 UIHostingController, UINavigationController 와 UIViewController를 활용하고 뷰만 SwiftUI를 사용하는 방법이 있고, 순수 SwiftUI NavigationView(NavigationStack)를 사용하는 방법이 있습니다. 본 글에서는 순수 SwiftUI를 이용하는 방법을 설명하려고 합니다.

iOS 14 기준으로 보더라도 SwiftUI의 기능이 UIKit을 대체하기에는 아직 부족하기 때문에 UIKIt을 혼합해서 사용하는 것이 안정적인 방법이지만, 많은 iOS 개발자들이 SwiftUI로의 Full-Change 를 시도하고 있고 성과를 내고 있습니다. 앞으로 나올 최신 애플 프레임워크 또한 SwiftUI 기준으로 나오고 있기 때문에 시장에서 기술을 선도하기 위해 이 챌린지는 큰 의미가 있다고 생각합니다.


SwiftUI NavigationView

NavigationView는 View라는 프로토톨을 채용한 구조체 이기 때문에 기존의 Coordinator 방식을 그대로 적용할 수는 없습니다. NavigationView 계층이 위치한 View는 Navigation Link를 통해 화면전환을 하게 됩니다. 기존 UINavigationController는 현재 뷰를 알 필요 없이 push(), pop() 메서드를 호출하기만 하면 Stack 형식으로 뷰를 사용할 수 있었지만, NavigationLink는 뷰 내부에 종속되며 반드시 뷰 현재 화면에서 push, pop을 일으켜야 합니다.


요구사항

SwiftUI에서 Coordinator를 사용하기 전에 몇 가지 꼭 구현되어야 하는 사항을 짚고 넘어가겠습니다.

  1. 어떤 뷰에서든 어떤 뷰로도 화면 전환이 가능해야 합니다. (A라는 뷰에서 A 뷰를 Push 할 수 있다)
  2. Programmatically 방식의 트리거가 가능해야 한다.
  3. LazyVStack을 쓰더라도 Push가 가능해야 한다.
  4. PopToRoot 동작이 가능해야 한다.

a: 간단한 시나리오입니다. 브랜디 앱을 예로 상품을 눌러 진입한 상품 상세 화면에서, 또 다른 상품 추천을 해 주기 때문에, 상품 상세 화면 > 상품 상세 화면 과 같은 시나리오가 가능해야 합니다.

b: NavigationToolbar, Deep Link 등과 같은 외부 입력에 의해서 화면전환이 필요하기 때문입니다.

c: SwiftUI에서는 기존 TableViewCell이나 CollectionViewCell에서 제공하는 재사용 기능을 LazyStack(Grid)를 통해서 지원한다는 것을 잘 아실 겁니다. 재사용 셀의 특성상 화면에 표시되기 전에는 해당 셀은 아직 메모리에 인스턴스화 되지 않은 상태이기 때문에, Programmatically 방식의 화면전환을 발생시키더라도 Push가 발생하지 않습니다.

d: View 계층이 많이 누적된 경우 PopToRoot와 같은 동작이 가능해야 합니다.

그리고 당연한 구현 사항으로 View계층에서 데이터 수정이 발생하면 즉각 반영되어야 합니다. 찜 버튼을 눌러서 화면을 Pop하고 나왔는데 해당 상품이 계속 찜 된 상태라고 보여줘야 합니다.


문제1: 모든 뷰로 화면전환

어플리케이션에서 사용하는 뷰가 A~Z 까지 있다고 가정했을 때 A라는 뷰에서 A~Z까지 이동할 수 있어야 합니다.

struct AView: View {
  var body: some View {
    NavigationLink {
      AView()
    } label: {
      EmptyView()
    }
    NavigationLink {
      BView()
    } label: {
      EmptyView()
    }
    NavigationLink {
      CView()
    } label: {
      EmptyView()
    }
    //...to ZView
  }
}

A 뷰부터 Z 뷰까지 모든 NavigationLink를 작성할 수도 있지만, 만약에 뷰 하나라도 삭제되거나 추가된다면 모든 뷰를 돌아다니면서 삭제(또는 추가)해야 하는 번거로움이 발생합니다.

우선 열거형을 통해 모든 뷰 작성에 대한 보일러 플레이트를 삭제해 보겠습니다.

enum Destination {
  case aView
  case bView
  case cView
  
  @ViewBuilder
  var view: some View {
    switch self {
    case .aView:
      AView()
    case .bView:
      BView()
    case .cView:
      CView()
    }
  }
}

이제 Destination 이라는 타입을 이용하면 모든 뷰를 일일이 작성하고 수정하는 일은 없어집니다.

Destination 을 뷰에서 어떻게 선언할 수 있을까요?

여기서부터 SwiftUI 만의 Coordinator를 빌드해 보겠습니다. SwiftUI에는 View이면서 EmptyView() 라는 아무것도 보여주지 않는 뷰가 존재하는데요, 이를 활용해서 화면에는 보이지 않지만 모든 뷰의 NavigationLink를 작성할 수 있습니다.

final class Coordinator: ObservableObject {
  var destination: Destination = .aView
  
  @ViewBuilder
  func navigationLinkSection() -> some View {
    NavigationLink {
      destination.view
    } label: {
      EmptyView()
    }
  }
}

Coordinator를 굳이 class와 ObservableObject로 선언한 이유에 대해서는 이후에 설명하겠습니다.

struct AView: View {
  @StateObject var coordinator = Coordinator()
  var body: some View {
    NavigationView {
      coordinator.navigationLinkSection()
    }
  }
}

이제 뷰 에서 Coordinator를 선언하고 navigationLinkSection() 을 만들어 주면, 화면에는 보이지 않지만 어디로든 화면전환 할 수 있는 기반이 마련됐습니다.


문제2: Programmatically 화면전환

문제1에 대한 해결로 Programmatically 화면전환은 필수 구현 사항이 됩니다. 왜냐하면 NavigationLink가 화면에 보이지 않기 때문에 더 이상 사용자의 직접적인 터치로 작동시킬 수 없습니다.

NavigationLink에는 Trigger로 동작시킬 수 있는 isActive:Binding<Bool> 파라미터를 전달할 수 있습니다.

isActive 값이 true가 되면 화면 전환을 실시하고 화면에서 나가게 되면 false를 전달합니다.

이를 통해 기존의 NavigationLink를 직접 터치해서 Push를 발생시키지 않고, 외부 입력이나 터치 등의 화면 전환의 트리거가 되는 액션들을 Coordinator로 요청하면 됩니다.

우선 Coordinator를 약간 수정 하겠습니다.

@Published private var navigationTrigger = false

navigationTrigger라는 Boolean 멤버 변수를 하나 추가합니다. 이 멤버 변수는 NavigationLink의 isActive로 전달될 겁니다.

@ViewBuilder
func navigationLinkSection() -> some View {
  NavigationLink(isActive: Binding<Bool>(get: getTrigger, set: setTrigger(newValue:))) {
    destination.view
  } label: {
    EmptyView()
  }
}

그리고 각 화면에 대한 대응 코드를 작성해 주겠습니다.

func showAView() {
  destination = .aView
  navigationTrigger.toggle()
}
  
func showBView() {
  destination = .bView
  navigationTrigger.toggle()
}
  
func showCView() {
  destination = .cView
  navigationTrigger.toggle()
}

그런데 화면 전환이 필요한 모든 뷰 대응 코드를 작성하려다 보니 반복 작업이 많기 때문에 리팩터링을 하겠습니다.

func push(destination: Destination) {
  self.destination = destination
  navigationTrigger.toggle()
}

여기서 SwiftUI Data Life Cycle에 대해서 잠깐 설명이 필요할 것 같습니다.

먼저 destination을 업데이트하고 Published 속성의 navigationTrigger 값을 변경해 주면 화면을 View body를 재호출하면서 업데이트된 destination으로 navigationLink를 다시 그리게 됩니다. 그리고 navigatoinLink의 isActive가 true가 되면서 화면 전환이 발생하게 됩니다.

.onReceive(SomeNotification) { value in
  coordinator.push(destination: .cView)
}

.onOpenURL { URL in
  ...
  coordinator.push(destination: .cView)
}

Button {
  coordinator.push(destination: .cView)
} label: {
  Image(systemName: "c.square.fill")
}

이제 Programmatically Navigation Push가 가능해졌습니다.

일반적으로 아래와 같은 형태로 아이템의 Row View와 DetailView를 작성하게 됩니다.

LazyVStack {
  ForEach(dataModel.someProducts, id: \.id) { product in
    NavigationLink {
      ProductDetailView(product: product)
    } label: {
      RowContent(product: product)
    }
  }
}

하지만 이 방식으로는 화면에 아직 보이지 않는 NavigationLink의 push를 발생시킬 수가 없습니다.

athena

다행히 이 문제 또한 앞서 만들어 두었던 Coordinator의 navigationLinkSection을 통해 해결이 가능합니다. 추가해야 할 것은 다음 화면에 필요한 Row 정보를 추가하기만 하면 됩니다.

열거형의 연관 값을 통해 쉽게 해결할 수 있습니다.

enum Destination {
  case aView
  case bView(Product)
  case cView
  
  @ViewBuilder
  var view: some View {
    switch self {
    case .aView:
      AView()
    case .bView(let product):
      BView(product: product)
    case .cView:
      CView()
    }
  }
}

미리 만들어둔 Destination 열거형에 연관 값을 전달하기만 하면 됩니다.

나머지 코드에서 변경할 부분은 없습니다.


문제4: PopToRoot

PopToRoot의 경우 두 가지 방법이 있습니다.

  1. SceneDelegate를 통한 window에 UIHostingController를 다시 할당하는 방법이 있고
  2. Coordiantor가 Root를 알게 하는 것입니다.

첫 번째 방법은 UIKit에 의존하는 방법으로 해당 주제에서는 다루지 않겠습니다.

여기서 다룰 것은 Coordinator가 Root를 알도록 하는 것입니다.

몇 가지 사전 작업이 필요합니다.

  • Coordinator에 isRoot 속성을 추가 합니다.
private let isRoot: Bool
  • isRoot를 생성자로 받도록 하겠습니다. 기본값으로 false를 주겠습니다.
init(isRoot: Bool = false) {
  self.isRoot = isRoot
}
  • popToRoot() 메서드를 추가합니다. 해당 메서드에는 Root Coordinator에게 Root Trigger를 동작시키라고 Notification을 보냅니다.

우선 Notification을 편하게 사용하기 위해서 extension으로 Name을 하나 추가했습니다.

extension Notification.Name {
  static let popToRoot = Notification.Name("PopToRoot")
}

그리고 외부에서 호출할 수 있게 popToRoot() 메서드를 만듭니다.

func popToRoot() {
  NotificationCenter.default.post(name: .popToRoot, object: nil)
}

코드가 호출되면 popToRoot 노티피케이션을 방출하도록 하겠습니다.

  • Coordinator에 popToRoot를 받는 Notification을 추가합니다.
if isRoot {
  NotificationCenter.default.publisher(for: .popToRoot)
    .sink { [unowned self] _ in
      rootNavigationTrigger = false
    }
    .store(in: &cancellable)
}

Notification Observing은 안전하게 Combine의 힘을 빌리겠습니다. 그리고 isRoot가 true인 경우에만 구독하면 되므로 isRoot == true로 분기를 작성하고, 저는 해당 블록을 생성자 시점에서 작성했습니다.

그리고 isRoot에 따라 필요한 분기 처리를 몇 가지 더 해줍니다.

func push(destination: Destination) {
  self.destination = destination
  if isRoot {
    rootNavigationTrigger.toggle()
  } else {
    navigationTrigger.toggle()
  }
}
  
private func getTrigger() -> Bool {
  isRoot ? rootNavigationTrigger : navigationTrigger
}
  
private func setTrigger(newValue: Bool) {
  if isRoot {
    rootNavigationTrigger = newValue
  } else {
    navigationTrigger = newValue
  }
}

이제 뷰에서 Coordinator의 popToRoot() 메서드만 호출해 주면 Root 화면으로 돌아갈 수 있게 되었습니다. 해당 방식의 장점은 iPad의 column 형태 내비게이션에서도 작동 한다는 것입니다.


Plus

이제 Coordinator를 구현해냈기 때문에, 이를 이용해서 한 가지 유용한 코드를 작성할 수 있습니다. 바로 공통 툴바 액션입니다. 장바구니나 홈화면 가기 같은 내비게이션 아이템은 자주 쓰이고, 반복적으로 작성되어야 하는 코드입니다. 이를 Coordinator에 포함할 수 있습니다.

@ToolbarContentBuilder
func commonToolbar() -> some ToolbarContent {
  ToolbarItemGroup(placement: .navigationBarTrailing) {
    Button { [unowned self] in
      push(destination: .aView)
    } label: {
      Image(systemName: "a.square.fill")
    }
    Button { [unowned self] in
      push(destination: .bView(.init(name: "Some Product")))
    } label: {
      Image(systemName: "b.square.fill")
    }
    Button { [unowned self] in
      push(destination: .cView)
    } label: {
      Image(systemName: "c.square.fill")
    }
  }
}


확장

지금은 하나의 Coordinator를 모든 뷰에서 사용하고 있습니다. 어플리케이션이 커지다 보면 하나의 Coordinator에 너무 많은 뷰가 포함될 우려가 있기 때문에, Coordinator 라는 하나의 프로토콜을 두고 GlobalCoordinator 등으로 Concrete 타입으로 변환해서 사용하면 더 유용할 거라고 생각합니다.


후기

SwiftUI와 UIKit 프레임워크는 전혀 다른 패러다임을 가지고 있는 걸 잘 알면서도 UIKit에서 구현하듯이 SwiftUI에 똑같이 구현하려다 보니 어려움과 제한 사항이 많았던 것 같습니다. Coordinator가 대표적인 사례라고 생각합니다. Swift의 Coordinator pattern 자체가 UIKit 프레임워크 위에서 만들어진 개념이기 때문에, 같은 방법을 고민하려다 보면 결국 해결 방법은 UIKit을 쓰는 방법밖에 없는 게 당연했던 것 같습니다.

iOS16 기점으로 NavigationView는 Deprecated되고 NavigationStack이 생기게 됩니다. 이름 그대로 Navigation을 Stack으로 관리할 수 있게 해주는 API로 제가 만든 SwiftUI Coordinator는 많은 게 바뀔 예정입니다. 하지만 iOS16을 사용하기 위해서는 현재 iOS14를 최소타겟으로 하는 서비스 기준으로도 2년이나 남았기 때문에 그 동안 SwiftUI의 화면전환을 고민하고 계신 분들에게 많은 도움이 되었으면 합니다.


참고자료

예제 프로젝트


이호승 | 커머스개발실 IOS팀
브랜디, 오직 예쁜 옷만