Give your simulator superpowers

Give your Xcode
Simulator extra features

Using NavigationLink programmatically based on binding in SwiftUI

NavigationLink in SwiftUI allows pushing a new destination view on a navigation controller. You can use NavigationLink in a list or decide to push a view programmatically. The latter enables you to trigger a new screen from a different location in your view.

Allowing to push a new screen onto a navigation stack is as simple as wrapping your SwiftUI views into a NavigationLink wrapper. As long as you contain your views in a navigation view, you’ll be able to push new destination views. Though, when working in dynamic lists, you might encounter views popping unexpectedly back to their root.

While watching the WWDC 2022 session, The SwiftUI cookbook for navigation, I got inspired to write an extension that works in SwiftUI for iOS 15 and up. It’s inspired by the new navigation stack APIs and demonstrates how creativity in Swift & SwiftUI can lead to valuable extensions for your apps.

Let me describe how you can push a new view from a dynamic list. It’s a technique I used in my Stock Analyzer App, commonly used when building apps with SwiftUI. The basic code for a similar application looks as follows:

import SwiftUI

@main
struct ListNavigationLinkApp: App {
    var body: some Scene {
        WindowGroup {
            FavoritesView()
        }
    }
}

struct FavoritesView: View {

    @ObservedObject var favoritesStore: FavoritesStore = .standard

    var body: some View {
        NavigationView {
            List(favoritesStore.favorites, id: \.self) { favorite in
                NavigationLink(favorite) {
                    FavoriteDetailView(favorite: favorite)
                }
            }.navigationTitle("My Favorites")
        }
    }
}

struct FavoriteDetailView: View {

    let favorite: String

    var body: some View {
        VStack {
            Text("Opened favorite:")
            Text(favorite)
            Button("Remove from favorites") {
                FavoritesStore.standard.remove(favorite)
            }
        }
    }
}

The list makes use of a favorites store which for this article is a shared instance containing a collection of strings:

final class FavoritesStore: ObservableObject {
    static let standard = FavoritesStore()

    @Published var favorites: [String] = ["Swift", "SwiftUI", "UIKit"]

    func add(_ favorite: String) {
        favorites.append(favorite)
    }

    func remove(_ favorite: String) {
        favorites.removeAll(where: { $0 == favorite })
    }
}

If you run the above application, you might think everything works just fine. You can tap on an item that will trigger a favorite detail view. However, as soon as you press “Remove from favorites,” you’ll notice that the favorites detail view is dismissed:

A NavigationLink inside a dynamic list will pop to root once the item gets deleted.

The view pops back to its root since the trigger item inside the dynamic list no longer belongs to the favorite items. While you expect this behavior, it might not be what you’re looking for in your application.

In my case for Stock Analyzer, I needed the detail view to stay visible so users could re-add an item back to their favorites. To implement this behavior, I had a look at the isActive-based NavigationLink API that is deprecated in favor of the new navigation APIs. The NavigationLink is triggered whenever the isActive binding returns true. While I couldn’t wholly reproduce the new iOS 16 APIs, I aimed to recreate the following view modifier:

.navigationDestination(for: Color.self) { color in
    ColorDetail(color: color)
}

This new API is only available on iOS 16 and up, which means we have to wait a little longer before adopting this in our apps. Unlike other new APIs, navigation-related APIs are harder to adopt in phases. It requires rewriting the foundational structure of your app, so it was clear to me that there’s value in coming up with a solution that works today.

The above API pushes a new view based on an input value. By using the type identifier Color.self, it becomes generic and useful for any Color that is pushed by a NavigationLink. While this is flexible, it was slightly too generic for my case. Instead, I would be satisfied enough to push a new view whenever an optional binding update to a wrapped value.

The outcome solution is an API that allows pushing a new view based on an optional binding. A new favorite detail view will push whenever the binding updates to a favorite. The outcome code looks as follows:

struct FavoritesProgrammaticallyView: View {

