Skip to content

Instantly share code, notes, and snippets.

@mbrandonw
Last active June 11, 2024 07:32
Show Gist options
  • Save mbrandonw/f8b94957031160336cac6898a919cbb7 to your computer and use it in GitHub Desktop.
Save mbrandonw/f8b94957031160336cac6898a919cbb7 to your computer and use it in GitHub Desktop.
iOS 16 Navigation API feedbacks

How to execute logic when NavigationLink is tapped?

FB10144005

Currently it doesn't seem possible to execute additional logic when a navigation link is tapped with the new NavigationLink(value:) initializer. When the link is tapped it updates path state all the way back at the root NavigationStack to drive navigation, but there are many times where we need to perform logic after the tap and before the drill down.

For example, after tapping a link we may want to pre-emptively load some data to show on the drill down screen. Or we may want to perform some form validation. Or we may want to track some analytics. This does not seem possible with the current link API.

A workaround is to use Buttons instead of NavigationLinks, but then you lose all of the styling and affordances given to links, such as chevrons when used in List.

If the API for NavigationLink cannot be changed to accomodate for this, perhaps a new ButtonStyle could be introduced that allows regular buttons to take on the style and behavior navigation links get:

Button("Login") {
  viewModel.loginButtonTapped()
}
.buttonStyle(.navigationLink)

This would allow you to layer additional logic onto navigation using plain buttons without losing any of the styling.

Ability to skip recognizing a navigationDestination

FB10234186

Currently once a .navigationDestination is recognized it will be fully responsible for constructing the view that is navigated too. Sometimes we may want to handle only a subset of the data that is being routed to. Say, in a child view we handle even integers of some route:

// In a child view
.navigationDestination(for: Int.self) { integer in 
  if integer.isMultiple(of: 2) {
    EvenView(integer: integer)
  } else {
    // How to allow the parent to try to recognize this data?
  }
}

And then in the parent view we handle odd integers:

// In a parent view
.navigationDestination(for: Int.self) { integer in 
  if !integer.isMultiple(of: 2) {
    OddView(integer: integer)
  } else {
    // How to allow the parent to try to recognize this data?
  }
}

Currently the child view has no way of saying "I do not handle this data" so that a parent .navigationDestination can try.

This feature would be somewhat similar to Javascript's event bubbling features, which allow you to control how events are passed up the DOM.

Expose more collection interface on NavigationPath

FB10395052

Currently NavigationPath is completely opaque to the user beyond adding, removing and counting elements. Sometimes it can be handy to inspect the values in the current stack to know what is presented.

This can be handy even if the elements are only exposed as any Hashable since we can cast it to a concrete type.

As an example, suppose you wanted to provide a breadcrumb API to the user so that they know what all is in the navigation stack. If we could iterate over the path then we could build up the breadcrumbs simply with:

class Model: ObservableObject {
  @Published var path = NavigationPath()

  var breadcrumbs: [String] {  
    var breadcrumbs: [String] = []
    for element in path {
      if element is User {
        breadcrumbs.append("User")
      } else if element is Collection {
        breadcrumbs.append("Collection")
      } else if ... {
        ...
      }
    }
    return breadcrumbs
  }
}

But currently this doesn't seem possible at all. Even if you use .onChange of in the view to inspect changes to the path you still don't get access to the element that was added.

.sheet(isPresented:) doesn't property dismiss/re-present when sheets swap

FB11167385

Please describe the issue:

If a view defines 2 sheets using .sheet(isPresented:) and swaps 1 sheet for another, the content flips in-place, despite the view modifiers being independent.

Please list the steps you took to reproduce the issue:

  1. Paste the following into a new SwiftUI project over its content view:
import SwiftUI

struct ContentView: View {
  @State var one = false
  @State var two = false

  var body: some View {
    VStack {
      Button("Present 1") { one = true }
    }
    .sheet(isPresented: $one) {
      Button("Swap 1 for 2") { (one, two) = (false, true) }
    }
    .sheet(isPresented: $two) {
      Button("Swap 2 for 1") { (two, one) = (false, true) }
    }
  }
}

struct ContentView_Previews: PreviewProvider {
  static var previews: some View {
    ContentView()
  }
}
  1. Run the preview/app.
  2. Tap “Present 1” and watch sheet go up.
  3. Tap “Swap 1 for 2” and watch the sheet’s content be replaced in-place.

What did you expect to happen?

I expect the first sheet to dismiss and then the second sheet to be presented over it.

Note that this is how the “.sheet(item:)” modifier behaves:

