SwiftUI View transition strategy

백상휘·2023년 8월 13일
1

iOS_Programming

목록 보기
9/11

(주의) 이 글은 최소 배포 버전 iOS 14 를 기준으로 구성하였다. 만약 iOS 16 이상으로 잡았다면 NavigationStack 이라는 좋은 녀석이 있다.

요즘 한동안 팔자에는 없겠다 싶었던 SwiftUI 로 프로젝트를 진행하게 되었다. (사람 일이라는게 정말 한치 앞도 모른다더니 정말이었다)

SwiftUI 는 선언형 문법을 이용해 뷰를 만든다는 장점 아닌 단점이 있다. 그리고 뷰 클래스들이 모두 뷰 구조체로 바뀌었으며, 뷰 구조체 내부의 상태값 프로퍼티 (Swift Combine 을 응용한 어노테이션 사용) 들을 계속 Observe 하며 자신의 상태/레이아웃/라이프 사이클을 바꾼다.

여기서 앞으로 계속 언급할 얘기가 있다.

SwiftUI 는 모든 뷰 상태값을 뷰 내부에서 정의해야 한다

여기까지 언급하면 iOS 개발을 많이 해본 분들에겐 아래의 궁금증이 떠오를 것이다.

어떻게 뷰 네비게이션 중간에 루트 뷰로 돌아가지?

아래의 그림을 보자. 예시이므로 약간 현실의 프로젝트와는 다르게 flow 를 꼬아놓은 면도 있다.

  • Start = 시작점이다. 네비게이션 뷰 안에 맨 처음 보여줄 뷰가 정의되어 있다.
  • A = 루트 뷰이다. 비즈니스 로직 혹은 특정 도메인이 시작되는 화면이다.
  • B, C, H, A = 우리가 구현하는 앱에서 추가 작업을 진행하지 않아도 될 경우 흘러갈 뷰 플로우이다.
  • F, G, A = 기본 플로우에 예외가 발생할 경우 거쳐야 하는 1번 플로우이다.
  • D, E, A = 기본 플로우에 예외가 발생할 경우 거쳐야 하는 2번 플로우이다.

잠시 구현하는 방법을 생각해보는 것도 좋을 것 같다. 여기서 다시 한번 상기할 필요가 있는데, SwiftUI 는 모든 뷰 상태값을 뷰 내부에서 정의해야 한다. 어떤 뷰의 화면 전환은 반드시 그 뷰 내부의 로직에 의해 실행되어야 한다.

만약 내가 G 에 있고 A 로 가야한다고 해보자.

  • A, F, G 까지는 init(destination:Destination, isActive:Binding<Bool>, @ViewBuilder label:() -> Label) 를 이용해서 이동하였다.
  • G 에서 A 를 이동해야 하므로 G 에서 dismiss 한다. dismiss 하면서 F 에서 전달받은 @Binding 값을 변경해준다. 이 @Binding 값은 F 의 NavigationLink 와 연결되어 있다.
  • F 뷰를 읽어들이다 @Binding 에 연결된 값이 변경된 것을 확인하였다. 사실 이 값은 A 뷰에서 전달받은 것이다. A 뷰의 NavigationLink 에 연결된 @State 인 것이다.
  • A 뷰에 잘 도착한다.

완전 나쁘지는 않다. 실제로 이렇게 사용한다고 주장하는 여러 게시글을 보았고 이번에 만든 예제 프로젝트에서도 이상 없이 동작하고 있었다.

그런데 개인적으로는 A 의 상태값을 어떻게 네이밍 해야 하는지도 모르겠고, 유지보수 비용이 무지하게 커질 것 같다는 생각이 든다. 특정 뷰에서 그것도 뷰 전환에만 관여하는 상태값을 선언해놓고 인수인계 하는 상황을 상상해보면 나는 좀 아찔하다.


만약 iOS 16 부터 사용 가능한 NavigationStack 을 사용한다면 이런 문제는 고민할 필요 없다. 현재까지 계속 NavigationView 에 쌓인 뷰들을 NavigationPath 라는 구조체로 추적해볼 수 있다.

