The future of SwiftUI navigation (?)

For a great overview on SwiftUI navigation, please check out this article by Paul Hudson.

As it stands today, SwiftUI presents some limitations when dealing with NavigationLinks.

In Swift protocols in SwiftUI we've covered how SwiftUI uses Identifiable to manage sheets, alerts, and other views presentation, while Hashable is used for navigation:
this is the first limiting factor as, out of the box, it makes it hard to programmatically trigger a navigation while also passing dynamic data to the destination view.

The second limiting factor is that NavigationLinks are buttons in disguise: their declaration requires a view of some kind, which will then be part of the view hierarchy.

In this article, let's try to overcome both these limitations.

Now you're here, now you're gone

NavigationLinks behave as triggers to push and pop views: internally, they observe a boolean binding (which can be exposed externally if needed) to determine when to do so.

If we want to pop back from a pushed view programmatically, the most common way is by setting the NavigationLink's binding to the proper state:

struct ContentView: View {
  @State var showingNavigation: Bool = false

  var body: some View {
    NavigationView {
      NavigationLink(
        "Push view",
        destination: Button("Pop back", action: { showingNavigation = false }),
        isActive: $showingNavigation
      )
    }
  }
}

While this works great, we can also use another hidden trick to obtain the same effect:
entirely remove the NavigationLink from the view hierarchy.

struct ContentView: View {
  @State var showingNavigation: Bool = true

  var body: some View {
    NavigationView {
      VStack {
        if showingNavigation {
          NavigationLink(
            "Go to destination",
            destination: Button("Hide navigation", action: { showingNavigation.toggle() })
          )
        }

        if !showingNavigation {
          Button("Show navigation", action: { showingNavigation.toggle() })
        }
      }
    }
  }
}

In this second case, we use showingNavigation not as a NavigationLink binding, but to decide whether the NavigationLink is part of the view hierarchy.
If we remove the NavigationLink while presenting, it will also pop its destination.

EmptyView

NavigationLinks require a view as their Label, however any view will do, therefore we can "hide" a NavigationLink by passing an EmptyView instance:

NavigationLink(
  destination: ...,
  isActive: ...,
  label: { EmptyView() }
)

By doing so, we're effectively hiding the link from the view hierarchy while still preserving its push/pop trigger effects.

struct ContentView: View {
  @State var showingNavigation: Bool = false

  var body: some View {
    NavigationView {
      VStack {
        NavigationLink(
          destination: Button("Pop back", action: { showingNavigation = false }),
          isActive: $showingNavigation,
          label: { EmptyView() }
        )

        Button("Push view", action: { showingNavigation = true })
      }
    }
  }
}

In this example, we're using a couple of buttons to trigger the navigation push and pop, but the same could have been done via any other logic (for example, after fetching something from the web).

While NavigationLink is still part of the view hierarchy definition, it's now hidden and disconnected from the rest of the UI.

Identifiable x NavigationLink

Let's recap what we've covered so far:

  • if a presenting NavigationLink is removed from the view hierarchy, its destination will pop back
  • using EmptyView as NavigationLink's Label will hide the view from the UI, while preserving its push/pop triggers

We would like to have a NavigationLink with an Identifiable binding instead of a Bool or Hashable one.

This aligns the navigation push/pop with the rest of SwiftUI view presentations (covered in the Identifiable chapter here), enabling us to pass data between different views easily.

While SwiftUI does not offer this out of the box, as we've covered in Hashable SwiftUI bindings, nobody stops us from creating a new extension.

We start with the following:

extension NavigationLink {
  public init<V: Identifiable>(
    item: Binding<V?>,
    destination: ...
  ) {
    ...
  }
}

Let's imagine to have an Identifiable type defined as following:

enum ContentViewNavigation: Identifiable {
  case one
  case two(number: Int)
  case three(text: String)

  // MARK: Identifiable

  var id: Int {
    switch self {
    case .one:
      return 1
    case .two:
      return 2
    case .three:
      return 3
    }
  }
}

This "navigation" type has three possible destinations, where some of them (case .two and .tree) will also pass dynamic data to the destination view.

Our NavigationLink extension would need to generate a different destination for each possible Identifiable instance, therefore let's ask for a view builder function as our destination parameter:

extension NavigationLink {
  public init<V: Identifiable>(
    item: Binding<V?>,
    destination: @escaping (V) -> Destination
  ) {
    ...
  }
}

Thanks to this API we no longer need to declare a separate NavigationLink for each destination. Therefore let's hide this NavigationLink from the UI by declaring its Label as an EmptyView:

extension NavigationLink where Label == EmptyView {
  public init<V: Identifiable>(
    item: Binding<V?>,
    destination: @escaping (V) -> Destination
  ) {
    ...
  }
}

