SwiftUI

복잡한 Navigation Flow 처리 (feat. Combine)

Phililip
728x90

안녕하세요.

 

이번에는 SwiftUI에서 복잡한 Navigation Flow를 처리하기 좋은 방법(구조?)에 대해 소개한 글이 있어서, 아래 글을 토대로 공부해볼게요.

 

- https://betterprogramming.pub/flow-navigation-with-swiftui-revisited-791f89421923

 

Flow Navigation With SwiftUI (Revisited)

How to implement navigation effectively in your code bases

betterprogramming.pub

 

전체 코드는 이곳을 참고해주세요!

 


# 1. Navigation Flow를 처리할 때 고려해야 할 규칙들

아름다운 구조와 코드를 만들기 위해서 몇 가지 고려해주면 좋습니다.

[규칙 1] 화면(View)은 부모에 대한 지식이 없어야 하며, 화면 흐름 관리(Flow Control)에 대한 책임을 가지고 있지 않습니다.
[규칙 2] 각 화면을 위한 각각의 View Model이 있어야 합니다.
[규칙 3] Flow Control 로직은 UI 구현부와는 분리되어 있어야 하며, UI 없이 테스트 가능해야 합니다.
[규칙 4] Flow에 다른 화면을 분기(branching)로 넣는 것이 가능해야 하며 유연해야 합니다.
[규칙 5] 확장이 가능해야 합니다.

 

 

# 2. Navigation

아래처럼 간단한 flow를 구성한다고 생각해볼게요.

 

출처: https://betterprogramming.pub/flow-navigation-with-swiftui-revisited-791f89421923

 

 

이 정도면 금방 만들지.. 라고 생각하셨나요??

 

 

그럼 아래처럼 화면 분기처리가 들어가는 경우를 생각해볼까요?

 

출처: https://betterprogramming.pub/flow-navigation-with-swiftui-revisited-791f89421923

 

물론, 이것도 금방 만들 수 있을 것입니다.

 

 

근데 중요한 것은 따로 있죠.

 

각 화면은 Flow Control 로직이 없어야 하고 Flow Control에 대한 책임을 가지고 있지 않아야 하고, 화면 추가하는 것에 유연해야 합니다.

 

 

 

요런 고민을 해결하기 위해서 아래와 같은 navigation nodes와 edges를 표현하는 트리 구조를 소개해볼까 해요.

(Flow가 edge이고 다른 뷰들은 node겠죠?)

var body: some View {
    NavigationView {
        Screen1()
        Flow {
            Screen2()
            Flow {
                Screen3()
                Flow {
                    FinalScreen()
                }
                Flow {
                    Screen4()
                    Flow {
                        FinalScreen()
                    }
                }
            }
        }
    }
}

 

 

 

 

Flow는 NavigationLink를 이용해서 화면을 푸시해주는 역할을 가집니다.

이를 위해서 아래 API를 활용하면 좋을 것 같네요. (isActive = true이면 화면을 push 하고, isActive = false이면 화면을 pop 합니다.)

NavigationLink(destination: Destination, isActive: Binding<Bool>) { Label }

 

ViewBuilder를 사용하면 아래처럼 구현할 수 있겠네요.

(next라는 flag를 binding 받았기 때문에 주입한 next 변수에 따라서 화면이 push 될지 pop 될지 결정이 됩니다.)

struct Flow<Content>: View where Content: View {
    @Binding var next: Bool
    var content: Content
    var body: some View {
        NavigationLink(
            destination: VStack() { content },
            isActive: $next
        ) {
            EmptyView()
        }
    }
    init(next: Binding<Bool>, @ViewBuilder content: () -> Content) {
        self._next = next
        self.content = content()
    }
}

 

 

구현한 Flow를 이용해서 위에서 소개한 Simple Screen Flow를 직접 구현해봅시다.

 

출처:&nbsp;https://betterprogramming.pub/flow-navigation-with-swiftui-revisited-791f89421923

 

class FlowVM: ObservableObject {
    @Published var navigateTo2: Bool = false
    @Published var navigateTo3: Bool = false
}

