안녕하세요~
iOS16+부터 사용할 수 있는 NavigationStack을 이용한 라우터 로직을 만들었는데요
그 과정에서 생각한 방법과 겪은 고민을 정리해보려합니다!
네비게이션 스택이뭐야? 하시는분들은
쓰윽 훑고오셔도 좋을것같습니다!
우선 제일 간단한 방법부터 시작해보겠습니다~!
아! 그전에
이 모든 라우팅 로직들은 모듈화를 했다는 가정하에 이뤄졌습니다.
예제에서는 아주 간단한 구조로 사용했어요
하나의 Screen Enum타입을 이용한 라우팅
제목 그대로입니다!
하나의 타입만을 사용하는 방식입니다
코드와 함께보시죠
// RouterCore
public enum ScreenA: Hashable {
case featureB1
case featureB2
case featureB3
case featureC1
case featureC2
case featureC3
}
public final class RouterA: ObservableObject {
@Published public var route: [ScreenA] = []
public init() { }
@MainActor
public func push(screen: ScreenA) {
route.append(screen)
}
@MainActor
public func pop() {
route.removeLast()
}
@MainActor
public func pop(depth: Int) {
route.removeLast(depth)
}
@MainActor
public func popToRoot() {
route.removeAll()
}
@MainActor
public func switchScreen(screen: ScreenA) {
guard !route.isEmpty else { return }
let lastIndex = route.count - 1
route[lastIndex] = screen
}
}
라우터 코드입니다
모든 화면의 타입을 정의하고
기본적인 기능만 가능하도록 정의했어요
// Feature A
import SwiftUI
import RouterCore
import FeatureB
import FeatureC
struct MainViewA: View {
@StateObject var router = RouterA()
var body: some View {
NavigationStack(path: $router.route) {
VStack {
Text("A Feature View")
Button {
router.push(screen: .featureB1)
} label: {
Text("B1로 이동")
}
Button {
router.push(screen: .featureC1)
} label: {
Text("C1로 이동")
}
}
.navigationDestination(for: ScreenA.self) { screenA in
screenAView(type: screenA)
.environmentObject(router)
}
}
}
@ViewBuilder
private func screenAView(type: ScreenA) -> some View {
switch type {
case .featureB1:
MainB1View()
case .featureB2:
MainB2View()
case .featureB3:
MainB3View()
case .featureC1:
MainC1View()
case .featureC2:
MainC2View()
case .featureC3:
MainC3View()
}
}
}
앱의 시작점인 화면입니다
NavigationStack의 정의와
라우터 인스턴스가 위치하고
라우터로인한 모든 화면을 정의하여 실제로 이동될 뷰정의가 이뤄지는 곳입니다.
// Feature B
import SwiftUI
import RouterCore
public struct MainB1View: View {
@EnvironmentObject var router: RouterA
public init() {}
public var body: some View {
VStack {
Text("MainB 1 View")
Button {
router.push(screen: .featureB2)
} label: {
Text("B2로 이동")
}
Button {
router.push(screen: .featureC2)
} label: {
Text("C2로 이동")
}
}
}
}
// Feature C
public struct MainC1View: View {
@EnvironmentObject var router: RouterA
public init() {}
public var body: some View {
VStack {
Text("MainC 1 View")
Button {
router.push(screen: .featureB2)
} label: {
Text("B2로 이동")
}
Button {
router.push(screen: .featureC2)
} label: {
Text("C2로 이동")
}
}
}
}
화면은 단순해서 하나만 들고왔어요
router.push로 간단하게 타입만 넘겨서 라우팅이 가능합니다
😇 장점
단순한 로직과 접근성이 좋음
타입의존적이기때문에 라우터 코드가 간결함
😈 단점
enum extension이 불가능
- 각 피쳐별 화면 개발시 enum타입에 공통적으로 추가돼야함
- 타입이 방대해질 가능성이 있고, 동시에 수정이 일어날 가능성이 큼 -> 충돌!
Screen타입 정의위치
RouterCore모듈에 정의
모든 모듈화면의 타입을 정의
정의되지않은 화면 라우팅 불가능(타입의존적)
하위모듈간 화면 전환가능여부
✅ 가능
RouterCore모듈에 ScreenType이 정의됐고
B,C모듈이 RouterCore를 의존하므로 모든 타입을 알 수 있게되서
서로 연결되지않은 B, C모듈에서
B -> C 이동, B -> C 화면이동이 자유롭습니다.
navigationDestination 뷰 정의위치
Router의 루트에 위치한 뷰(FeatureA)에서 정의합니다.
(RouterCore에서 정의시 상위모듈의 타입을 알 수 없음 -> 뷰 정의불가능)
생각정리
모듈이 없거나 작은 프로젝트에서 사용하면 이점이 있을것같다..!
최하위 모듈에 모든 화면정보를 넣고 사용한다..!
각 피쳐모듈별 Screen Enum타입을 이용한 라우팅(Hashable)
위에서의 문제는
enum타입의 정의가 한곳에서만 이뤄지기때문에 엄청 방대해질 것이라는 우려였죠
그 문제를 해결하고자
모듈별 Screen타입을 정의해보려고했어요
수정되야할부분이 2가지가 있습니다.
1. 각 모듈별 타입이존재, Router가 하나의 타입에 의존적 -> 라우터공통 타입 사용
2. 각 모듈이 서로의 화면이동시 서로 참조로 Dependency Cycle -> 각 모듈의 interface모듈에 타입을 정의
1번에서 문제가 생깁니다.
각 라우터가 타입의존적이기 때문에 어떻게 해야할까 많은 시도를했습니다.
먼저 Hashable하면 NavigationStack을 이용할 수 있기 때문에
각 Screen타입이 Hashable하도록하고
라우터 타입을 any Hashable을 써보자로 시작했습니다.
정의에서
Data타입아니면 NavigationPath (Hashable)을 이용해야했기때문에 후자를 먼저 선택했습니다.
처음엔 any Hashable 배열을 이용해볼까 시도해봤지만 어림도 없었습니다 ㅎ
any Hashable한 타입을 바인딩할 수 없었습니다...
하지만 NavigationPath를 사용함으로서 더 단순화 됐습니다.
아래는 적용된 코드입니다
public final class RouterB: ObservableObject {
@Published public var route = NavigationPath() // ✅변경
public init() { }
@MainActor
public func push<T: Hashable>(screen: T) { // ✅변경
route.append(screen)
}
@MainActor
public func pop() {
route.removeLast()
}
@MainActor
public func pop(depth: Int) {
route.removeLast(depth)
}
@MainActor
public func popToRoot() { // ✅변경
route = NavigationPath()
}
@MainActor
public func switchScreen<T: Hashable>(screen: T) { // ✅변경
guard !route.isEmpty else { return }
var tempRoute = route
tempRoute.removeLast()
tempRoute.append(screen)
route = tempRoute
}
}
특정타입이 아니라 모든 타입에 대해서 사용해야하고 NavigationPath를 사용하기위해
Hashable 타입에 의존하도록 변경했습니다.
// Feature A
struct MainViewA: View {
@StateObject var router = RouterB() // ✅ 변경
var body: some View {
NavigationStack(path: $router.route) {
VStack {
Text("A Feature View")
Button {
router.push(screen: ScreenB.featureB1) // ✅ 변경
} label: {
Text("B1로 이동")
}
Button {
router.push(screen: ScreenC.featureC1) // ✅ 변경
} label: {
Text("C1로 이동")
}
}
.navigationDestination(for: ScreenB.self) { type in // ✅ 변경
FeatureBView(type: type)
.environmentObject(router)
}
.navigationDestination(for: ScreenC.self) { type in // ✅ 변경
FeatureCView(type: type)
.environmentObject(router)
}
}
}
}
// 이 뷰의 모듈은 A혹은 B여도 무관합니다
struct FeatureBView: View {
let type: ScreenB
var body: some View {
if type == .featureB1 {
MainB1View()
} else if type == .featureB2 {
MainB2View()
} else if type == .featureB3 {
MainB3View()
}
}
}
// Feature B
public enum ScreenB: Hashable {
case featureB1
case featureB2
case featureB3
}
라우터를 새로만든 것으로 갈아끼웠고
그에 따라 push값도 명시타입으로 변경해주고
Screen타입을 각 모듈에서 정의해주고
navigationDestination에는 이제 각각의 모듈에서 정의한 Screen타입이 전달되므로 각각 정의해줬습니다.
현재 모듈구조에서는 타입을 서로알수없어서 불가능하지만
FeatureBInterface모듈과 FeatureCInterface모듈을 만들어서 각 모듈의 Screen타입을 이위치에 정의한다면
B -> C, C -> B 이런 화면 이동을 가능하게 할 수 있습니다.
😇 장점
Hashable한 타입에대해서 모두 라우팅 가능
😈 단점
단순한 라우팅 로직가능 복잡한 화면 컨트롤 불가능
탭뷰 사용시 라우터당 화면정의가 필요하므로 어쩔수없이 각 탭마다 중복된 화면 정의가 필요
Screen타입 정의위치
각 모듈에 존재(만약 공통으로 사용시 interface모듈에 정의)
Hashable 프로토콜 준수
하위모듈간 화면 전환가능여부
✅ 조건부 가능
조건: Dependency Cycle을 회피할 interface 모듈이 존재해야함
navigationDestination 뷰 정의위치
Router의 루트에 위치한 뷰(FeatureA)에서 정의합니다.
push된 Screen타입이 전달되서 들어옴 이값을 이용하여 화면구분
생각정리
복잡하지않고 처음 방법의 단점을 해결하고 코드분리가 가능함
그 현재 스택에 쌓인 타입을 이용한다던가 더 특별한 작업을 하는건 사실 드물다고 생각..
push, pop, popToRoot, switchScreen이 모두가능하다.
이게 답인거 같다..!
각 피쳐모듈별 Screen Enum타입을 이용한 라우팅 (Data, Codable)
Hashable로도 했으면 Data로도 해봐야죠
이번에는 Data와 Codable한 타입을 이용합니다
// RouterCore
public final class RouterC: ObservableObject {
@Published public var route: [Data] = [] // ✅ 변경
let encoder = JSONEncoder() // ✅ 변경
public init() { }
@MainActor
public func push<T: Codable>(screen: T) { // ✅ 변경
let data = try! encoder.encode(screen)
route.append(data)
}
@MainActor
public func pop() {
route.removeLast()
}
@MainActor
public func pop(depth: Int) {
route.removeLast(depth)
}
@MainActor
public func popToRoot() { // ✅ 변경
route.removeAll()
}
@MainActor
public func switchScreen<T: Codable>(screen: T) { // ✅ 변경
guard !route.isEmpty else { return }
let data = try! encoder.encode(screen)
let lastIndex = route.count - 1
route[lastIndex] = data
}
}
라우터타입을 Data타입으로 변경하고
화면전환시에는 Codable을 준수하는 타입만 사용할 수 있고
JSONEncoder를 이용해서 Screen타입을 Data타입으로 인코딩하여 사용합니다.
// Feature A
struct MainViewA: View {
@StateObject var router = RouterC() // ✅ 변경
var body: some View {
NavigationStack(path: $router.route) {
VStack {
Text("A Feature View")
Button {
router.push(screen: ScreenB.featureB1)
} label: {
Text("B1로 이동")
}
Button {
router.push(screen: ScreenC.featureC1)
} label: {
Text("C1로 이동")
}
}
.navigationDestination(for: Data.self) { data in // ✅ 변경
decodeScreenRouterC(data: data)
.environmentObject(router)
}
}
}
@ViewBuilder
func decodeScreenRouterC(data: Data) -> some View { // ✅ 변경
if let screenB = try? JSONDecoder().decode(ScreenB.self, from: data) {
FeatureBView(type: screenB)
} else if let screenC = try? JSONDecoder().decode(ScreenC.self, from: data) {
FeatureCView(type: screenC)
}
}
}
struct FeatureBView: View {
let type: ScreenB
var body: some View {
if type == .featureB1 {
MainB1View()
} else if type == .featureB2 {
MainB2View()
} else if type == .featureB3 {
MainB3View()
}
}
}
navigationDestination에 Data타입이 전달되고
이타입을 디코딩시켜서 타입에 매칭되는지 검사후
원하는 뷰로 이동시킬 수 있습니다.
// Feature B
public enum ScreenB: Codable { // ✅ 변경
case featureB1
case featureB2
case featureB3
}
public struct MainB1View: View {
@EnvironmentObject var router: RouterC // ✅ 변경
public init() {}
public var body: some View {
VStack {
Text("MainB 1 View")
Button {
router.push(screen: ScreenB.featureB2)
} label: {
Text("B2로 이동")
}
Button {
router.switchScreen(screen: ScreenB.featureB3)
} label: {
Text("B3로 화면전환")
}
}
}
}
화면쪽 모듈에는 변경되는점이 많이없네요!
사용성 측면에서는 Hashable과 비슷하고
Encoding, Decoding을 직접 신경써서 해주는 점이 다르다고 할 수 있죠
이때 주의해야할점으로
enum A { case mainView }
enum B { case mainView }
각모듈에 이렇게 정의 돼있고
A.mainView, B.mianView 모두를 Encoding해서 Data타입으로 만든다면
case의 타입만 저장함으로 "mainView"만 저장되서 뷰쪽에서 화면타입을 정할때
A모듈의 화면인지 B모듈의 화면인지 구분하지못하고 먼저 정의된 화면쪽으로 이동하게되는 점 주의해야해요
😇 장점
Codable한 타입에대해서 모두 라우팅 가능
배열에 넣고 사용함으로 순서나 컨트롤에서는 Hashable보다 이점
😈 단점
Encoding, Decoding 신경써야함
탭뷰 사용시 라우터당 화면정의가 필요하므로 어쩔수없이 각 탭마다 중복된 화면 정의가 필요
Screen타입 정의위치
각 모듈에 존재(만약 공통으로 사용시 interface모듈에 정의)
Codable 프로토콜 준수
하위모듈간 화면 전환가능여부
✅ 조건부 가능
조건: Dependency Cycle을 회피할 interface 모듈이 존재해야함
navigationDestination 뷰 정의위치
Router의 루트에 위치한 뷰(FeatureA)에서 정의합니다.
모든 타입은 Data타입으로 전달받고 이 값을 Decoding시켜서 화면타입을 구분
생각정리
Hashable하게 사용하는 라우터보다 개발자가 신경써야할 부분이 좀 더많아지는 느낌
타입네이밍에 신경써야한다
더 좋은 방법이 생각나거나 이미 더 개선된 방법으로 사용하시고 계신다면
많은 댓글과 첨언 부탁드려요 🙇♂️🙇♂️
감사합니다!!
'iyOmSd > Title: SwiftUI' 카테고리의 다른 글
[SwiftUI] WWDC24 SwiftUI essentials 정리 (0) | 2024.11.08 |
---|---|
[SwiftUI] Highlight Text만들기(일치하는 텍스트 강조 뷰) (0) | 2024.06.23 |
[SwiftUI] ChipView(iOS16+, iOS16-) tag view 구현하기 (1) | 2024.03.13 |
[SwiftUI] WWDC23 Demystify SwiftUI Performance (0) | 2023.11.30 |
[SwiftUI] StateObject init 생성자 (1) | 2023.08.28 |