하지만 이는 쉽지 않은 부분이다. SwiftUI 는 사실 아직까지도 현업에서 사용하기에는 논의가 필요한 기술이라는게 나의 생각이다.

  1. 낮은 iOS 버전을 사용하는 유저들은 아직까지도 의미있는 숫자로 집계된다.
  2. SwiftUI 앱은 iOS 13 버전 이하 버전으로는 빌드할 수 없다.
  3. iOS 13 버전의 SwiftUI 는 그 흔한 List 뷰도 없어 굉장히 사용하기 불편하다.

그래서 방법을 고심하던 중 Coordinator Pattern 을 찾게 되었다.

문제 정의

우리는 2 개의 문제를 풀어볼 것이다. 그리고 현재 필자가 사용하는 답안도 같이 표시한다.

  1. 뷰 네비게이션을 중간에 예외 로직을 실행하고 예외 로직을 시작한 뷰로 돌아가야 한다.
    • Coordinator Pattern 이용
  2. 모든 뷰 네비게이션을 취소하고 초기화하는 방법이 필요하다.
    • id 뷰 수정자와 EnvironmentObject 이용

(1번 문제 해결) Coordinator Pattern

Coordinator Pattern(코디네이터 패턴) 은 화면전환 관련 로직을 별도의 모델 객체로 분리 및 관리하는 디자인 패턴이다.

처음 이 패턴에 대한 얘기를 찾았을 때는 머릿속에서 이런 생각이 들었다.

그럼 이걸 하나 만들고 모든 뷰들이 이 객체 하나만 보게 하면 되겠다.

하지만 다시 설명한다. SwiftUI 는 모든 뷰 상태값을 뷰 내부에서 정의해야 한다. 만약 뷰 내부에서 관찰하는 값이 아닌 주입된 객체라면 원치 않는 결과가 나오기가 굉장히 쉽다.

여기서 Coordinator 객체들을 뷰마다 배치하고 NavigationLink 의 isActive 바인딩 값을 이용한다. 전략은 생각보다 간단하다.

  • 초기에 NavigationLink 의 바인딩 Bool 은 false 로 세팅해 놓는다.
  • 뷰를 이동하고 싶다면 바인딩 값만 true 로 바꿔서 네비게이션을 실행시킨다
    • false 였던 바인딩 값이 true 로 변하면 뷰가 다시 그려진다.
    • NavigationLink 가 activate 된 상태로 그려지기 때문에 NavigationLink 의 destination 으로 뷰 이동이 진행된다.
  • 특정 뷰로 돌아가고 싶다면 NotificationCenter 를 이용해 강제로 바인딩 값을 다시 true 로 변경한다.

이제 직접 구현으로 들어가보도록 하자. 자세한 구현은 아래 깃허브 주소를 남겨놓도록 하겠다. Coordinator 패턴을 쓴 경우, 일반적(필자의 기준에서 일반적)인 경우로 나누었다.

구현

우선 Coordinator 객체부터 정의해야 한다. Coordinator 의 명세는 다음과 같다.

  • 어떤 뷰로 이동할지 저장해 놓아야 한다.
  • NavigationLink 를 따로 정의해야 한다.
    • 그렇지 않으면 Coordinator 가 아닌 NavigationLink 를 사용하게 되어 Coordinator 패턴으로 정의되는 뷰 전환의 의미가 흐려진다.
  • 현재 자신이 포함된 뷰가 root 뷰인지 아닌지 알아야 한다.

결과적으로 아래와 같은 Coordinator 를 만들 수 있다.

import SwiftUI
import Combine

final class Coordinator: ObservableObject {
  @Published private var trigger = false
  @Published private var rootTrigger = false
  
  private let isRoot: Bool
  private var destination: CoordinatorDestination
  private var subscriptions = Set<AnyCancellable>()
  
