'How to make a SwiftUI NavigationLink conditional based on an Optional Object?

Let’s say I have a model class Ball, that conforms to the ObservableObject protocol, and I define an optional instance of it (var ball: Ball?).

Is there a way to trigger a NavigationLink to display its destination view based on setting the value of the optional instance? So when I self.ball = Ball(), a NavigationLink will trigger?

One problem seems to be that an optional (Type?) can’t be an @ObservedObject.

The other problem seems to be that the isActive: parameter for NavigationLink can only take a Binding<Bool>.

/// Contrived minimal example to illustrate the problem.
include SwiftUI

class Ball: ObservableObject {
  @Published var colour: String = "red"
  // ...
}

struct ContentView: View {
  // this won’t trigger view updates when it’s set because it’s not observed:
  var ball: Ball?

  // this line won’t compile:
  @ObservedObject var observedBall: Ball?
  // 🛑 Property type 'Ball?' does not match that of the
     // 'wrappedValue' property of its wrapper type 'ObservedObject'

  var body: some View {
    NavigationView {
      VStack {
        // I want this to navigate to the ballView when isActive becomes true,
        // but it won’t accept the test of nil state on the optional value:
        NavigationLink(
          destination: BallView(ball: self.ball), isActive: self.ball != nil
        ) {
          EmptyView()
        } // 🛑 compiler error because `self.ball != nil` isn’t valid for `isActive:`

        // Button user taps to set the `ball`,
        // which I want to trigger the BallView to be shown.
        Button(action: { self.ball = Ball() }, label: { Text("Show Ball") })
      }
    }
  }
}

struct BallView: View {
  @ObservedObject var ball: Ball
  // typical view stuff here ...
}


Solution 1:[1]

So far, the best workaround I have for the above limitations involves:

  • defining another ObservableObject-conforming class as a wrapper around an Optional Ball instance,
  • adding a @State var ballIsSet = false (or @Binding var ballIsSet: Bool) variable to my view,
  • passing in that ballIsSet boolean variable to the wrapper class object,
  • then having a didSet function on the wrapped Ball variable that updates the passed in boolean.

Phew!

Hopefully someone knows a simpler/better way to do this…

// Still a somewhat contrived example, but it illustrates the points…

class ObservableBall: ObservableObject {
  // a closure to call when the ball variable is set
  private var whenSetClosure: ((Bool) -> Void)?
  @Published var ball: Ball? {
    didSet {
      // if the closure is assigned, call it when the ball gets set
      if let setClosure = self.whenSetClosure {
        setClosure(self.ball != nil)
      }
    }
  }
  
  init(_ ball: Ball? = nil, whenSetClosure: ((Bool) -> Void)? = nil) {
    self.ball = ball
    self.whenSetClosure = whenSetClosure
  }
}

struct ContentView: View {
  @ObservedObject var observedBall = ObservableBall()
  @State var ballIsSet = false

  var body: some View {
    NavigationView {
      VStack {
        // Navigate to the ballView when ballIsSet becomes true:
        NavigationLink(
          // we can force unwrap observedBall.ball! here
          // because this only gets called if ballIsSet is true:
          destination: BallView(ball: self.observedBall.ball!),
          isActive: $ballIsSet
        ) {
          EmptyView()
        }

        // Button user taps to set the `ball`,
        // triggering the BallView to be shown.
        Button(
          action: { self.observedBall.ball = Ball() },
          label: { Text("Show Ball") }
        )
      }
      .onAppear {
        observedBall.whenSetClosure = { isSet in
          self.ballIsSet = isSet
        }
      }
    }
  }
}

Solution 2:[2]

I encountered similar problem and the way I tried to get my NavigationLink to render based on a nullable object is to wrap around the NavigationLink if a optional binding to that optional object, then attach the onAppear callback to modify the isActive binding boolean to turn on the navigation link (so that it become active after it added to the view hierarchy and thus keep the transition animation)

class ObservableBall: ObservableObject {
  @Published var ball: Ball?
  @Published var showBallView = false
}

struct ContentView: View {
  @ObservedObject var observedBall: Ball 

  var body: some View {
    NavigationView {
      VStack {
        if let ball = observedBall.ball {
          NavigationLink(
            destination: BallView(ball: ball), 
            isActive: $observedBall.showBallView) 
          {
            EmptyView()
          }
          .onAppear {
            observedBall.showBallView = true
          } 
        } 
        // Button user taps to set the `ball`,
        // which I want to trigger the BallView to be shown.
        Button(action: { self.observedBall.ball = Ball() }, label: { Text("Show Ball") })
      }
    }
  }
}

struct BallView: View {
  @ObservedObject var ball: Ball
  // typical view stuff here ...
}

Solution 3:[3]

You can use the init(get:set:) initializer of the Binding to activate the destination view based on the optional instance or on an arbitrary conditional logic. For example:

struct Ball {
  var colour: String = "red"
  // ...
}
struct ContentView: View {
  @State private var ball: Ball?
  var body: some View {
    NavigationLink(isActive: Binding<Bool>(get: {
      ball != nil
    }, set: { _ in
      ball = nil
    })) {
      DestinationView()
    } label: {
      EmptyView()
    }
  }
}

I've used struct Ball instead of class Ball: ObservableObject, since @ObservedObject ball: Ball? represents a value semantic of type Optional<Ball>, thus cannot be combined with @ObservedObject or @StateObject, which are property wrappers for reference types. The value types (i.e., Optinal<Ball>) can be used with @State or @Binding, but this is most likely not what you want with an optional observable object.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2 chenhk
Solution 3