iOS/SwiftUI

[SwiftUI] - NavigationStack에서 화면 재사용 시 루트 뷰로 돌아가는 방법 설계

강철곰탱이 2025. 5. 15. 21:31

 

앱을 개발하다 보면 여러 플로우에서 동일한 화면을 사용하는 상황이 자주 발생한다. NavigationStack을 활용해, 공통된 화면은 재사용하면서도 각자의 플로우에 맞게 루트로 돌아갈 수 있도록 구현해보자.


1. 상황

 

다음 두 플로우가 존재한다:

  1. 비행기 직접 작성 후 날리기 플로우
  2. 비행기 이어서 날리기(릴레이) 플로우

 

1. 비행기 직접 작성 후 날리기 플로우

 

 

2. 비행기 이어서 날리기 플로우

 

[1. 비행기 날리기 플로우와 2. 비행기 이어서 날리기 플로우]에서 같은 화면인 비행기 작성하는 화면을 플로우에서 공유하고 있다.

최종적으로 "홈 화면으로" 버튼을 누르면 각 플로우에 해당하는 루트 뷰로 돌아가게 하고 싶다.

 

 

이걸 어떻게 Navigaion Stack으로 구현할 수 있을까?

 

위의 상황을 뷰 이름으로 정리해 보자.

 

FlyAnimationView에서 "홈 화면으로" 버튼을 누르면,

  • 첫 번째 플로우에서는 MainView로 돌아가야 하고
  • 두 번째 플로우에서는 LandingZoneView로 돌아가야 한다.

 

-> 즉, 같은 화면(FlyAnimationView)에서 다른 루트 뷰로 돌아가야 하는 조건이 필요하다.

 


2. 루트뷰로 돌아오게 구현

 

플로우 1. MainView -> SelectSubjectView -> SendLetterView -> FlyAnimationView

 

1️⃣ MainView

 

MainRoute는 MainView에서 어떤 뷰로 이동할지를 정의하는 enum이다. 각 화면은 해당 enum case로 매핑된다.

enum MainRoute {
    case selectSubject
    case sendLetter
    case flyAnimation
}

 

MainView에 NaivgationStack의 경로는 viewModelWrapper의 path 변수로 관리할 것이고 .navigationDestination으로 목적지를 MainRoute에 따라 정할 것이다.

 

 

 

struct MainView: View {
    
    @EnvironmentObject var viewModelWrapper: MainViewModelWrapper
        
    var body: some View {
        NavigationStack(path: $viewModelWrapper.path){
            VStack {
            	 Button(action: {
                    viewModelWrapper.path.append(.selectSubject)
                }, label: {
                    Text("비행기 날리기")
                })
            }
            .navigationDestination(for: MainRoute.self) { route in
                switch route {
                case .selectSubject:
                    SelectSubjectView()
                case .sendLetter:
                    SendLetterView()
                case .flyAnimation:
                    FlyAnimationView()
                }
            }
        }
    }
}

final class MainViewModelWrapper: ObservableObject {
    @Published var path: [MainRoute] = []
}

 

  • NaivgationStack의 경로는 viewModelWrapper의 path 변수로 관리
  • 버튼을 누르면 .selectSubject가 path에 추가되며, SelectSubjectView로 이동한다.
  • 현재 MainView만 stack에 쌓여있고, 이후 경로를 계속 추가하면서 화면을 쌓아간다.

 

 

2️⃣ SelectSubjectView

 

"다음" 버튼을 누르면 .sendLetter가 path에 추가되며, SendLetterView로 이동한다.

struct SelectSubjectView: View {
    
    @EnvironmentObject var viewModelWrapper: MainViewModelWrapper
    
    var body: some View {
        VStack {
            Text("SelectSubjectView")
        }
        .toolbar{            
            ToolbarItem(placement: .topBarTrailing){
                Button(action: {
                    viewModelWrapper.path.append(.sendLetter)
                }, label: {
                    Text("다음")
                })
            }
        }
    }
}

 

3️⃣ SendLetterView

 

SendLetterView도 SelectSubjectView와 동작 방식이 같다.