  init(isRoot: Bool = false, destination: CoordinatorDestination) {
    self.isRoot = isRoot // 1
    self.destination = destination
    
    if isRoot { // 1
      NotificationCenter.default
        .publisher(for: .popToRoot)
        .sink { _ in
          self.rootTrigger = false
        }
        .store(in: &subscriptions)
    }
  }
  
  @ViewBuilder
  func navigationContext() -> some View { // 2
    NavigationLink(isActive: Binding(
      get: getTrigger,
      set: setTrigger(newValue:))
    ) {
      destination.view
    } label: {
      EmptyView()
    }
  }
  
  private func getTrigger() -> Bool {
    isRoot ? rootTrigger : trigger
  }
  
  private func setTrigger(newValue: Bool) {
    if isRoot {
      rootTrigger = newValue
    } else {
      trigger = newValue
    }
  }
  
  func push(destination: CoordinatorDestination) { // 3
    self.destination = destination
    setTrigger(newValue: true)
  }
  
  func popToRoot() {
    NotificationCenter.default
      .post(name: .popToRoot, object: destination)
  }
}

enum CoordinatorDestination { // 4
  case aView, bView, cView, dView, eView, fView, gView, hView
  
  @ViewBuilder
  var view: some View {
    switch self {
    case .aView:
      A()
    case .bView:
      B()
    case .cView:
      C()
    case .dView:
      D()
    case .eView:
      E()
    case .fView:
      F()
    case .gView:
      G()
    case .hView:
      H()
    }
  }
}

extension Notification.Name {
  static var popToRoot = Notification.Name("popToRoot")
}
  1. isRoot 는 현재 자신이 속한 뷰가 root 인지 알아낸다.
  • 만약 isRoot 가 true 일 경우 특정 Notification 에 반응하는 Publisher 를 등록해 놓게 된다.
  1. @ViewBuilder 를 이용해 DSL 로 뷰를 정의한다. 하나의 NavigationLink 이지만 destination 이 여러개의 뷰이므로 @ViewBuilder 를 사용한다.
  2. push 는 새로운 destination 을 정의하고 binding 값을 true 로 변경한다.
  3. Coordinator 가 다룰 view 들을 enum 으로 미리 정의해 놓는다. 여기서도 @ViewBuilder 를 이용해 여러 개의 뷰를 정의해 놓는다.

참고로 destination 을 꼭 정의해야 하는 이유는 push 할 때 써야하기 때문이다. 초기값은 그렇게 중요하지 않다. 가독성을 위해 Coordinator 를 소유한 뷰의 이름을 그대로 따르곤 한다.


A 뷰가 root 이므로 A 뷰 부터는 Coordinator 를 추가한다.

struct A: View {
  @StateObject var coordinator = Coordinator(isRoot: true, destination: .aView)
  
  var body: some View {
    VStack(spacing: 20) {
      coordinator.navigationContext()
      /**
      NavigationLink(isActive: Binding(
        get: rootTrigger,
        set: setTrigger(newValue:))
      ) {
        destination.view
      } label: {
        EmptyView()
      }
      */
      
      Button {
        coordinator.push(destination: .fView)
      } label: {
        Text("Go to F")
      }
      
      Button {
        coordinator.push(destination: .bView)
      } label: {
        Text("Go to B")
      }
      
      Button {
        coordinator.push(destination: .dView)
      } label: {
        Text("Go to D")
      }
      .padding(.bottom, 20)
      
      Image("ViewStructure")
        .resizable()
        .aspectRatio(contentMode: .fit)
    }
  }
}

A 에서는 어떤 일이 일어날까? coordinator.navigatonContext() 밑에 작은 주석을 넣어보았다.

개인적으론 내장되어 있다는 표현을 쓰고 싶다. @StateObject 로 선언된 ObservableObject 타입의 Coordinator 에서 @Published 인 rootTrigger 를 바인딩으로 관찰하는 NavigationLink 이다.

