Reputation: 9855
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...)
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".
Upvotes: 10
Views: 7855
Reputation: 3467
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
Upvotes: 1
Reputation: 258375
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())
}
}
Upvotes: 12