'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 |