This NavigationLink will generate a view with the correct destination only when the given binding is not nil: when the binding is nil, the initialization will fail.

To make this happen our extension will implement a failable initializer (note the ? in the init):

extension NavigationLink where Label == EmptyView {
  public init?<V: Identifiable>(
    item: Binding<V?>,
    destination: @escaping (V) -> Destination
  ) {
     ...
  }
}

Now our NavigationLink will only be generated when the binding has a value (and a view is pushed): as soon as the binding is set to nil the NavigationLink will be removed from the view hierarchy, triggering a navigation pop.

extension NavigationLink where Label == EmptyView {
  public init?<V: Identifiable>(
    item: Binding<V?>,
    destination: @escaping (V) -> Destination
  ) {
    if let value = item.wrappedValue {
      self.init(...)
    } else {
      return nil
    }
  }
}

Lastly, we need to pass a binding to the initializer within our extension, let's make one that the official NavigationLink API accepts:

extension NavigationLink where Label == EmptyView {
  public init?<V: Identifiable>(
    item: Binding<V?>,
    destination: @escaping (V) -> Destination
  ) {
    if let value = item.wrappedValue {
      let isActive: Binding<Bool> = Binding(
        get: { item.wrappedValue != nil },
        set: { value in
          // There's shouldn't be a way for SwiftUI to set `true` here.
          if !value {
            item.wrappedValue = nil
          }
        }
      )

      self.init(
        destination: destination(value),
        isActive: isActive,
        label: { EmptyView() }
      )
    } else {
      return nil
    }
  }
}

And with this, our extension is complete! Here's how we can use it:

struct ContentView: View {
  @State private var showingNavigation: ContentViewNavigation?

  var body: some View {
    NavigationView {
      VStack {
        NavigationLink(item: $showingNavigation, destination: presentNavigation)

        Button("Go to navigation one") {
          showingNavigation = .one
        }
        Button("Go to navigation two") {
          showingNavigation = .two(number: Int.random(in: 1...5))
        }
        Button("Go to navigation three") {
          showingNavigation = .three(text: ["five", "stars"].randomElement()!)
        }
      }
    }
  }

  @ViewBuilder
  func presentNavigation(_ navigation: ContentViewNavigation) -> some View {
    switch navigation {
    case .one:
      Text(verbatim: "one")
    case .two(let number):
      Text("two \(number)")
    case .three(let text):
      Text("three \(text)")
    }
  }
}

What NavigationLink?

Thanks to our extension, it's now simpler to pass data to any destination view while maintaining clarity in our view definition.

Compared to other SwiftUI presentations, our solution is still missing the complete removal of its definition from the view hierarchy. While this is currently not possible, we can undoubtedly hide it nicely.

Looking at our previous example, the navigation link is still within our VStack:

struct ContentView: View {
  ...

  var body: some View {
    NavigationView {
      VStack {
        NavigationLink(item: $showingNavigation, destination: presentNavigation)

        Button(...) { ... }
        Button(...) { ... }
        Button(...) { ... }
      }
    }
  }
  ...
}

However, a NavigationLink can be placed anywhere. For example, we can move it to the background, and everything would still work fine:

struct ContentView: View {
  ...

  var body: some View {
    NavigationView {
      VStack {
        Button(...) { ... }
        Button(...) { ... }
        Button(...) { ... }
      }
      .background(
        NavigationLink(item: $showingNavigation, destination: presentNavigation)
      )
    }
  }

  ...
}

As this would probably be the ideal way to use our NavigationLink extension, we can define a helper View function that enables us to so:

extension View {
  func navigation<V: Identifiable, Destination: View>(
    item: Binding<V?>,
    destination: @escaping (V) -> Destination
  ) -> some View {
    background(NavigationLink(item: item, destination: destination))
  }
}

Making our final API look very similar to other SwiftUI presentations (for sheets, alerts, etc.):

struct ContentView: View {
  ...

  var body: some View {
    NavigationView {
      VStack {
        Button(...) { ... }
        Button(...) { ... }
        Button(...) { ... }
      }
      .navigation(item: $showingNavigation, destination: presentNavigation)
    }
  }

  ...
}

The complete working project can be found here.

Conclusions

The extensions introduced here probably won't cover 100% of the use cases. Still, I'd argue that a similar official API would be very welcome and would solve most of the implementation issues we face today.

We've seen no change on SwiftUI's navigation APIs at this year's WWDC. However, I wouldn't be surprised if something new would pop up in a new Xcode 11.4-like release, or perhaps at the next WWDC. In the meanwhile, we can always try and solve these challenges ourselves!

Did you face or are you facing any SwiftUI challenges? How did you solve it? Please let me know!

Thank you for reading, and stay tuned for more articles!

⭑⭑⭑⭑⭑

Further Reading

Explore SwiftUI

Browse all