struct FlowView: View {
    @ObservedObject var vm: FlowVM
    var body: some View { 
        NavigationView {
            VStack() {
                Text("Screen 1")
                Button(
                    action: { vm.navigateTo2 = true },
                    label: { Text("Next") }
                )
                Flow(next: $vm.navigateTo2) {
                    Text("Screen 2")
                    Button(
                        action: { vm.navigateTo3 = true },
                        label: { Text("Next") }
                    )
                
                    Flow(next: $vm.navigateTo3) {
                        Text("Screen 3")
                    }
                }
            }
        }
    }
}

 

완벽해 보이지만.... [규칙 3]을 지키지 않았네요..

(버튼이 눌릴 때, flag를 직접 수정해주고 있습니다.. 저희는 뷰 바깥에서 컨트롤을 하는 게 목적인데 말이죠.)

 

 

 

 

# 3. View Models and Binding

View Model의 장점에 대해서 간략하게 설명하자면,

 

View Model을 사용하면 non-UI 로직을 캡슐화할 수 있고, 뷰 없이 unit test도 가능해집니다.

 

SwiftUI에서는 view model을 만들기 위해 ObservableObject를 제공해주고 있습니다.

 

ObservableObject는 2가지 방법으로 Binding 시켜줄 수 있어요. (ObservedObject, StateObject)

(이때 ObservedObject는 뷰가 재생성될 때 뷰 모델 인스턴스가 다시 생성될 수 있으니 주의해야 합니다.)

 

UI 이벤트들이 view -> view model로 전달되고, view model에서 특정 로직이 돌아가는 것이죠.

 

 

 

다시 예시로 돌아와서,

 

그럼 Navigation Flow에 대해서 구상을 해보면, 2가지의 View Model이 필요하다는 것을 알 수 있습니다.

 

첫 번째는, 각 화면마다 1:1로 존재하는 View Model입니다. (ScreenVM 이라고 부를게요!)

ScreenVM은 화면 그 자체에 대한 View Model로써, UI 이벤트를 받았을 때 어떤 동작을 하고 화면에 어떻게 뿌려줄지를 결정하는 View Model이에요.

 

두 번째는, 화면과 화면을 연결시키는 역할을 담당하는 View Model 입니다. (FlowVM이라고 부를게요!)

FlowVM은 화면 content에 대해서는 고려할 필요가 없고, 어떤 화면을 push/pop 하면 될지만 결정하면 되는 것이에요.

 

 

2가지 View Model의 관계에 대한 대략적인 구조는 아래와 같습니다.

출처:&nbsp;https://betterprogramming.pub/flow-navigation-with-swiftui-revisited-791f89421923

 

 

 

FlowVM은 어떤 화면을 push/pop 할지 관리하는 것이고, ScreenVM은 화면 그 자체에 대한 로직을 가지고 있다고?

 

OK... 그렇다고 치자...

 

그럼 만약에, 화면의 버튼을 눌렀을 때 다음 화면이 push 되게끔 하고 싶으면 어떻게 구현해야 함???

 

 

 

 

방법은 여러 가지가 있겠지만, 방법 중 하나는 PassthroughSubject를 사용하는 것이에요. (delegate, callback 등도 사용 가능하겠죠?)

 

FlowVM이 ScreenVM을 구독하고 있고, 버튼이 눌릴 때 ScreenVM이 broadcast 해주면, FlowVM이 이벤트를 전달받고 화면을 push/pop 해주는 것이 가능하겠죠?

 

코드는 아래처럼 될 것입니다.

class Screen1VM: ObservableObject {
    @Published var name = "" // some bound info
    let didComplete = PassthroughSubject<Screen1VM, Never>() 
    fileprivate func didTapNext() {
        didComplete.send(self)
    }
}
struct Screen1: View {
    @StateObject var vm: Screen1VM
    
