Reading time: 6 min

This recipe shows how to add easy-to-use segues to your SwiftUI navigation. They allow for presenting views using common UIKIt Segue types - push, modal and popover.

The end result looks like this:

preview

Navigating between views/screens in SwiftUI is more difficult and convoluted than it is in UIKit, with different segues dispersed over multiple views (e.g, NavigationLink) and modifiers (popover, fullScreenCover). Moreover, part of this functionality isn't available on iOS 13.

We'll create two ViewModifiers that allow for seamless integration of segues, fully compatible with iOS 13 and above. The segues will be triggered by setting binding values, and can be dismissed by setting the value to nil.

This component is available as a Swift Package in this repo.

Recipe

First, define the possible segue types in an enum:

public enum SegueType {
  case push,
       modal,
       popover(PopoverAttachmentAnchor, Edge)
}

A good SwiftUI navigation practice is to define all routes, i.e transitions from the current view to subsequent ones, in an enum and then use a @State var to your view (or @Published var in your VM) whose value is an optional enum route. This is consistent with the tag/selection and item variants of NavigationLink / Popover / FullScreenCover. Assigning a value to the route binding will trigger a segue, and assigning it nil will dismiss it.

We'll use this approach to define the segue view modifier:

public struct Segue<Destination, Selection>: ViewModifier
where Destination : View, Selection : Hashable {
  let type: SegueType
  // when selection is set to this value, trigger the segue
  let tag: Selection 
  // trigger binding
  @Binding var selection: Selection? 
  // view shown then segue is triggered
  @ViewBuilder let destination: () -> Destination 

  public init(type: SegueType,
              tag: Selection,
              selection: Binding<Selection?>,
              @ViewBuilder destination: @escaping () -> Destination) {
    self.type = type
    self.tag = tag
    _selection = selection
    self.destination = destination
  }

  public func body(content: Content) -> some View {
    // build the segue based on its type
    switch type {
    case .push:
      pushSegue(content)
    case .modal:
      modalSegue(content)
    case let .popover(anchor, arrowEdge):
      popoverSegue(content, anchor: anchor, arrowEdge: arrowEdge)
    }
  }

  @ViewBuilder private func pushSegue(_ content: Content) -> some View {
    // a push segue requires a NavigationLink
    // hint: using a Group instead of ZStack doesn't work
    ZStack {
      content
      NavigationLink(tag: tag,
                     selection: $selection,
                     destination: destination) {
        EmptyView()
      }
    }
  }

  @ViewBuilder private func modalSegue(_ content: Content) -> some View {
    // a modal segue is a fullScreenCover
    content
      .fullScreenCover(isPresented: Binding(get: {
        selection == tag
      }, set: { _ in
        selection = nil
      }),
                       onDismiss: nil,
                    content: destination)
  }

  @ViewBuilder private func popoverSegue(_ content: Content,
                                         anchor: PopoverAttachmentAnchor,
                                         arrowEdge: Edge) -> some View {
    // a popover segue triggers, well, a popover
    content
      .popover(isPresented: Binding(get: {
        selection == tag
      }, set: { _ in
        selection = nil
      }),
               attachmentAnchor: anchor,
               arrowEdge: arrowEdge,
               content: destination)
  }
}

fullScreenCover is only available starting from SwiftUI 2 (iOS 14), but you can check out this recipe to see how to make it work on iOS 13. This is also the approach used by the SwiftUISegues package.

Lastly, add this helpful extension:

public extension View {
  func segue<Destination, Selection>(_ type: SegueType,
                                     tag: Selection,
                                     selection: Binding<Selection?>,
                                     @ViewBuilder destination: @escaping () -> Destination) -> some View
  where Destination : View, Selection : Hashable {
    self.modifier(Segue(type: type,
                        tag: tag,
                        selection: selection,
                        destination: destination))
  }
}

After this, adding segues becomes dead-easy Here's some sample code that demonstrates how it's done:

struct MixedSegueTest: View {
  // All the routes that lead from this view to the next ones
  enum Route: Hashable {
    case pushTest, modalTest, popoverTest
  }

  // Triggers segues when its values are changes
  @State private var route: Route? = nil

