nalexn / clean-architecture-swiftui

SwiftUI sample app using Clean Architecture. Examples of working with CoreData persistence, networking, dependency injection, unit testing, and more.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Separate Routing/Navigation from View

Patrick3131 opened this issue · comments

Hi Alex, as I mentioned before I like your sample project, your thoughts and implementation of SwiftUI and Combine.

I want to separate the View from the navigation flow, therefore I created a separate Router. The router contains the state of the routing or visibility of sheet popups and takes care of creating the destinations, basically NavigationLink vViews.
Therefore the view does not know about the links to the next view.

Implemented my approach looks like this:

extension AccountView {
    class Router: ObservableObject, AccountRouting {
        @Published var routingState: Routing
        @Published var showLoginView = false
        @Published var showManageSubscription = false
        
        private var cancelBag = CancelBag()
        
        init(appState: Store<AppState>) {
            _routingState = .init(initialValue: appState.value.routing.account)
            cancelBag.collect {
                [
                    $routingState
                    .removeDuplicates()
                        .sink { appState[\.routing.account] = $0}
                ]
            }
        
        }
        
        func showLoginView(viewModel: ViewModel) -> AnyView {
            return AnyView(EmptyView()
                .sheet(isPresented: Binding<Bool>.init(get: { self.showLoginView },
                                                       set: { self.showLoginView = $0 }), content: {
                                                        LoginView(cancel: {
                                                            viewModel.cancelLogin()
                                                        })
                }))
        }
        
        func showManageSubscription(viewModel: ViewModel) -> AnyView {
            return AnyView(EmptyView()
                .sheet(isPresented: Binding<Bool>.init(get: { self.showManageSubscription },
                                                       set: { self.showManageSubscription = $0 }), onDismiss: {
                }, content: {
                    BuySubscriptionView(viewModel: viewModel.createBuySubcriptionViewModel())
                })
            )
        }
    }
}

or

extension CategoriesListView {
    class Router: ObservableObject, CategoriesListRouting {
        @Published var exercisesRouting: CategoriesListView.Routing
        private var cancelBag = CancelBag()
        
        init(appState: Store<AppState>) {
            _exercisesRouting = .init(initialValue: appState.value.routing.categories)
            
            self.cancelBag.collect {
                $exercisesRouting
                    .removeDuplicates()
                    .sink { appState[\.routing.categories] = $0 }
                
                appState.map(\.routing.categories)
                    .removeDuplicates()
                    .assign(to: (\.exercisesRouting), on: self)
            }
        }
        
        func exerciseViewDestination(viewModel: CategoriesListView.ViewModel, category: Category) -> AnyView {
            return AnyView(
                NavigationLink(
                    destination: ExercisesView(viewModel:
                        viewModel.createExerciseViewModel(category: category)),
                    tag: category.name,
                    selection: Binding<String?>.init(get: {self.exercisesRouting.categories},
                                                     set: {  self.exercisesRouting.categories = $0 ?? ""}))
                {
                    CategorieCell(name: category.name, number: "\(category.numberOfExercises)")
            })
        }
    }
}

The Router itself is owned by the ViewModel of the View.
The View would call a function of the ViewModel that returns AnyView. The ViewModel then calls a function of the router that would call the NavigationLink wrapped in AnyView.
What do you think, does this approach make sense to you? Do you have any other ideas how to take away routing responsibilities from the View?

Full project: https://github.com/Patrick3131/LearnHockey

@Patrick3131 ,

Unlike with views in UIKit, we cannot take a SwiftUI view and ask it to layout subviews, or render onto an image context. A SwiftUI view is absolutely useless without the drawing engine, which also owns the state (and the view only receives a reference to that state at render time, even when it uses a local @State).

A View is nothing more than a drawing algorithm. That's why you cannot easily extract routing off the view: routing is an integral part of it. My point is that we should not fight its nature. Instead, we should structure the program so that major pieces of this drawing algorithm would reside in separate views, and we can put the routing state either in a app-wide container (AppState being an ObservableObject or CurrentValueSubject), or in a local container (@State or @ObservedObject). But the routing logic itself should stay within the view.

I believe that the router (aka coordinator) is needless in SwiftUI. This does not mean we are doomed - there are other ways we can achieve testability. This sample project has view routing tests: 1, 2, even tests for deep link routing.