NavigationView hacks for SwiftUI

There’s a couple ways to programmatically navigate with SwiftUI’s NavigationView, but they usually require you to still have a NavigationLink somewhere. NavigationLink is a bit inflexible when it comes to styling, so a workaround I’ve usually seen is to zero out the frame, maybe set opacity to 0, and maybe set .disabled(true):

struct ContentView: View {
  @State private var isVisiting = false

  var body: some View {
    NavigationView {
      VStack {
        NavigationLink(
          destination: Text("hello world"),
          isActive: $isVisiting
        ) {
          EmptyView()
        }
        .frame(width: 0, height: 0)
        .opacity(0)
        .disabled(true)
        Text("Visit").onTapGesture {
          self.isVisiting = true
        }
      }
    }
  }
}

Rather than having to duplicate these styles everywhere, I extracted a component called NavigationTarget:

struct NavigationTarget<Content: View>: View {
  var destination: Content
  @Binding var isActive: Bool

  var body: some View {
    NavigationLink(
      destination: destination,
      isActive: $isActive
    ) {
      EmptyView()
    }
    .frame(width: 0, height: 0)
    .opacity(0)
    .disabled(true)
  }
}

And it’s used like so:

struct ContentView: View {
  @State private var isVisiting = false

  var body: some View {
    NavigationView {
      VStack {
        NavigationTarget(
          destination: Text("hello world"),
          isActive: $isVisiting
        )
        Text("Visit").onTapGesture {
          self.isVisiting = true
        }
      }
    }
  }
}

That’s a bit nicer, but it’s still a bit of a bummer to have to litter views with these components that don’t actually show up in the UI. We also need to keep a @State variable for each NavigationTarget in our view.

It’d be nicer if we could just add a tapNavigatesTo` modifier so that tapping a view would navigate somewhere.

We can do that:

private struct TapNavigatesTo<DestinationType: View>: ViewModifier {
  @State private var isActive = false
  var destination: DestinationType

  func body(content: Content) -> some View {
    content
      .highPriorityGesture(TapGesture().onEnded {
        self.isActive = true
      })
      .overlay {
        NavigationTarget(
          destination: destination,
          isActive: $isActive
        )
      }
  }
}

extension View {
  // Wrapper around NavigationTarget that navigates to destination here when you tap the view
  func tapNavigatesTo<DestinationType: View>(destination: DestinationType) -> some View {
    modifier(TapNavigatesTo(destination: destination))
  }
}

Note that .overlay is only available as of iOS 15.

Now our view just looks like this:

struct ContentView: View {
  var body: some View {
    NavigationView {
      Text("Visit").tapNavigatesTo(destination: Text("hello world"))
    }
  }
}

Hi, I’m Pat. I'm writing about some stuff I figured out or thought was useful, but I am no expert on iOS development. Hopefully something here was useful for you but if I’m completely wrong, my bad.

· swiftui, swift