만약 A 의 rootTrigger 가 true 로 바뀌는, 즉 push 가 일어난 후 다른 뷰에서 A 로 돌아가고 싶다면 rootTrigger 만 false 로 바꿔주면 된다.


아래는 보통 뷰들의 구조를 표현해 보았다. F 뷰를 예로 들어보자.

struct F: View {
  @StateObject var coordinator = Coordinator(destination: .fView)
  var body: some View {
    VStack(spacing: 20) {
      Button {
        coordinator.push(destination: .gView)
      } label: {
        Text("Go to G")
      }
      
      coordinator.navigationContext()
    }
  }
}

F 에도 Coordinator 를 새로 정의하였다. DI 등을 이용하는 것이 아니다. SwiftUI 는 모든 뷰 상태값을 뷰 내부에서 정의해야 한다. 해당 뷰에서 사용할 뷰 전환에 사용될 객체는 내부에서 선언되어야 하는 것이다.


이제 popToRoot 를 해보자. A 뷰로 돌아가는 것이다. 즉 A -> F -> G -> A 이다. 사실은 A -> F -> G 가 다시 A 로 바뀌는 것이지만 말이다.

struct G: View {
  @StateObject var coordinator = Coordinator(destination: .gView)
  var body: some View {
    VStack(spacing: 20) {
      Button {
        coordinator.popToRoot()
        /**
        func popToRoot() {
          NotificationCenter.default
            .post(name: .popToRoot, object: destination)
        }
        */
      } label: {
        Text("Go to A(Root)")
      }
      
      coordinator.navigationContext()
    }
  }
}

popToRoot 를 실행하는 Button 을 생성하며 아래에 popToRoot 소스코드를 주석으로 넣어보았다. 단순히 Notificaton 을 post 할 뿐이다.


Coordinator 에서 해당 Notification 을 어떻게 핸들링 했는지 기억해 볼 시간이 왔다.

NotificationCenter.default
  .publisher(for: .popToRoot)
  .sink { _ in
    self.rootTrigger = false
  }
  .store(in: &subscriptions)

어떤 뷰든지 간에 rootTrigger 에 영향을 받는 뷰라면 그 뷰는 다시 그려질 것이다. rootTrigger 는 Published 로 선언되어 있기 때문이다.

그런 뷰가 기억 나는가? 바로 A 다. 이로 인해 A 에 내장된 NavigationLink 의 뷰 전환은 취소된다.

(2번 문제 해결) NavigationView 시작 뷰 id 로 재정의

SwiftUI 의 View 타입에는 다음의 뷰 수정자가 존재한다. 해당 수정자의 이름과 설명을 공식문서에서 가져와 보았다.

.id<ID>(_ id: ID) -> some View where ID: Hashable

  • Binds a view’s identity to the given proxy value.

즉 해당 값이 바뀌게 되면 SwiftUI 는 다른 뷰로 인식하는 것이다. 우리는 이 특성을 응용할 것이다.

class SessionManager: ObservableObject {
  @Published private(set) var coordinatorID = UUID() // 1
  @Published private(set) var normalID = UUID() // 2
  
  func popToCoordinatorRootView() {
    self.coordinatorID = .init()
  }
  
  func popToNormalRootView() {
    self.normalID = .init()
  }
}
  1. coordinatorID 는 coordinator 패턴을 사용하는 뷰들의 시작 뷰 ID 로 사용된다.
  2. normalID 는 coordinator 패턴을 사용하지 않는 뷰들의 시작 뷰 ID 로 사용된다.
  3. 아래는 ID 프로퍼티들을 초기화하는 인터페이스이다.

여기서부터는 해당 기능을 개발하는 개발자의 결정에 따른다. 어느 뷰에 .id() 뷰 수정자를 사용할 것인가? 필자는 이번 예제 프로젝트를 생성하며 시작점 뷰를 따로 정의하고 해당 뷰에 id 를 부여하였다.