import SwiftUI

extension Int: Identifiable { public var id: Self { self } }

struct ContentView: View {
  @State var one: Int?
  @State var two: Int?

  var body: some View {
    VStack {
      Button("Present 1") { one = 1 }
    }
    .sheet(item: $one) { _ in
      Button("Swap 1 for 2") { (one, two) = (nil, 2) }
    }
    .sheet(item: $two) { _ in
      Button("Swap 2 for 1") { (two, one) = (nil, 1) }
    }
  }
}

struct ContentView_Previews: PreviewProvider {
  static var previews: some View {
    ContentView()
  }
}

The above version of the same code properly dismisses and presents a fresh sheet. It seems that the “isPresented:” version of “sheet” should take its view graph identity into account.

What actually happened?

The sheet’s content is replaced in-place.

No "navigationDestination(item:)" version of "navigationDestination(isPresented:)"

FB11167544

Please describe the issue:

SwiftUI’s sheet modifiers come with 2 versions

  • isPresented: Binding
  • item: Binding<Item: Identifiable>

If you naively use “isPresented” everywhere and try to dismiss one sheet and present another, it will swap out the presented sheet’s content without dismissing and re-presenting. This might be considered a bug, as it happens even if you have 2 separate sheet modifiers (see FB11167385).

“navigationDestination(isPresented:)” has this same issue, where the contents of the pushed screen are replaced in-place, which means that bug needs to be fixed, or we need a version that uses identity, like “navigationDestination(item:)”.

Please list the steps you took to reproduce the issue:

  1. Paste the following over a fresh SwiftUI project’s ContentView.swift:
import SwiftUI

struct ContentView: View {
  @State var one = false
  @State var two = false

  var body: some View {
    NavigationStack {
      VStack {
        Button("Present 1") { one = true }
      }
      .navigationDestination(isPresented: $one) {
        Button("Swap 1 for 2") { (one, two) = (false, true) }
      }
      .navigationDestination(isPresented: $two) {
        Button("Swap 2 for 1") { (two, one) = (false, true) }
      }
    }
  }
}

struct ContentView_Previews: PreviewProvider {
  static var previews: some View {
    ContentView()
  }
}
  1. Run the preview
  2. Tap “Present 1” and drill down
  3. Tap “Swap 1 for 2” and watch the content be replaced in-place

What did you expect to happen?

I expected the first nav destination to be popped before presenting the second destination.

NavigationStack deep linking breaks inside TabView

FB11710323

When a NavigationStack is nested inside a TabView, its “path” binding is written with an empty array upon first render and, if the path contained values, the UI is left in a state that doesn’t match the source of truth.

To see, copy and paste the contents of this description over a fresh SwiftUI project’s app entry point and run in the simulator.

You will start drilled-down multiple layers in navigation, but 2 lines will also immediately print in the console showing that the deep-linked path we start with is immediately cleared out. We will remain drilled-down, so the state of the model does not reflect the state of the UI. This can lead to crashes if you try to interact with the path by removing items that don’t exist.

If you remove the TabView from the view hierarchy, things work just fine.

import SwiftUI

class MyModel: ObservableObject {
  @Published var path: [Int] {
    willSet {
      print("willChange", self.path, "->", newValue)
    }
  }

  init(path: [Int] = []) {
    self.path = path
  }
}

struct MyView: View {
  @ObservedObject var model: MyModel

  var body: some View {
    TabView {  // Comment me out and deep-linking works
      NavigationStack(path: $model.path._printChanges()) {
        Form {
          Text("Root")
        }
        .navigationDestination(for: Int.self) {
          Text("\($0)")
        }
      }
    }
  }
}

extension Binding {
  func _printChanges() -> Self {
    Self(
      get: { self.wrappedValue },
      set: {
        print("set", self.wrappedValue, "->", $0)
        self.transaction($1).wrappedValue = $0
      }
    )
  }
}

@main
struct NavacationApp: App {
  var body: some Scene {
    WindowGroup {
      MyView(
        model: MyModel(path: [1, 2, 3])
      )
    }
  }
}

Unable to show alert after dismissing sheet

FB12038918

If you simultaneously dismiss a sheet and show an alert, the alert will not show and there will be a warning in the logs.

To reproduce run the following in an Xcode preview or simulator, tap the "Open sheet" button, and then tap the "Dismiss and show alert" button:

import SwiftUI

struct ContentView: View {
  @State var isAlertOpen = false
  @State var isSheetOpen = false