    @ObservedObject var favoritesStore: FavoritesStore = .standard

    /// Store the favorite that has to be shown inside a detail view.
    @State var selectedFavorite: String?

    var body: some View {
        NavigationView {
            List(favoritesStore.favorites, id: \.self) { favorite in
                Button(favorite) {

                    /// Update `selectedFavorite` on tap.
                    selectedFavorite = favorite
                }.tint(Color.primary)
            }.navigationTitle("My Favorites")

                /// Whenever `selectedFavorite` is set, a new `FavoriteDetailView` is pushed.
                .navigationDestination(for: $selectedFavorite) { favorite in
                    FavoriteDetailView(favorite: favorite)
                }
        }
    }
}

The above view lets you programmatically push a view onto the navigation stack. Let’s go over the applied changes:

  • A new state property selectedFavorite is added to store the favorite to show in a detailed view
  • We replaced the NavigationLink inside the list with a button that updates the selected favorite
  • The new navigation destination view modifier takes care of pushing Favorite detail views onto the screen

Our view redraws whenever selectedFavorite becomes a non-optional view. The view modifier triggers since it will find a wrapped favorite value, and we can push the favorite detail view onto our stack.

Defining a new navigation stack modifier

Let’s have a look at the new view modifier we’ve created:

struct NavigationStackModifier<Item, Destination: View>: ViewModifier {
    let item: Binding<Item?>
    let destination: (Item) -> Destination

    func body(content: Content) -> some View {
        content.background(NavigationLink(isActive: item.mappedToBool()) {
            if let item = item.wrappedValue {
                destination(item)
            } else {
                EmptyView()
            }
        } label: {
            EmptyView()
        })
    }
}

public extension View {
    func navigationDestination<Item, Destination: View>(
        for binding: Binding<Item?>,
        @ViewBuilder destination: @escaping (Item) -> Destination
    ) -> some View {
        self.modifier(NavigationStackModifier(item: binding, destination: destination))
    }
}

A lot is going on in this code, so let’s go over it one by one:

  • The NavigationStackModifier requires to be set up with an optional item provided by a binding and a destination @ViewBuilder to create the destination for the wrapped value
  • The NavigationLink added as a background view modifier allows pushing a view while not taking any visual space
  • We’re making use of the isActive navigation variant that requires us to convert the binding into a boolean binding
  • For convenience, we’ve created a view extension to make use of the new view modifier easily

Converting a Binding<Wrapped?> to an optional is common practice when working with bindings in SwiftUI. Such conversion can be helpful in cases you want to observe a binding value based on the boolean outcome. The code for converting to boolean looks as follows:

public extension Binding where Value == Bool {
    init<Wrapped>(bindingOptional: Binding<Wrapped?>) {
        self.init(
            get: {
                bindingOptional.wrappedValue != nil
            },
            set: { newValue in
                guard newValue == false else { return }

                /// We only handle `false` booleans to set our optional to `nil`
                /// as we can't handle `true` for restoring the previous value.
                bindingOptional.wrappedValue = nil
            }
        )
    }
}

extension Binding {
    public func mappedToBool<Wrapped>() -> Binding<Bool> where Value == Wrapped? {
        return Binding<Bool>(bindingOptional: self)
    }
}

The outcome allows you to open a detail view and adjust the dynamic list without popping the view:

Our view no longer pops due to the binding-based NavigationLink implementation.

Conclusion

Being creative with NavigationLink in SwiftUI allows you to dynamically push new views onto the navigation stack based on optional bindings. The issue of views popping when a dynamic list changes can be solved using the technique described in this article. Converting an optional-containing binding to a boolean outcome allows you to use the existing NavigationLink APIs creatively.

If you like to improve your SwiftUI knowledge, check out the SwiftUI category page. Feel free to contact me or tweet me on Twitter if you have any additional tips or feedback.

Thanks!

 

Featured SwiftLee Jobs

Loading RSS Feed

Browse more Swift related Jobs, or add your own on SwiftLee Jobs