struct StartView: View {
  @EnvironmentObject var sessionManager: SessionManager
  var body: some View {
    VStack(spacing: 50) {
      Text("Start View")
        .font(.largeTitle)
      Text("Coordinator")
        .font(.subheadline)
      
      NavigationLink {
        A()
      } label: {
        Text("Start")
          .font(.title)
          .foregroundColor(.red)
      }
    }
    .id(sessionManager.coordinatorID)
  }
}

그리고 특정 뷰에서 다시 시작점 뷰로 이동하고 싶다면 아래와 같이 하면 된다.

struct H: View {
  @EnvironmentObject var sessionManager: SessionManager
  var body: some View {
    VStack {
      Button {
        sessionManager.popToCoordinatorRootView()
      } label: {
        Text("Go to Start")
      }
    }
    .commonNavigationBar("I'm H")
  }
}

지금까지 2 개의 문제를 해결하며 우리는 아래의 flow 를 모두 구현할 수 있게 된 것이다. 물론 이는 Coordinator 패턴 없이도, id() 뷰 수정자 없이도 구현 가능하다. 하지만 복잡도, 유지보수성 측면에서 유리한 면이 있다고 생각 된다.

단점 (주의할 점)

  • 나는 이 패턴이 결국 눈속임이라는 생각이 든다.

내가 생각한 가장 좋은 방법은 현재의 뷰가 계속 바꿔치기 되는 것이었다. 하지만, 현재의 구현방법은 사실 뷰를 계속 네비게이션 뷰에 쌓다가 한번에 버리는 형식이 된다.

그럴 일은 없겠지만, 이런 식의 뷰가 계속 쌓이다 보면 메모리는 갈수록 늘어나게 된다. 이 중간에 이미지를 관리하는 뷰가 들어가게 되면 메모리 문제는 갈수록 심각해진다.

이래뵈도 필자는 메모리 문제에 대해 귀여운 정도로 심각하다.

  • DoubleColumnNavigationViewStyle 을 사용할 수 없다.

Coordinator 패턴을 사용하려면 반드시 NavigationView 들의 style 이 StackNavigationViewStyle 로 정의되어 있어야 한다.

TabView(selection: $selection) {
  NavigationView {
    StartView()
  }
  .navigationViewStyle(.stack)
  .tabItem { Text(Tabs.coordinator.rawValue.uppercased()) }
  
  NavigationView {
    StartNormalView()
  }
  .navigationViewStyle(.stack)
  .tabItem { Text(Tabs.normal.rawValue.uppercased()) }
}

원인은 현재 확인중이나 개인적으로는 NavigationLink 타입의 생성자 중 isActive 생성자가 deprecated 된 이유와 깊이 관련이 있어 보인다.

즉, isActive 바인딩 값이 영향을 주는 프로퍼티들은 NavigationView 의 StackNavigationViewStyle 에 의존적이라고 생각된다.

  • pop 은 뷰 내부에서 직접 정의해야 한다.

SwiftUI 는 모든 뷰 상태값을 뷰 내부에서 정의해야 한다. (약간 치트키처럼 쓰고 있는 것 같아 가슴이 아프다)

위의 Coordinator 객체에 있는 바인딩 값을 다른 뷰에서 변경하려면 결국 Notification 을 뷰마다 정의하거나, 바인딩 값을 전달해야 한다. Coordinator 로 얻을 수 있는 장점이 줄어들게 되고 유지보수 비용은 다시 늘어난다.

필자는 그냥 아래처럼 뷰 내부에서 pop 을 정의한다.

import SwiftUI

struct PopView: View {
	@Environment(\.presentationMode) var presentationMode
	var body: some View {
    	VStack {
        	Button {
            	presentationMode.wrappedValue.dismiss()
            } label: {
            	Text("분하군...")
            }
        }
    }
}           

example project

https://github.com/SangHwi-Back/CoordinatorTest

Reference

profile
plug-compatible programming unit

1개의 댓글

comment-user-thumbnail
2023년 8월 13일

큰 도움이 되었습니다, 감사합니다.

답글 달기