[SwiftUI] - NavigationStack에서 화면 재사용 시 루트 뷰로 돌아가는 방법 설계
앱을 개발하다 보면 여러 플로우에서 동일한 화면을 사용하는 상황이 자주 발생한다. NavigationStack을 활용해, 공통된 화면은 재사용하면서도 각자의 플로우에 맞게 루트로 돌아갈 수 있도록 구현해보자.
1. 상황
다음 두 플로우가 존재한다:
- 비행기 직접 작성 후 날리기 플로우
- 비행기 이어서 날리기(릴레이) 플로우
[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와 클로저를 이용해서 각 플로우의 책임을 명확하게 나눠주는 방식이 유지보수할 때도 편하고, 나중에 플로우가 더 늘어나더라도 확장하기 쉬운 좋은 구조라고 생각한다.