0

I have a SwiftUI View that presents and updates data originating in an @ObservedObject. The code sample below works as expected UNLESS the @ObservedObject happens to be a subclass of UIViewController. In this case, data updates are made to the @ObservedObject but they do not trigger a reload of the View content as expected.

Here is the SwiftUI View:

struct ContentView : View {
    @ObservedObject var scene: SceneViewController

    var body: some View {
        VStack {
            Text("The time is: \(scene.currentSimTimestamp.description(with: .current))")
            Button(action: {self.scene.currentSimTimestamp = Date()},
                   label: {Text("Update")})
        }
    }
}

And here is the @ObservedObject:

class SceneViewController: UIViewController, ObservableObject {

    @Published var currentSimTimestamp: Date = Date()

}

Pressing the "Update" button will cause the value stored in scene.currentSimTimestamp to update, HOWEVER ContentView will not reload (the screen won't update to reflect the data change).

Changing class SceneViewController: UIViewController, ObservableObject { to class SceneViewController: ObservableObject { will cause the update to display as expected.

It seems this may be a bug, as the Apple documentation and videos that I have seen seem to suggest that any Class can adopt ObservableObject, and indeed there is no compiler issue generated. But am I missing something?

(Added sample SceneDelegate code to reproduce the sample code into a project, below)

import UIKit
import SwiftUI

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?

    let sceneController: SceneViewController = SceneViewController()

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
        // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
        // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).

        // Create the SwiftUI view that provides the window contents.
        let contentView = ContentView(scene: sceneController)

        // Use a UIHostingController as window root view controller.
        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            window.rootViewController = UIHostingController(rootView: contentView)
            self.window = window
            window.makeKeyAndVisible()
        }
    }
...
RGood
  • 111
  • 1
  • 6
  • How are you using `UIViewController`? I'm seeing no reference to something like `UIViewControllerRepresentable`. I think that's the only way to use a `UIViewController` in your `SwiftUI` project. (And if not, please post enough code for us to reproduce your issue.) –  Aug 29 '19 at 02:23
  • In the actual project, the UIViewController is containing the SwiftUI view (using UIHosingtController). However, in my testing I found that it doesn't matter -- even if the UIViewController is not in the same view hierarchy as the SwiftUI view, the behavior remains as described. For example, in a simplest example, you can use the default Swift "use Swift UI project" SceneDelegate as follows with the code samples provided: – RGood Aug 29 '19 at 03:43

2 Answers2

1

As a workaround, I have found that I can pull out the @Published property wrapper from the View Controller, and move it to a new (non UIViewController) ObservableObject class that exists just for the purpose of publishing this property. With this as the only change, it functions as expected. Obviously, the workaround is clunky, but it does allow data owned by an existing UIViewController to be utilized as expected within a SwiftUI View. Here's the update:

class NewClass: ObservableObject {

    @Published var timestamp: Date = Date()

}

class SceneViewController: UIViewController {
 var currentSimTimestamp: NewClass()

 override func viewDidLoad() {
    super.viewDidLoad()

    // Shown here with our SwiftUI view as a child in our ViewController's hierarchy
    let contentView = ContentView(newClass: currentSimTimestamp)
    let contentViewController = UIHostingController(rootView: contentView)
    addChild(contentViewController)
    view.addSubview(contentViewController.view)
    ...
 }

}

struct ContentView : View {
    @ObservedObject var newClass: NewClass

    var body: some View {
        VStack {
            Text("The time is: \(newClass.timestamp.description(with: .current))")
            Button(action: {self.newClass.timestamp = Date()},
                   label: {Text("Update")})
        }
    }
}

RGood
  • 111
  • 1
  • 6
  • In fact anytime your ObservableObject is a subclass it does not work, I have the exact same issue with a subclass of a viewModel. – RoaflinSabos Sep 07 '19 at 20:03
  • I do not find this clunky. Looks quite close to a ViewModel. You might even get away with declaring it as a `struct` and using `@State/Binding` – nine stones Feb 11 '20 at 02:19
1

2 more ways of getting notified:

The simplest is just invoking the publisher (AFAIK scene.currentSimTimestamp does not have to be @Published):

Button(action: {
  self.scene.currentSimTimestamp = Date()
  self.scene.objectWillChange.send()
},
       label: {Text("Update")})

Slightly more involved but IMHO slightly cleaner the Combine way: https://stackoverflow.com/a/60126962/301790

nine stones
  • 3,264
  • 1
  • 24
  • 36