struct SendLetterView: View{
    
    @EnvironmentObject var viewModelWrapper: MainViewModelWrapper
    
    var body: some View{
        ScrollView{
            Text("SendLetterView")
        }
        .toolbar{
            ToolbarItem(placement: .topBarTrailing){
                ToolbarFlyButton(action: {
                    Button(action: {
                    	viewModelWrapper.path.append(.flyAnimation)
                    }, label: {
                        Text("다음")
                    })
                })
            }
        }
    }
}

 

4️⃣ FlyAnimationView

 

struct FlyAnimationView: View {

    @EnvironmentObject var viewModelWrapper: MainViewModelWrapper

    var body: some View {
        VStack {
            Button(action: {
                viewModelWrapper.path = []
            }, label: {
                Text("홈 화면으로")
            })
        }
    }
}

 

 

  • "홈 화면으로" 버튼을 누르면 viewModelWrapper.path = []가 실행되어 NavigationStack이 초기화된다.
  • 이로써 MainView로 돌아가고, 이전 화면 스택은 모두 제거된다.

 

 

 

 

 

✏️ 최종 플로우 정리

MainView에서 시작
→ path.append(.selectSubject)로 SelectSubjectView 이동
→ path.append(.sendLetter)로 SendLetterView 이동
→ path.append(.flyAnimation)로 FlyAnimationView 이동
→ path = []를 호출하여 MainView로 초기화

 

 

 


3. 플로우에 따라 루트뷰 변경

 

SendLetterView와 FlyAnimationView는 여러 진입점(Main 또는 LandingZone)에서 공통으로 사용되는 뷰다. 이 경우, 어떤 플로우에서 진입했는지를 구분하여 적절한 path를 관리하고, 루트로 되돌아가는 처리를 확실하게 할 필요가 있다.

 

🔎 두 개의 플로우

플로우 1: MainView 기반
MainView → SelectSubjectView → SendLetterView → FlyAnimationView

플로우 2: LandingZoneView 기반
LandingZoneView → LandingZoneInfoView → SendLetterView → FlyAnimationView

두 플로우는 마지막 두 화면인 SendLetterView와 FlyAnimationView를 공유한다.

 

플로우 2. LandingZoneView -> LandingZoneInfoView -> SendLetterView -> FlyAnimationView

 

1️⃣ LandingZoneView

 

LandingZoneRoute는 LandingZoneView에서 어떤 뷰로 이동할지를 정의하는 enum이다. 각 화면은 해당 enum case로 매핑된다.

enum LandingZoneRoute {
    case landingZoneInfo
    case relayLetter
    case flyAnimation
}

 

같이 공유하는 View가 SendLetterView이기 때문에 SnedLetterRoute를 만들어 어떤 흐름으로 이 뷰에 들어오게 된 건지 구분해야 한다.

 

enum SendLetterRoute {
    case start   // MainView 플로우
    case relay   // LandingZone 플로우
}

 

LandingZoneView는 릴레이로 전달하는 게 필요해서 SendLetterView를 사용하기 때문에 route를 relay로 설정해 준다.

그럼 MainView는 SendLetterView(route: .start)로 바꾸면 된다.

struct LandingZoneView: View {
    
    @EnvironmentObject var viewModelWrapper: LandingZoneViewModelWrapper
        
    var body: some View {
        NavigationStack(path: $viewModelWrapper.path){
            VStack {
            	Text("LandingZoneView")
            }
            .navigationDestination(for: LandingZoneRoute.self) { route in
                switch route {
                case .landingZoneInfo:
                    LandingZoneInfoView()
                case .relayLetter:
                    SendLetterView(route: .relay)
                case .flyAnimation:
                    FlyAnimationView(onHome: {
                        viewModelWrapper.path = []
                    })
                }
            }
        }
    }
}

 

2️⃣ LandingZoneInfoView

 

"다음" 버튼을 누르면 .relayLetter가 path에 추가되며, SendLetterView로 이동한다.

struct LandingZoneInfoView: View {
    
    @EnvironmentObject var viewModelWrapper: LandingZoneViewModelWrapper
    
