I have a custom navigation bar view that's used throughout my app. To make this reusable, I've created a ToolbarContent
conformant struct that implements this custom navigation bar. This view contains some navigation bar buttons (ToolbarItem
s). I need to be able to enable/disable some of these buttons on demand after the view has been presented on screen.
Everything works fine on iOS 16, I can enable/disable the buttons as expected. However, on iOS 15, I ran into an issue, where I cannot change the enabled/disabled state of the ToolbarItem
s once they're displayed on the screen. Doesn't matter whether their initial state is enabled or disabled, toggling their state doesn't do anything.
Here's a minimal reproducible example, where the disabled state of the trailing navigation bar ToolbarItem
is toggled via a button's action:
import SwiftUI
final class TopBarViewModel: ObservableObject {
@Published var isTrailingButtonDisabled: Bool = true
}
struct TopBar: ToolbarContent {
@ObservedObject private var viewModel: TopBarViewModel
init(viewModel: TopBarViewModel) {
self.viewModel = viewModel
}
var body: some ToolbarContent {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
print("Trailing navigation bar button pressed")
}, label: {
let state = viewModel.isTrailingButtonDisabled ? "disabled" : "enabled"
Text("Button is \(state)")
})
.disabled(viewModel.isTrailingButtonDisabled)
}
}
}
final class PlaygroundViewModel: ObservableObject {
let topBarViewModel: TopBarViewModel
init(topBarViewModel: TopBarViewModel) {
self.topBarViewModel = topBarViewModel
}
}
struct Playground: View {
@ObservedObject private var viewModel: PlaygroundViewModel
@ObservedObject private var topBarViewModel: TopBarViewModel
init(viewModel: PlaygroundViewModel) {
self.viewModel = viewModel
self.topBarViewModel = viewModel.topBarViewModel
}
var body: some View {
NavigationView {
Button(action: {
viewModel.topBarViewModel.isTrailingButtonDisabled.toggle()
}, label: {
let title = viewModel.topBarViewModel.isTrailingButtonDisabled ? "Enable" : "Disable"
Text("\(title) navigation bar trailing button")
})
.toolbar {
TopBar(viewModel: viewModel.topBarViewModel)
}
}
}
}
The Button
that's part of the View
is updated correctly when the @Published
property on the ObservableObject
conformant ViewModel is updated, however, the ToolbarContent
isn't updated at all - the ToolbarItem
doesn't update either its label or its disabled state on iOS 15. On iOS 16, everything is updated as expected.
I suspect this is a bug in SwiftUI on iOS 15 - how can I work around this bug so that I get the expected behaviour on both iOS 15 and 16?
The "usual" ways of forcing a view to update by manually calling objectWillChange.send()
on the ObservableObject
or by changing the View's id
doesn't seem to work on ToolbarContent
unfortunately.
However, if you create a @State
variable that shadows the @Published
on the ObservableObject
, you can force the ToolbarContent
to update correctly even on iOS 15.
struct TopBar: ToolbarContent {
@ObservedObject private var viewModel: TopBarViewModel
// Update this variable whenever viewModel.isTrailingButtonDisabled is updated, this will force the ToolbarItem to be updated as well
@State private var isTrailingButtonDisabled: Bool
init(viewModel: TopBarViewModel) {
self.viewModel = viewModel
self._isTrailingButtonDisabled = State(initialValue: viewModel.isTrailingButtonDisabled)
}
var body: some ToolbarContent {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
print("Trailing navigation bar button pressed")
}, label: {
let state = viewModel.isTrailingButtonDisabled ? "disabled" : "enabled"
Text("Button is \(state)")
})
.disabled(viewModel.isTrailingButtonDisabled)
// Update the State variable whenever viewModel.isTrailingButtonDisabled is updated, this will force the ToolbarItem to be updated as well
.onChange(of: viewModel.isTrailingButtonDisabled) {
isTrailingButtonDisabled = $0
}
}
}
}