iyOmSd/Title: SwiftUI

[SwiftUI] NavigationStack 화면전환 방법 고민정리 (feat. Router 구현)

냄수 2024. 4. 26. 23:50
반응형

안녕하세요~

iOS16+부터 사용할 수 있는 NavigationStack을 이용한 라우터 로직을 만들었는데요

그 과정에서 생각한 방법과 겪은 고민을 정리해보려합니다!

 

네비게이션 스택이뭐야? 하시는분들은

https://nsios.tistory.com/199

 

[SwiftUI] NavigationStack

안녕하세요! SwiftUI에서 항상 느꼇던 불편한점중 하나가 네비게이션이였는데요 이를 해결해주는게 나온지 좀 됐지만 이제 해보려합니다! (진작에 나왔을 녀석이여야 했는데...) iOS16 타겟을 쓸일

nsios.tistory.com

쓰윽 훑고오셔도 좋을것같습니다!

우선 제일 간단한 방법부터 시작해보겠습니다~!

 

아! 그전에

모든 라우팅 로직들은 모듈화를 했다는 가정하에 이뤄졌습니다.

예제에서는 아주 간단한 구조로 사용했어요

 

 

하나의 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하게 사용하는 라우터보다 개발자가 신경써야할 부분이 좀 더많아지는 느낌

타입네이밍에 신경써야한다

 

 

 

 

더 좋은 방법이 생각나거나 이미 더 개선된 방법으로 사용하시고 계신다면

많은 댓글과 첨언 부탁드려요 🙇‍♂️🙇‍♂️

감사합니다!!

 

 

 

 

 

 

 

 

 

반응형