    var body: some View {
        VStack {
            Text("LandingZoneInfoView")
        }
        .toolbar{            
            ToolbarItem(placement: .topBarTrailing){
                Button(action: {
                    viewModelWrapper.path.append(.relayLetter)
                }, label: {
                    Text("다음")
                })
            }
        }
    }
}

 

3️⃣ SendLetterView

 

SendLetterView에서 처리해야 할 경로가 [MainView에서부터 시작한 경로, LandingZoneView에서부터 시작한 경로] 이렇게 두 가지다.

 

두 플로우에서 공통으로 사용되므로 route 파라미터로 현재 플로우를 구분한다. 그리고 두 viewModelWrapper를 모두 주입받아 조건에 따라 적절한 경로에 flyAnimation을 추가하도록 한다.

 

struct SendLetterView: View{
    
    @EnvironmentObject var viewModelWrapper: MainViewModelWrapper
    @EnvironmentObject var landingZoneViewModelWrapper: LandingZoneViewModelWrapper
    
    let route: SendLetterRoute
    
    var body: some View{
        ScrollView{
            Text("SendLetterView")
        }
        .toolbar{
            ToolbarItem(placement: .topBarTrailing){
                ToolbarFlyButton(action: {
                    Button(action: {
                    	sendLetter()
                    }, label: {
                        Text("다음")
                    })
                })
            }
        }
    }
    
    private func sendLetter(){
        if route == .start{
            viewModelWrapper.path.append(.flyAnimation)
        } else{
            landingZoneViewModelWrapper.path.append(.flyAnimation)
        }
    }
}

 

🧐 만약 sendLetter() 분기처리를 해주지 않는다면?

 

두 플로우 모두 flyAnimation으로 이동하는 건 같아서 처음에는 route를 처리하지 않고 구현했다.

"다음" 버튼을 누르면 path를 추가하는 로직 둘 다 실행되게 했는데 문제가 발생했다.

viewModelWrapper.path.append(.flyAnimation)
landingZoneViewModelWrapper.path.append(.flyAnimation)

 

플로우 2로 시작해서 온 경우 viewModelWrapper가 주입되지 않은 상태이기 때문에 viewModelWrapper를 찾을 수 없다고 오류가 난다... ㅎ

 

그래서 viewModelWrapper가 주입된 경로에 따라 처리를 해줘야 한다고 판단해서 route 파라미터를 사용했다.

 

4️⃣ FlyAnimationView

 

공통으로 사용하는 뷰이므로 FlyAnimationView 내에서 path = []를 직접 제어하지 않고, 상위 뷰에서 클로저(onHome)를 통해 루트 뷰로 돌아갈 수 있도록 처리했다.

 

struct FlyAnimationView: View {

    let onHome: () -> Void
    
    var body: some View {
        VStack {
            Button(action: {
                onHome()
            }, label: {
                Text("홈 화면으로")
            })
        }
    }
}

 

이렇게 하면 FlyAnimationView는 어느 플로우로 진입했든지 간에 상위 뷰에서 적절히 루트로 돌아갈 수 있다. ㅎ

 

✏️ 최종 플로우 정리

LandingZoneView에서 시작
→ path.append(.landingZoneInfo)로 LandingZoneInfoView 이동
→ path.append(.relayLetter)로 SendLetterView 이동
→ path.append(.flyAnimation)로 FlyAnimationView 이동
→ LandingZoneView에서 클로저(onHome)을 호출하여 LandingZoneView로 초기화

 


이번 작업을 통해 NavigationStack의 path를 잘 관리하면 화면 전환을 깔끔하게 처리할 수 있다는 점을 알게되었다.

하지만 반대로, 어떤 흐름에서 그 화면에 도착했는지를 제대로 구분하지 않으면 오히려 더 복잡해질 수도 있을 것 같다.

그래서 이번처럼 route와 클로저를 이용해서 각 플로우의 책임을 명확하게 나눠주는 방식이 유지보수할 때도 편하고, 나중에 플로우가 더 늘어나더라도 확장하기 쉬운 좋은 구조라고 생각한다.