Bringing the simplicity of NavigationLink to popovers

In this article we explore the possibility to extend SwiftUIs APIs to make the common use case of presenting a popover easier and in line of NavigationLink.

I love the simple design of the NavigationLink view in SwiftUI. Only specifying the destination view and a label gets you fully functional navigation in the hosting NavigationView.

NavigationLink(destination: Text("This is the subview")) {
    Text("Go to subview")
}

But pushing a new view to the navigation stack is not the only interesting way to present new views. Modal sheets or popovers also have their use cases! Unfortunately the API for sheets and popovers is a little less straightforward to use, as it involves introducing a state variable and updating it from e.g. a button:

struct ContentView: View {
    @State var isShowingPopover = false
    
    var body: some View {
        Button {
            isShowingPopover = true
        } label: {
            Text("Go to subview")
        }
        .popover(isPresented: $isShowingPopover) {
            Text("Go to subview")
        }
    }
}

While this is still relatively concise and exposes access to dismissing the popover, it still requires introducing state even though this might not be needed.

So I asked myself: Is it possible to achieve the same API as NavigationLink but for popovers?
It turns out the answer is yes, and the solution is remarkably clean and easy to implement.

We start by implementing our own view that leans on the API of NavigationLink:

struct PopoverLink<Label, Destination> : View where Label : View, Destination : View {
    /// Creates an instance that presents `destination`.
    public init(destination: Destination, @ViewBuilder label: () -> Label)

    /// Creates an instance that presents `destination` when active.
    public init(destination: Destination, isActive: Binding<Bool>, @ViewBuilder label: () -> Label)
}

This interface is adapted from NavigationLink (but leaves out one initializer that I never used). Now to implementing this interface.
First we have to introduce variables for destination, label and isActive and assign these in the initializers. We also have to introduce an internal state variable that is used if the initializer without specific binding is used.

struct PopoverLink<Label, Destination> : View where Label : View, Destination : View {
    private let destination: Destination
    private let label: Label
    private var isActive: Binding<Bool>?
    @State private var internalIsActive = false

	/// Creates an instance that presents `destination`.
	public init(destination: Destination, @ViewBuilder label: () -> Label) {
        self.destination = destination
        self.label = label()
    }

    /// Creates an instance that presents `destination` when active.
    public init(destination: Destination, isActive: Binding<Bool>, @ViewBuilder label: () -> Label) {
        self.destination = destination
        self.label = label()
        self.isActive = isActive
    }
}

Last but not least we have to implement the body of our view. Here we can lean on our previous implementation of a popover and essentially do the same thing in the body of our view:

var body: some View {
    Button {
        (isActive ?? _internalIsActive.projectedValue).wrappedValue = true
    } label: {
        label
    }.popover(isPresented: (isActive ?? _internalIsActive.projectedValue)) {
        destination
    }
}

Note the use of optional chaining to pick the right binding depending on which initializer was used!

Finally we can fix the popover animation bug and present a sheet instead on compact layouts:

@Environment(\.horizontalSizeClass) var horizontalSizeClass

private func popoverButton() -> some View {
    Button {
        (isActive ?? _internalIsActive.projectedValue).wrappedValue = true
    } label: {
        label
    }
}

public var body: some View {
    if horizontalSizeClass == .compact {
        popoverButton().sheet(isPresented: (isActive ?? _internalIsActive.projectedValue)) {
            destination
        }
    } else {
        popoverButton().popover(isPresented: (isActive ?? _internalIsActive.projectedValue)) {
            destination
        }
    }
}

With just a little code we introduced our own PopoverLink implementation that allows us easily present modal popovers in SwiftUI using easy and familiar syntax from NavigationLink:

PopoverLink(destination: Text("This is the subview")) {
    Text("Go to subview")
}

This article shows how easy it is to introduce own views to SwiftUI to simplify common UI use cases and to improve on the existing SwiftUI library.

Below you can find the full source code for PopoverLink.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.