iosswiftswiftuiios15

SwiftUI ToolbarItem disabled state isn't updated on iOS 15 when ObservedObject is updated


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 (ToolbarItems). 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 ToolbarItems 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?


Solution

  • 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
          }
        }
      }
    }