  var body: some View {
    Button("Open sheet") {
      self.isSheetOpen = true
    }
    .sheet(isPresented: self.$isSheetOpen) {
      Button("Dismiss and show alert") {
        self.isSheetOpen = false
        self.isAlertOpen = true
      }
    }
    .alert("Hello!", isPresented: self.$isAlertOpen) {

    }
  }
}

struct ContentView_Previews: PreviewProvider {
  static var previews: some View {
    ContentView()
  }
}

This prints the following to the console:

2023-03-06 20:11:16.515958-0800 SheetThenAlert[27105:2830311] [Presentation] Attempt to present <SwiftUI.PlatformAlertController: 0x10a80e000> on <TtGC7SwiftUI19UIHostingControllerGVS_15ModifiedContentVS_7AnyViewVS_12RootModifier_: 0x10f817600> (from <TtGC7SwiftUI19UIHostingControllerGVS_15ModifiedContentVS_7AnyViewVS_12RootModifier_: 0x10f817600>) which is already presenting <TtGC7SwiftUI29PresentationHostingControllerVS_7AnyView: 0x11080ca00>.

First stack drill-down fails to animate

FB12202210

With a couple conditions met, NavigationStack will fail to animate its first navigation:

  • It is presented in a sheet
  • Its path binding is driven by an ObservableObject

To see, paste this over a fresh iOS SwiftUI project's "ContentView.swift" file, and:

  1. Tap "Present"
  2. Tap "Go to 0"

Note that the view was pushed onto the stack without animation.

Drilling out and tapping again will animate correctly.

Totally dismissing and re-presenting the sheet will allow the bug to be observed again.

Note: The behavior does not exist when using @State instead: Swap the $model.path binding for $path and the bug goes away.

import SwiftUI

class Model: ObservableObject {
  @Published var isPresented = false
  @Published var path: [Int] = []
}

struct ContentView: View {
  @ObservedObject var model = Model()
  @State var path: [Int] = []

  var body: some View {
    Button("Present") {
      model.isPresented = true
    }
    .sheet(isPresented: $model.isPresented) {
      NavigationStack(
        path: $model.path
        // Swap the above binding with the following for expected, animated behavior:
        // path: $path
      ) {
        NavigationLink("Go to 0", value: 0)
          .navigationDestination(for: Int.self) { n in
            Text("\(n)")
          }
      }
    }
  }
}

struct ContentView_Previews: PreviewProvider {
  static var previews: some View {
    ContentView()
  }
}

Screen goes blank during programmatic pop

FB12651099

When modifying the path binding on a NavigationStack to remove the current screen, it correctly pops, but the current screen goes blank during the pop animation.

This is true for both @State and @ObservedObject. For @State, paste this description into the “ContentView.swift” file of a fresh SwiftUI iOS project:

import SwiftUI

struct Component: Hashable {
  let id = UUID()
}

struct ContentView: View {
  @State var path: [Component] = []

  var body: some View {
    NavigationStack(path: self.$path) {
      Button("Push") {
        self.path.append(Component())
      }
      .navigationDestination(for: Component.self) { component in
        Button("Pop") {
          self.path.removeLast()
        }
        .frame(width: 1000, height: 1000)
        .background(Color.black)
      }
    }
  }
}

struct ContentView_Previews: PreviewProvider {
  static var previews: some View {
    ContentView()
  }
}

✅ Fixed in Xcode 14 beta 4

NavigationStack binding overwritten with empty array on launch

FB10261523

When an application launches with a navigation path pre-filled, the binding is immediately written to with an empty array, clearing out the state. You can see this by running the following in an application or playground:

import SwiftUI
import PlaygroundSupport

struct ContentView: View {
  @State var path = NavigationPath([1])

  var body: some View {
    NavigationStack(path: $path) {
      Button("Go") {
        path.append(2)
      }
      .navigationDestination(for: Int.self) { Text("State: \($0)") }
      .onAppear { print("onAppear", "path.count", path.count) }
      .onChange(of: path) { print("onChange", "path.count", $0.count) }
    }
  }
}

PlaygroundPage.current.setLiveView(ContentView())

The following logs are printed to the console:

onAppear path.count 1

onChange path.count 0

This shows that at launch the path had a single element, but a moment later it changed to be empty.

This happens with @ObservedObject too, not just @State:

class ViewModel: ObservableObject {
  @Published var path = NavigationPath([1])
}

struct ContentView: View {
  @ObservedObject var viewModel: ViewModel

