SwiftUI MVVM Coordinator/Router/NavigationLink
Asked Answered
P

2

10

I am having problems to translate UIKit architecture patterns to SwiftUI. My current pattern is mostly MVVM with Coordinators/Routers. The MVVM part seems quite easy and natural with the addition of @ObservableObject/@Published. But the coordinating/routing seems unintuitive. The View and the coordination (navigation) functionality are tightly coupled in SwiftUI. It seems like it's not really possible to separate them apart from using the helper struct AnyView.

Here one example: I want to create a reusable row/cell in SwiftUI. Let say that this row in Production is quite complex therefore I want to reuse it. I want to place it also in another module so I can reuse it in multiple targets. (like iOS, macCatalyst, etc...)

enter image description here

Now I want to control what happens when the user taps on that view or buttons in that view. Depending on the context I need to navigate to different destinations. As far I can see the possible NavigationLink targets have to be either hardwired into the view or AnyView has to be passed into the View.

Here some sample code. This cell/row contains two buttons. I want to navigate to some other view which is dependent on the context and not to be hardwired into the code:

struct ProductFamilyRow: View {
    @State private var selection: Int? = 0
    let item: ProductFamilyItem
    
    let destinationView1: AnyView
    let destinationView2: AnyView
    
    var body: some View {
        VStack {
            NavigationLink(
                destination: destinationView1,
                tag: 1,
                selection: self.$selection
            ) {
                EmptyView()
            }
            
            NavigationLink(
                destination: destinationView2,
                tag: 2,
                selection: self.$selection
            ) {
                EmptyView()
            }
                        
            HStack {
                Text(item.title)
                Button("Destination 1") {
                    self.selection = 1
                }.foregroundColor(Color.blue)
                
                Button("Destination 2") {
                    self.selection = 2
                }.foregroundColor(Color.blue)
            }

            //Image(item.image)
        }.buttonStyle(PlainButtonStyle())
    }
}

This seems to be a major design flaw in SwiftUI. Reusable components with Navigation Links are basically not possible apart from using the AnyView hack. As far as I know AnyView is just used for specific use cases where I need type-erasure and has quite some performance drawbacks. So I do not consider this the idiomatic solution to create reusable, navigatable views with SwiftUI.

Is this really the only solution? Maybe I am totally wrong and this is anyway the wrong direction. I read somewhere (can't find the post anymore..) about using some central state which indicates which view to show but I saw no concrete example how to do this.

2nd challenge: Also I do not want the cell to react on any other taps then on the buttons. But it seems not to be possible to control where the cell Navigates to if tapped. (so not tapping on one of the buttons but anywhere in the cell) In the current sample code it navigates (for any reason) to "Destination 2".

Philadelphia answered 13/4, 2020 at 12:41 Comment(0)
F
12

It is better to use generics for your row, as below (tested with Xcode 11.4)

Usage example:

ProductFamilyRow(item: ProductFamilyItem(title: "Test"),
    destinationView1: { Text("Details1") },
    destinationView2: { Text("Details2") })

Interface:

Update - added block for row highlight. List has auto detection for button or link inside row and highlights if any standard (!key) present. So, to disable such behaviour it needs to hide everything under custom button style.

struct ProductFamilyRowStyle: ButtonStyle {

    func makeBody(configuration: Self.Configuration) -> some View {
        configuration.label
            .colorMultiply(configuration.isPressed ? 
                 Color.white.opacity(0.5) : Color.white) // any effect you want
    }
}

struct ProductFamilyRow<D1: View, D2: View>: View {
    let item: ProductFamilyItem
    let destinationView1: () -> D1
    let destinationView2: () -> D2

    init(item: ProductFamilyItem, @ViewBuilder destinationView1: @escaping () -> D1,
        @ViewBuilder destinationView2: @escaping () -> D2)
    {
        self.item = item
        self.destinationView1 = destinationView1
        self.destinationView2 = destinationView2
    }

    @State private var selection: Int? = 0

    var body: some View {
        VStack {
            HStack {
                Text(item.title)
                Button(action: {
                    self.selection = 1
                }) {
                    Text("Destination 1")
                        .background( // hide link inside button !!
                            NavigationLink(destination: destinationView1(),
                                tag: 1, selection: self.$selection) { EmptyView() }
                        )
                }.foregroundColor(Color.blue)

                Button(action: {
                    self.selection = 2
                }) {
                    Text("Destination 2")
                        .background(
                            NavigationLink(destination: destinationView2(),
                                tag: 2, selection: self.$selection) { EmptyView() }
                        )
                }.foregroundColor(Color.blue)
            }

            //Image(item.image)
        }.frame(maxWidth: .infinity) // to have container centered
        .buttonStyle(ProductFamilyRowStyle())
    }
}
Farriery answered 13/4, 2020 at 13:19 Comment(8)
Thanks! Do you also know how to disable the row-selection it self? I want the user to only navigate by tapping on the buttons.Philadelphia
Generally the idea works but unfortunately this does not scale to real Production Apps. As soon as I have multiple reusable screens I run into the logical problem that one reusable view (ViewA) needs a preconfigured view-destination (ViewB). But what if ViewB also needs a preconfigured view-destination ViewC? I would need to create ViewB already in such a way that ViewC is injected already in ViewB before I inject ViewB into ViewA. And so on.... but as the data which at that time has to be passed is not available the whole construct fails.Philadelphia
My opinion is still unchanged: SwiftUI has a major design flaw. Reusable views with Navigation are not possible in real life. (in small Sample Apps, yes, of course) The deeper reason for this: navigation and view is strongly bound to each other. In UIKit this is separable. Like logic and view have to be separated also navigation and view have to be separated (Coordinator pattern) But this is in the current SwiftUI version simply not possible.Philadelphia
Here a follow app question by me: #61305200Philadelphia
This answer is GOLD. Thank you @FarrieryInopportune
@Philadelphia This is not a design flaw in SwiftUI. If you want a dynamic solution (i.e. you have a dynamic set of elements, and certain properties determine the destination view) there is still a solution for this, if you accept that your source view can accept to deal with a function contentForDetail(id:) -> AnyView that returns a AnyView. We just need to discover the patterns, idioms and best practices ;)Rancourt
@Philadelphia SwiftUI View structs are just data don't design them like screens or rows. MMVM has no place in SwiftUI but if you are still struggling with learning it you can simply think of the SwiftUI View data struct as analogous to a VM.Tetravalent
Hi! I encourage you to check this out: github.com/nandzz/Jump-SwiftUI-CoordinatorBenham
M
0

