'SwiftUI MVVM Coordinator/Router/NavigationLink

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".

Thanks in advance.



Solution 1:[1]

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())
    }
}

backup

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1