  var body: some View {
    NavigationStack(path: self.$viewModel.path) {
      Button("Go") {
        self.viewModel.path.append(2)
      }
      .navigationDestination(for: Int.self) { Text("State: \($0)") }
      .onAppear { print("onAppear", "path.count", self.viewModel.path.count) }
      .onChange(of: self.viewModel.path) { print("onChange", "path.count", $0.count) }
    }
  }
}

PlaygroundPage.current.setLiveView(ContentView(viewModel: .init()))

onAppear path.count 1

onChange path.count 0

✅ Fixed in Xcode 14 beta 4

Crash when setting navigation path from decoded data

FB10577481

Running the following code in a SwiftUI application causes a fatal error when the "Load path" button is tapped:

struct ContentView: View {
  @State var path = NavigationPath()

  var body: some View {
    NavigationStack(path: self.$path) {
      Button("Load path") {
        self.loadPath()
      }
      .navigationDestination(for: String.self) { string in
        Text("String view: \(string)")
      }
      .navigationDestination(for: Int.self) { int in
        Text("Int view: \(int)")
      }
      .navigationDestination(for: Bool.self) { bool in
        Text("Bool view: \(String(describing: bool))")
      }
    }
  }

  func loadPath() {
    do {
      var path = NavigationPath()
      path.append("Hello")
      path.append(123)
      path.append(true)

      let data = try JSONEncoder().encode(path.codable)
      let codablePath = try JSONDecoder().decode(
        NavigationPath.CodableRepresentation.self,
        from: data
      )
      self.path = NavigationPath(codablePath)
    } catch {}
  }
}

The following is printed to the logs:

Failed to decode item in navigation path at index 0. Perhaps the navigationDestination declarations have changed since the path was encoded?

SwiftUI/NavigationPath.swift:596: Fatal error: throw through?

✅ Fixed in Xcode 14.3 and iOS 16.4

navigationDestination(isPresented:) deep linking doesn't work

FB11056434

If a view is created with a navigationDestination(isPresented:) boolean bound to true, it should show the destination to the user, but it does not.

To reproduce, copy and paste the following over a fresh SwiftUI project’s ContentView.swift file and run the preview/application. It shows the outer presenter view, not the inner destination view. Worse: tapping the button to present the view does not work because the boolean is already true.

import SwiftUI

class VM: ObservableObject {
  @Published var isPresented: Bool

  init(isPresented: Bool = false) {
    self.isPresented = isPresented
  }
}

struct ContentView: View {
  @ObservedObject var vm = VM()

  var body: some View {
    NavigationStack {
      Button("Outer") { self.vm.isPresented = true }
        .navigationDestination(isPresented: self.$vm.isPresented) {
          Text("Inner")
        }
    }
  }
}

struct ContentView_Previews: PreviewProvider {
  static var previews: some View {
    ContentView(vm: VM(isPresented: true))
  }
}

✅ Fixed in Xcode 14.3 and iOS 16.4

Programmatic nav via multiple/chained "navigationDestination(isPresented:)" is broken

FB11167501

Please describe the issue:

If I chain multiple “navigationDestination(isPresented:)” view modifiers and programmatically swap a presented destination, it works if I swap the first destination for the second, but breaks if I swap the second destination for the first.

See related issues: FB11056434, FB11167385

Please list the steps you took to reproduce the issue:

  1. Paste the following over a fresh SwiftUI project’s ContentView.swift:
import SwiftUI

struct ContentView: View {
  @State var one = false
  @State var two = false

  var body: some View {
    NavigationStack {
      VStack {
        Button("Present 1") { one = true }
      }
      .navigationDestination(isPresented: $one) {
        Button("Swap 1 for 2") { (one, two) = (false, true) }
      }
      .navigationDestination(isPresented: $two) {
        Button("Swap 2 for 1") { (two, one) = (false, true) }
      }
    }
  }
}