    var body: some View {
        VStack(alignment: .center) {
            TextField("Name", text: $vm.name)
            Button(action: {
                self.vm.didTapNext()
            }, label: { Text("Next") })
        }
    }
}
class FlowVM: ObservableObject {
    @Published var navigateTo2: Bool = false
    var subscription = Set<AnyCancellable>()
    func makeScreen1VM() -> Screen1VM {
        let vm = Screen1VM()
        vm.didComplete
            .sink(receiveValue: didComplete1)
            .store(in: &subscription)
        return vm
    }
    func didComplete1(vm: Screen1VM) {
        navigateTo2 = true
    }
}

 

😁

 

 

# 4. Brining It Together

출처:&nbsp;https://betterprogramming.pub/flow-navigation-with-swiftui-revisited-791f89421923

 

초반에 예시로 소개한 Screen Flow With Branching 예시를 구현해보면, 위에서 설명한 edges와 nodes를 표현한 트리 구조 & View Model의 장점이 어느 정도 눈에 보입니다.

class FlowVM: ObservableObject {
    @Published var navigateTo2: Bool = false
    @Published var navigateTo2: Bool = false
    @Published var navigateTo3: Bool = false
    @Published var navigateTo4: Bool = false
    @Published var navigateToFinalFrom3: Bool = false
    @Published var navigateToFinalFrom3: Bool = false
    var subscription = Set<AnyCancellable>()
    // repeated for all screens
    func makeScreen1VM() -> Screen1VM {
        let vm = Screen1VM()
        vm.didComplete
            .sink(receiveValue: didComplete1)
            .store(in: &subscription)
        return vm
    }
    // repeated for all screens
    func didComplete1(vm: Screen1VM) {
        //do other logic here
        navigateTo2 = true
    }
    ...
}
struct FlowView: View {
    @StateObject var vm: FlowVM
    var body: some View {
        NavigationView {
            VStack() {
                Screen1(vm: vm.makeScreen1VM())
                Flow(next: $vm.navigateTo2) {
                    Screen2(vm: vm.makeScreen2VM())
                    Flow(next: $vm.navigateTo3) {
                        Screen3(vm: vm.makeScreen3VM())
                        Flow(next: $vm.navigateTo4) {
                            Screen4(vm: vm.makeScreen2VM())
                            Flow(next: $vm.navigateToFinalFrom4) {
                                FinalScreen(vm: vm.makeScreen5VM())
                            }
                        }
                        Flow(next: $vm.navigateToFinalFrom3) {
                            FinalScreen(vm: vm.makeScreen5VM())
                        }
                    }
                }
            }
        }
    }
}

 

코드가 상당히 복잡해 보이긴 하지만

 

View, View의 View Model(ScreenVM), Navigation을 위한 View Model(FlowVM)이 명확하게 나뉘어 있어서 좋은 구조라고 할 수 있을 것 같네요ㅎㅎㅎ 👍 👍 👍 

 

(전체 코드는 여기를 참고해주세요!)

 

 

 

# 5. Testing

물론, navigation flow에 대해서 UI 없이 테스트도 가능해진 것도 큰 장점이라고 할 수 있겠네요.

class NavigationFlowTests: XCTestCase {
    func test_navigation() throws {
        let sut = FlowVM()
        XCTAssertFalse(sut.navigateTo2)
        let screen1VM = sut.makeScreen1PhoneVM()
        screen1VM.didTapNext()
        XCTAssertFalse(sut.navigateTo2)
    }
}

 

👍 👍 👍 

 

 

 

## 참고

- https://betterprogramming.pub/flow-navigation-with-swiftui-revisited-791f89421923

 

Flow Navigation With SwiftUI (Revisited)

How to implement navigation effectively in your code bases

betterprogramming.pub

 

 


 

이번 글은 시간이 좀 걸리더라도 이해를 하고 넘어가 주시면 좋을 것 같아요.

제가 설명을 잘 못해서... 이해가 잘 안되시면 원문을 보시는 것도 추천드려요!!

 

이번 글은 여기서 마무리.

 

 

 

반응형

'SwiftUI' 카테고리의 다른 글

Environment  (0) 2022.05.29
SwiftUI에서 ViewController를?  (0) 2022.05.11
MotionScape  (0) 2022.05.06
Dismiss Presented View  (0) 2022.04.25
Custom Adaptive StackView (feat. ViewBuilder)  (0) 2022.04.17