SwiftUI NavigationStack Router로 깔끔하게 관리하기

peppermint100
PEPPERMINT100
Published in
9 min readMay 25, 2024

서론

사이드 프로젝트에 클린 아키텍처를 최대한 걸림돌 없이 적용하기 위해 스터디를 촘촘하게 하고 있다. UIKit을 사용해서 개발을 할 때에는 Coordinator 패턴을 통해 NavigationStack을 관리했다.

Coordinator 패턴의 사용 이유는 ViewController가 가진 화면 전환의 책임을 분리하기 위해서, 또 ViewController에서 다른 ViewController를 초기화하면 재사용성이 떨어지기 때문이었다.

SwiftUI에서는 Router를 사용해서 이 부분을 해결할 수 있는데, 기존에 사용해오던 NavigationView는 iOS 16부터 deprecate 되었기 때문에 최신 스펙인 NavigationStack을 깔끔하게 뷰로부터 분리하여 화면 전환을 관리하는 나름의 방법을 소개해보도록 하겠다.

화면 설명

먼저 화면은 아래와 같다.

미디움은 이미지 크기 조절이 안되나..

1번 화면이 가장 첫 번째 화면이고 Coffee를 누르면 각 화면에 맞는 Coffee 디테일로. Dessert를 누르면 맞는 Dessert로 이동하게 된다.

우측 상단 Random Coffee는 누르면 커피중 아무 커피화면으로 들어가게 된다.

Model

먼저 기본이 되는 모델들을 만들어준다

enum Route: Hashable {

case cafeMenu(item: any CafeMenu)

func hash(into hasher: inout Hasher) {
hasher.combine(self.hashValue)
}

static func == (lhs: Route, rhs: Route) -> Bool {
switch (lhs, rhs) {
case let (.cafeMenu(lhsItem), .cafeMenu(rhsItem)):
return lhsItem.id == rhsItem.id
}
}
}

protocol CafeMenu {
var id: String { get }
var name: String { get }
}

struct Coffee: Hashable, Identifiable, CafeMenu {
let id: String
let name: String
}

extension Coffee {
static let all: [Coffee] = [
Coffee(id: "latte", name: "latte"),
Coffee(id: "espresso", name: "espresso"),
Coffee(id: "cappuccino", name: "cappuccino"),
Coffee(id: "americano", name: "americano"),
]
}

struct Dessert: Hashable, Identifiable, CafeMenu {
let id: String
let name: String
}

extension Dessert {
static let all: [Dessert] =
[
Dessert(id: "cake", name: "cake"),
Dessert(id: "chocolate", name: "chocolate"),
Dessert(id: "cupcake", name: "cupcake"),
Dessert(id: "candy", name: "candy"),
]
}

Route는 화면 전환에 전반적으로 사용하게 되고,

Coffee와 Dessert는 CafeMenu라는 프로토콜을 따르는 모델이 된다.

all 타입 프로퍼티는 개발을 위해 더미로 만든 프로퍼티이다.

Router

class NavigationRouter: ObservableObject {

@Published var path = NavigationPath()

func push(to route: Route) {
path.append(route)
}

func pop() {
path.removeLast()
}

func reset() {
path.removeLast(path.count)
}
}

이제 Router를 만들어준다. 전반적으로 사용할 path를 만들고 path에 Route를 넣고(push) 빼고 (pop) 최초 화면으로 돌아갈 수 있는 reset까지 만들어준다.

위 Router를 모든 뷰에서 사용할 수 있게 Environment로 만들어서 주입해준다.

홈 화면

이제 홈화면을 보자. 가장 먼저 Environment로 들어간 router를 가져와준다. 그리고 NavigationStack에 path에 바인딩해준다.

struct ContentView: View {

@EnvironmentObject private var router: NavigationRouter

var body: some View {
NavigationStack(path: $router.path) {
List {
Section(header: Text("Coffee Menu")) {
ForEach(Coffee.all) { coffee in
NavigationLink(value: Route.cafeMenu(item: coffee), label: {
Text(coffee.name)
})
}
}

Section(header: Text("Dessert Menu")) {
ForEach(Dessert.all) { dessert in
NavigationLink(value: Route.cafeMenu(item: dessert), label: {
Text(dessert.name)
})
}
}
}
.navigationTitle("Cafe Menu")
.navigationDestination(for: Route.self) { route in
switch route {
case .cafeMenu(let item):
switch item {
case is Coffee:
CoffeeDetailView(coffee: item as! Coffee)
case is Dessert:
DessertDetailView(dessert: item as! Dessert)
default:
EmptyView()
}
}
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button(action: {
router.push(to: Route.cafeMenu(item: Coffee.all.randomElement()!))
}, label: {
Text("Random Coffee")
.foregroundStyle(.blue)
})
.foregroundStyle(.black)
.font(.headline)
}
}
}
}
}

그리고 NavigationLink쪽을 보면

Section(header: Text("Coffee Menu")) {
ForEach(Coffee.all) { coffee in
NavigationLink(value: Route.cafeMenu(item: coffee), label: {
Text(coffee.name)
})
}
}

Section(header: Text("Dessert Menu")) {
ForEach(Dessert.all) { dessert in
NavigationLink(value: Route.cafeMenu(item: dessert), label: {
Text(dessert.name)
})
}
}
}

이렇게 value에 Route 형태의 값을 넣어준다. 이렇게 하면 .navigationDestination 에서 Route 타입으로 깔끔하게 분기하여 네비게이셔닝을 할 수 있다.

.navigationDestination(for: Route.self) { route in
switch route {
case .cafeMenu(let item):
switch item {
case is Coffee:
CoffeeDetailView(coffee: item as! Coffee)
case is Dessert:
DessertDetailView(dessert: item as! Dessert)
default:
EmptyView()
}
}
}

Route를 switch 문으로 분기하여 각각 디테일 뷰로 이동시켜줄 수 있다.

디테일 뷰와 Programatic Navigation

struct CoffeeDetailView: View {

@EnvironmentObject private var router: NavigationRouter
let coffee: Coffee

var body: some View {
Text("\(coffee.name)")
.font(.headline)
.navigationTitle("Coffee")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button(action: {
router.pop()
}, label: {
Image(systemName: "chevron.left")
})
.foregroundStyle(.black)
.font(.headline)
}
}
}
}

디테일 뷰는 이런식으로 생겼다. 툴바 버튼에 router.pop 을 통해 NavigationStack을 통하지 않고 화면을 전환할 수 있다.

Coordinator 패턴을 이해하고 적용하는데 꽤 시간이 걸렸는데 SwiftUI의 Router는 한층 이해하기가 편해보인다. 전체 코드는 아래 깃허브에서 확인할 수 있다.

--

--

peppermint100
PEPPERMINT100

기억하기 위해 또는 잊어버리기 위해 작성하는 블로그입니다.