struct ContentView_Previews: PreviewProvider {
  static var previews: some View {
    ContentView()
  }
}
  1. Run the preview
  2. Tap “Present 1” and drill down
  3. Tap “Swap 1 for 2” and watch the content be replaced in-place (this also seems to be a bug, as I would expect to first drill out before drilling back in again
  4. Tap “Swap 2 for 1” and watch the nav stack get popped

What did you expect to happen?

I expected to go back to the first navigation destination.

✅ Fixed in Xcode 14.1 beta 3

Crash when navigating back from navigation path loaded from decoded data

FB11173376

If you load a NavigationPath from serialized data to restore a navigation stack, and then navigate back, you will get a fatalError.

To reproduce run the following in an iOS application, tap the "Load path" button, and then tap the "Back" button:

import SwiftUI 

struct ContentView: View {
  @State var path = NavigationPath()

  var body: some View {
    NavigationStack(path: self.$path) {
      Button("Load path") {
        self.loadPath()
      }
      .navigationDestination(for: String.self) { string in
        Text("String view: \(string)")
      }
    }
  }

  func loadPath() {
    do {
      var path = NavigationPath()
      path.append("Hello")

      let data = try JSONEncoder().encode(path.codable)
      let codablePath = try JSONDecoder().decode(
        NavigationPath.CodableRepresentation.self,
        from: data
      )
      self.path = NavigationPath(codablePath)
    } catch {}
  }
}

The crash is the following:

🛑 Fatal error: attempting to remove 1 items from path with 1 items

✅ Fixed in Xcode 14.3 and iOS 16.4

Changing ".sheet(item:)" correctly re-presents sheet but breaks dismissal hooks

FB11975674

Paste the following into a fresh SwiftUI project’s “ContentView.swift” file and run the application in the simulator.

  1. Tap “Item 1” and observe the sheet appearing for item 1
  2. Tap “Swap” and observe the sheet is dismissed and a new sheet appears for item 2
  3. Clear the console.
  4. Drag the sheet to dismiss and check the console. Note that neither the debug print from the binding, nor the debug print from the “onDismiss” is hit. It seems that the act of swapping a presented sheet for another presented sheet prevents us from observing dismissal entirely.

Note: We did work around the problem by adding an “onDisappear” to the sheet’s content, but such a workaround is precarious and requires us to make sure we don’t observe dismissal more than once in the more common case.

import SwiftUI

struct Item1: Identifiable {
  let id = 1
}

struct Item2: Identifiable {
  let id = 2
}

extension Binding {
  func printChanges() -> Self {
    Self(
      get: { self.wrappedValue },
      set: { self.wrappedValue = dump($0, name: "printChanges") }
    )
  }
}

struct ContentView: View {
  @State var item1: Item1?
  @State var item2: Item2?

  var body: some View {
    VStack {
      Button("Item 1") {
        self.item1 = .init()
      }
      Button("Item 2") {
        self.item2 = .init()
      }
    }
    .sheet(
      item: self.$item1.printChanges(),
      onDismiss: {
        print("dismiss1")
      }
    ) { _ in
      Button("Swap") {
        self.item1 = nil
        self.item2 = .init()
      }
      Text("Item 1")
    }
    .sheet(
      item: self.$item2.printChanges(),
      onDismiss: {
        print("dismiss2")
      }
    ) { _ in
      Button("Swap") {
        self.item1 = .init()
        self.item2 = nil
      }
      Text("Item 2")
    }
  }
}

struct ContentView_Previews: PreviewProvider {
  static var previews: some View {
    ContentView()
  }
}

✅ Fixed in Xcode 15 beta 4 and iOS 17

NavigationStack writes empty array to binding when presented in a sheet

FB12183098

The code below has two views:

  • A root view with a button that when pressed shows a modal.
  • A modal view that holds a NavigationStack that displays integers.

The bug occurs when trying to simultaneously present the modal with some data pre-filled in the stack. In that case the NavigationStack immediately writes an empty array to the binding, causing the screens to be popped off the stack.

To reproduce the bug, run the following code in an Xcode preview or simulator and tap the "Present" button. You will see a sheet comes up with a "1" on the screen, but then that screen is immediately popped off the stack. Further if you check the logs you will see $path.set = [] in the logs, showing that NavigationStack did indeed erroneously write to the binding:

import SwiftUI

struct RootView: View {
  @State var isPresenting = false
  @State var path: [Int] = [1, 2]

  public var body: some View {
    VStack {
      Button("Present") { self.isPresenting = true }
    }
    .sheet(isPresented: self.$isPresenting) {
      ModalView(path: self.$path)
    }
  }
}

struct ModalView: View {
  @Binding var path: [Int]

  var body: some View {
    NavigationStack(
      path: Binding(
        get: { self.path },
        set: { print("$path.set =", $0); self.path = $0 }
      )
    ) {
      Button("Push") {
        self.path.append((self.path.last ?? 0) + 1)
      }
      .navigationTitle("Root")
      .navigationDestination(for: Int.self) { int in
        Button("push") { self.path.append((self.path.last ?? 0) + 1) }
          .navigationTitle("\(int)")
      }
    }
  }
}

struct Previews: PreviewProvider {
  static var previews: some View {
    RootView()
  }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment