SwiftUI NavigationStack Router로 깔끔하게 관리하기
서론
사이드 프로젝트에 클린 아키텍처를 최대한 걸림돌 없이 적용하기 위해 스터디를 촘촘하게 하고 있다. 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는 한층 이해하기가 편해보인다. 전체 코드는 아래 깃허브에서 확인할 수 있다.