  var body: some View {
    NavigationView {
      VStack(spacing: 20) {
        Button("Push") {
          // Navigate by setting route values
          route = .pushTest
        }
        Button("Modal") {
          route = .modalTest
        }
        Button("Popover") {
          route = .popoverTest
        }
      }
      .navigationBarTitle("SwiftUI Segues", displayMode: .inline)

      // Here are individual, mixed segues, with their destinations
      .segue(.push, tag: .pushTest, selection: $route) {
        Text("Welcome to Push")
      }
      .segue(.modal, tag: .modalTest, selection: $route) {
        Button("Welcome to modal") {
          route = nil
        }
      }
      .segue(.popover(.rect(.bounds), .top), tag: .popoverTest, selection: $route) {
        Text("Welcome to Popover")
      }
    }
  }
}
Simpler handling of segues of the same type

Most of the time, segues leading from a single view will likely be of the same type. This, combined with the fact that SwiftUI's notorious for not liking stacked popovers and fullScreenCovers, prompts us to create another variant which handles multiple segues of the same type under a single modifier. It looks more-or less like the Segue modifier above, except it uses a few tricks to allow for multiple destinations on a single modifier:

public struct Segues<Destination, Selection>: ViewModifier
where Destination : View, Selection : Identifiable, Selection : CaseIterable, Selection : Hashable {
  let type: SegueType
  @Binding var selection: Selection?
  @ViewBuilder let destination: (Selection) -> Destination

  public init(type: SegueType,
             selection: Binding<Selection?>,
             @ViewBuilder destination: @escaping (Selection) -> Destination) {
    self.type = type
    _selection = selection
    self.destination = destination
  }

  public func body(content: Content) -> some View {
    switch type {
    case .push:
      pushSegue(content)
    case .modal:
      modalSegue(content)
    case let .popover(anchor, arrowEdge):
      popoverSegue(content, anchor: anchor, arrowEdge: arrowEdge)
    }
  }

  @ViewBuilder private func pushSegue(_ content: Content) -> some View {
    ZStack {
      content
      ForEach(Array(Selection.allCases)) { tag in
        NavigationLink(tag: tag,
                       selection: $selection,
                       destination: { destination(tag) }) {
          EmptyView()
        }
      }
    }
  }

  @ViewBuilder private func modalSegue(_ content: Content) -> some View {
    content
      .fullScreenCover(item: $selection,
                       onDismiss: nil,
                       content: destination)
  }

  @ViewBuilder private func popoverSegue(_ content: Content,
                                         anchor: PopoverAttachmentAnchor,
                                         arrowEdge: Edge) -> some View {
    content
      .popover(item: $selection,
               attachmentAnchor: anchor,
               arrowEdge: arrowEdge,
               content: destination)
  }
}

public extension View {
  func segues<Destination, Selection>(_ type: SegueType,
                                      selection: Binding<Selection?>,
                                      @ViewBuilder destination: @escaping (Selection) -> Destination) -> some View
  where Destination : View, Selection : Identifiable, Selection: CaseIterable, Selection : Hashable {
    self.modifier(Segues(type: type,
                         selection: selection,
                         destination: destination))
  }
}

Then, you can put the segues modifier to use like this:

struct PushSegueTest: View {
  @State private var route: Route? = nil

  var body: some View {
    NavigationView {
      VStack {
        Button("Go to A") {
          route = .a
        }
        Button("Go to B") {
          route = .b
        }
        Button("Go to C") {
          route = .c
        }
      }
      .segues(.push, selection: $route) { route in
        switch route {
        case .a:
          Text("A")
        case .b:
          Text("B")
        case .c:
          Text("C")
        }
      }
    }
  }

  enum Route: Identifiable, CaseIterable, Hashable {
    case a, b, c

    var id: String {
      "\(self)"
    }
  }
}

struct ModalSegueTest: View {
  @State private var route: Route? = nil

  var body: some View {
    VStack {
      Button("Go to A") {
        route = .a
      }
      Button("Go to B") {
        route = .b
      }
      Button("Go to C") {
        route = .c
      }
    }
    .segues(.modal, selection: $route) { route in
      switch route {
      case .a:
        Button("A") {
          self.route = nil // dismissed the segue
        }
      case .b:
        Button("B") {
          self.route = nil
        }
      case .c:
        Button("C") {
          self.route = nil
        }
      }
    }
  }

  enum Route: Identifiable, CaseIterable, Hashable {
    case a, b, c

    var id: String {
      "\(self)"
    }
  }
}

Next Post Previous Post