After going over several articles here and there and reading the comments and answer here, for which I generally agree, I tried to abstract as much as possibile the displaying of views (modal/sheet/navigation) and I came up with an explorative solution which might help

Sample project and full explanation here:

https://github.com/LucaIaco/DisplayerSwiftUI

In a nutshell, I wanted to give the control of displaying any sort of view from outside the given SwiftUI view, and regardless of the view itself (without relying on UIKit, and taking into account the difference in navigation before and after iOS 16). Yes, under the hood there’s still a binding to the view (that’s intrinsic in the nature of SwiftUI), but it should be generalized in a way that it shouldn’t bother you too much.

We have a “Displayer” component logically associated to the currently displayed SwiftUi view on screen (this could be in practice your coordinator or router, or anything you may like) and a single property “displayingItem” which is observed by a wrapper view (or to be precised, by the underling view modifier that does the magic behind) and this way allows you to display other views without manually writing extra code in each every view in your flow.

Sample usage:

// In my DisplayerProtocol conforming object...

@Published
var displayingItem: Displayable.ViewItem = .none

.. 

// displaying a SwiftUI view
self.displayingItem = .init(displayMode: .modal, anyView: DummySwiftUIView(message: "Some modal SwiftUI view", viewModel: DummyViewModel(coordinator: self)))
self.displayingItem = .init(displayMode: .sheet, anyView: DummySwiftUIView(message: "Some sheet SwiftUI view", viewModel: DummyViewModel(coordinator: self)))
self.displayingItem = .init(displayMode: .pushed, anyView: DummySwiftUIView(message: "Some pushed SwiftUI view", viewModel: DummyViewModel(coordinator: self)))
// working if this displayer or a parentDisplayer object has a `displayingItem.displayMode` as `.pushed`
self.pushView(DummySwiftUIView(message: "Some pushed SwiftUI view", viewModel: DummyViewModel(coordinator: self))) 

// displaying a UIKit view
self.displayingItem = .init(displayMode: .modal, anyView: DummyViewController(viewModel: DummyViewModel(coordinator: self)))
self.displayingItem = .init(displayMode: .sheet, anyView: DummyViewController(viewModel: DummyViewModel(coordinator: self)))
self.displayingItem = .init(displayMode: .pushed, anyView: DummyViewController(viewModel: DummyViewModel(coordinator: self)))
// working if this displayer or a parentDisplayer object has a `displayingItem.displayMode` as `.pushed`
self.pushView(DummyViewController(viewModel: DummyViewModel(coordinator: self)))

..
// Then, in my factory/builder ..

/// `displayer` is the object conforming to `DisplayerProtocol` in charge of displaying view
/// `navigationHandling` indicates how the navigation (push/pop) should be handled specifically by this view
let viewToBeDisplayed = Displayable.RootView(displayer: myDisplayerObject, navigationHandling: myNavigationHandling) {
    // My current view displayed on screen, from which I’ll be able to display another view, in the context of the provided displayer holding the ‘displayingItem’
    MyContentView(viewModel: viewModel)
}

You can find more details explained in the Github project and even more in the code (highly commented).

Also, regarding the infamous AnyView (which I had to use for the adopted approach in the underlying generalized component), I didn’t experience any performance impact in the tests I did (like embedding long list or similar), and regarding this topic, maybe this other project I found out there, might bring more clarity on how to coexist with AnyView Check this out: https://github.com/hmlongco/AnyViewTest

Memorable answered 15/11, 2023 at 18:5 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.