SwiftUI - how to avoid navigation hardcoded into the view?
I try to do the architecture for a bigger, production ready SwiftUI App. I am running all the time into the same problem which points to a major design flaw in SwiftUI.
Still nobody could give me a full working, production ready answer.
How to do reusable Views in SwiftUI
which contain navigation?
As the SwiftUI
NavigationLink
is strongly bound to the view this is simply not possible in such a way that it scales also in bigger Apps. NavigationLink
in those small sample Apps works, yes - but not as soon as you want to reuse many Views in one App. And maybe also reuse over module boundaries. (like: reusing View in iOS, WatchOS, etc...)
The design problem: NavigationLinks are hardcoded into the View.
NavigationLink(destination: MyCustomView(item: item))
But if the view containing this NavigationLink
should be reusable I can not hardcode the destination. There has to be a mechanism which provides the destination. I asked this here and got quite a good answer, but still not the full answer:
SwiftUI MVVM Coordinator/Router/NavigationLink
The idea was to inject the Destination Links into the reusable view. 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.
Another idea I had was to use the Environment
as dependency injection mechanism to inject destinations for NavigationLink
. But I think this should be considered more or less as a hack and not a scalable solution for large Apps. We would end up using the Environment basically for everything. But because Environment also can be used only inside View's (not in separate Coordinators or ViewModels) this would again create strange constructs in my opinion.
Like business logic (e.g. view model code) and view have to be separated also navigation and view have to be separated (e.g. the Coordinator pattern) In UIKit
it's possible because we access to UIViewController
and UINavigationController
behind the view. UIKit's
MVC already had the problem that it mashed up so many concepts that it become the fun-name "Massive-View-Controller" instead of "Model-View-Controller". Now a similar problem continues in SwiftUI
but even worse in my opinion. Navigation and Views are strongly coupled and can not be decoupled. Therefore it's not possible to do reusable views if they contain navigation. It was possible to solve this in UIKit
but now I can't see a sane solution in SwiftUI
. Unfortunately Apple did not provide us an explanation how to solve architectural issues like that. We got just some small sample Apps.
I would love to be proven wrong. Please show me a clean App design pattern which solves this for big production ready Apps.
Thanks in advance.
Update: this bounty will end in a few minutes and unfortunately still nobody was able to provide a working example. But I will start a new bounty to solve this problem if I can't find any other solution and link it here. Thanks to all for their great Contribution!
Update 18th June 2020: I got an answer from Apple regarding this issue, proposing something like this to decouple views and models:
enum Destination {
case viewA
case viewB
case viewC
}
struct Thing: Identifiable {
var title: String
var destination: Destination
// … other stuff omitted …
}
struct ContentView {
var things: [Thing]
var body: some View {
List(things) {
NavigationLink($0.title, destination: destination(for: $0))
}
}
@ViewBuilder
func destination(for thing: Thing) -> some View {
switch thing.destination {
case .viewA:
return ViewA(thing)
case .viewB:
return ViewB(thing)
case .viewC:
return ViewC(thing)
}
}
}
My response was:
Thanks for the feedback. But as you see you still have the strong coupling in the View. Now "ContentView" needs to know all the views (ViewA, ViewB, ViewC) it can navigate too. As I said, this works in small sample Apps, but it does not scale to big production ready Apps.
Imagine that I create a custom View in a Project in GitHub. And then import this view in my App. This custom View does not know anything about the other views it can navigate too, because they are specific to my App.
I hope I explained the problem better.
The only clean solution I see to this problem is to separate Navigation and Views like in UIKit. (e.g. UINavigationController)
Thanks, Darko
So still no clean & working solution for this problem. Looking forward to WWDC 2020.
Update September 2021:
Using AnyView
is not a good general solution for this problem. In big Apps basically all views have to be designed in a reusable way. This would mean that AnyView
get's used everywhere. I had a session with two Apple developers and they clearly explained to me the AnyView
creates a way worse performance then View and it should be only used in exceptional cases. The underlying reason for this is that the type of AnyView
can't be resolved during compile time so it has to be allocated on the heap.
The closure is all you need!
struct ItemsView<Destination: View>: View {
let items: [Item]
let buildDestination: (Item) -> Destination
var body: some View {
NavigationView {
List(items) { item in
NavigationLink(destination: self.buildDestination(item)) {
Text(item.id.uuidString)
}
}
}
}
}
I wrote a post about replacing the delegate pattern in SwiftUI with closures. https://swiftwithmajid.com/2019/11/06/the-power-of-closures-in-swiftui/
My idea would pretty much be a combination of Coordinator
and Delegate
pattern. First,
create a Coordinator
class:
struct Coordinator {
let window: UIWindow
func start() {
var view = ContentView()
window.rootViewController = UIHostingController(rootView: view)
window.makeKeyAndVisible()
}
}
Adapt the SceneDelegate
to use the Coordinator
:
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
let coordinator = Coordinator(window: window)
coordinator.start()
}
}
Inside of ContentView
, we have this:
struct ContentView: View {
var delegate: ContentViewDelegate?
var body: some View {
NavigationView {
List {
NavigationLink(destination: delegate!.didSelect(Item())) {
Text("Destination1")
}
}
}
}
}
We can define the ContenViewDelegate
protocol like this:
protocol ContentViewDelegate {
func didSelect(_ item: Item) -> AnyView
}
Where Item
is just a struct which is identifiable, could be anything else (e.g id of some element like in a TableView
in UIKit)
Next step is to adopt this protocol in Coordinator
and simply pass the view you want to present:
extension Coordinator: ContentViewDelegate {
func didSelect(_ item: Item) -> AnyView {
AnyView(Text("Returned Destination1"))
}
}
This has so far worked nicely in my apps. I hope it helps.
I will try to answer your points one by one. I will follow a little example where our View that should be reusable is a simple View
that shows a Text
and a NavigationLink
that will go to some Destination
.
I created a Gist: SwiftUI - Flexible Navigation with Coordinators
if you want to have a look at my full example.
The design problem: NavigationLinks are hardcoded into the View.
In your example it is bound to the View but as other answers have already shown, you can inject the destination to your View type struct MyView<Destination: View>: View
. You can use any Type conforming to View as your destination now.
But if the view containing this NavigationLink should be reusable I can not hardcode the destination. There has to be a mechanism which provides the destination.
With the change above, there are mechanisms to provide the type. One example is:
struct BoldTextView: View {
var text: String
var body: some View {
Text(text)
.bold()
}
}
struct NotReusableTextView: View {
var text: String
var body: some View {
VStack {
Text(text)
NavigationLink("Link", destination: BoldTextView(text: text))
}
}
}
will change to
struct ReusableNavigationLinkTextView<Destination: View>: View {
var text: String
var destination: () -> Destination
var body: some View {
VStack {
Text(text)
NavigationLink("Link", destination: self.destination())
}
}
}
and you can pass in your destination like this:
struct BoldNavigationLink: View {
let text = "Text"
var body: some View {
ReusableNavigationLinkTextView(
text: self.text,
destination: { BoldTextView(text: self.text) }
)
}
}
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....
Well, obviously you need some kind of logic that will determine your Destination
. At some point you need to tell the view what view comes next. I guess what you're trying to avoid is this:
struct NestedMainView: View {
@State var text: String
var body: some View {
ReusableNavigationLinkTextView(
text: self.text,
destination: {
ReusableNavigationLinkTextView(
text: self.text,
destination: {
BoldTextView(text: self.text)
}
)
}
)
}
}
I put together a simple example that uses Coordinator
s to pass around dependencies and to create the views. There is a protocol for the Coordinator and you can implement specific use cases based on that.
protocol ReusableNavigationLinkTextViewCoordinator {
associatedtype Destination: View
var destination: () -> Destination { get }
func createView() -> ReusableNavigationLinkTextView<Destination>
}
Now we can create a specific Coordinator that will show the BoldTextView
when clicking on the NavigationLink
.
struct ReusableNavigationLinkShowBoldViewCoordinator: ReusableNavigationLinkTextViewCoordinator {
@Binding var text: String
var destination: () -> BoldTextView {
{ return BoldTextView(text: self.text) }
}
func createView() -> ReusableNavigationLinkTextView<Destination> {
return ReusableNavigationLinkTextView(text: self.text, destination: self.destination)
}
}
If you want, you can also use the Coordinator
to implement custom logic that determines the destination of your view. The following Coordinator shows the ItalicTextView
after four clicks on the link.
struct ItalicTextView: View {
var text: String
var body: some View {
Text(text)
.italic()
}
}
struct ShowNavigationLinkUntilNumberGreaterFourThenItalicViewCoordinator: ReusableNavigationLinkTextViewCoordinator {
@Binding var text: String
let number: Int
private var isNumberGreaterThan4: Bool {
return number > 4
}
var destination: () -> AnyView {
{
if self.isNumberGreaterThan4 {
let coordinator = ItalicTextViewCoordinator(text: self.text)
return AnyView(
coordinator.createView()
)
} else {
let coordinator = ShowNavigationLinkUntilNumberGreaterFourThenItalicViewCoordinator(
text: self.$text,
number: self.number + 1
)
return AnyView(coordinator.createView())
}
}
}
func createView() -> ReusableNavigationLinkTextView<AnyView> {
return ReusableNavigationLinkTextView(text: self.text, destination: self.destination)
}
}
If you have data that needs to be passed around, create another Coordinator around the other coordinator to hold the value. In this example I have a TextField
-> EmptyView
-> Text
where the value from the TextField should be passed to the Text.
The EmptyView
must not have this information.
struct TextFieldView<Destination: View>: View {
@Binding var text: String
var destination: () -> Destination
var body: some View {
VStack {
TextField("Text", text: self.$text)
NavigationLink("Next", destination: self.destination())
}
}
}
struct EmptyNavigationLinkView<Destination: View>: View {
var destination: () -> Destination
var body: some View {
NavigationLink("Next", destination: self.destination())
}
}
This is the coordinator that creates views by calling other coordinators (or creates the views itself). It passes the value from TextField
to Text
and the EmptyView
doesn't know about this.
struct TextFieldEmptyReusableViewCoordinator {
@Binding var text: String
func createView() -> some View {
let reusableViewBoldCoordinator = ReusableNavigationLinkShowBoldViewCoordinator(text: self.$text)
let reusableView = reusableViewBoldCoordinator.createView()
let emptyView = EmptyNavigationLinkView(destination: { reusableView })
let textField = TextFieldView(text: self.$text, destination: { emptyView })
return textField
}
}
To wrap it all up, you can also create a MainView
that has some logic that decides what View
/ Coordinator
should be used.
struct MainView: View {
@State var text = "Main"
var body: some View {
NavigationView {
VStack(spacing: 32) {
NavigationLink("Bold", destination: self.reuseThenBoldChild())
NavigationLink("Reuse then Italic", destination: self.reuseThenItalicChild())
NavigationLink("Greater Four", destination: self.numberGreaterFourChild())
NavigationLink("Text Field", destination: self.textField())
}
}
}
func reuseThenBoldChild() -> some View {
let coordinator = ReusableNavigationLinkShowBoldViewCoordinator(text: self.$text)
return coordinator.createView()
}
func reuseThenItalicChild() -> some View {
let coordinator = ReusableNavigationLinkShowItalicViewCoordinator(text: self.$text)
return coordinator.createView()
}
func numberGreaterFourChild() -> some View {
let coordinator = ShowNavigationLinkUntilNumberGreaterFourThenItalicViewCoordinator(text: self.$text, number: 1)
return coordinator.createView()
}
func textField() -> some View {
let coordinator = TextFieldEmptyReusableViewCoordinator(text: self.$text)
return coordinator.createView()
}
}
I know that I could also create a Coordinator
protocol and some base methods, but I wanted to show a simple example on how to work with them.
By the way, this is very similar to the way that I used Coordinator
in Swift UIKit
apps.
If you have any questions, feedback or things to improve it, let me know.