swiftuiwatchos

Updating SwiftUI View from background


When my watchOS app starts, AppDelegate's applicationDidFinishLaunching kicks off a background thread which does some things and updates the View.

In SwiftUI, the View cannot be updated just like that. All Views are functions of state. Altering the state would 'refresh' the View.

With the above concept in mind, there's the following MessageView:

public struct MessageView: View {
    
    init(_ message: String) {
        
        self.message = message
    }
    
    @State
    public var message: String 
    
    public var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Text(message)
        }
        .padding()
    }
}

This MessageView is initialised with sample data in a StaticContext... even before the AppDelegate is invoked.

public struct StaticContext {
    
    public static var messageView: MessageView = MessageView("[Lifecycle]: Hellow World!")
}

// App Struct
@main
struct WatchOSStartup_Watch_AppApp: App {
    
    @WKApplicationDelegateAdaptor
    var appDelegate: AppDelegate
    
    var body: some Scene {
        WindowGroup {
            StaticContext.messageView
        }
    }
}

With the above style, I'm able to store a reference to the MessageView before giving it to SwiftUI. When my background thread is done with its work (most of it is in C++), it can use this reference to alter the state of MessageView and therefore update MessageView.

@objc
static func updateUIMessage() {
      
    StaticContext.messageView.message = "[Presentation]: Hello World!"
}

When state is altered, the View must refresh and the new data is supposed to get displayed? But I only see the previous message. Not sure why... Is this approach even okay? If not, how to update a SwiftUI View from background thread?

PS: This is only a basic model. Eventually, I'm expecting to have bunch of properties which can affect a View. So, I should be using @StateObject, @ObservedObject and have a custom type (conforming to the ObservableObject protocol) as the state of the View. I wanted to test a very basic model first.


Solution

  • After some reading and this helpful WWDC video about Data Essentials in SwiftUI, I'm able to update a View from a background thread.

    This is my sample data model:

    public class MessageViewData: ObservableObject {
    
        init(_ message: String) {
            
            self.message = message
        }
        
        @Published
        public var message: String
    }
    

    This is the MessageView:

    public struct MessageView: View {
        
        public init(messageData: MessageViewData) {
            
            self.messageData = messageData
        }
        
        // Not a Source of Truth.
        @ObservedObject
        var messageData: MessageViewData
        
        public var body: some View {
            VStack {
                Image(systemName: "globe")
                    .imageScale(.large)
                    .foregroundStyle(.tint)
                Text(messageData.message)
            }
            .padding()
        }
    }
    

    As shown above, the MessageView doesn't hold on to the data (aka the 'Source of Truth'), but only a reference to it. The lifecycle of the data is managed externally. In my case, I've created a static object of type MessageViewData

    public class AppDelegate: NSObject, WKApplicationDelegate {
        
        public func applicationDidFinishLaunching() {
            
            // UI related initialization
            // Note: SwiftUI view is not initialzed yet.
            
            // Run a background thread for 10 sec to mock the application logic of
            // 1) Initialize other app components
            // 2) Read from disk
            // And finally, update the UI by updating 'Source of Truth'
            let queue: DispatchQueue = DispatchQueue.global(qos: .background)
            queue.async {
                
                var presentDate: Date = Date.now
                Log("Start time = " + String(describing: presentDate))
                for i in 1...2 {
                    
                    // Sleep for 5 sec
                    Thread.sleep(forTimeInterval: 5)
                    
                    // Then count
                    presentDate = Date.now
                    Log(String(format: "%d) %@", i, String(describing: presentDate)))
                }
                Log("End time = " + String(describing: presentDate))
                
                DispatchQueue.main.async {
                
                    Log("Updating Source of Truth...")
                    AppDelegate.messageData.message = "Update"
                }
            }
        }
        
        // Source of Truth of the data passed to SwiftUI. The lifecycle of this data
        // is managed by the AppDelegate and not any SwiftUI View.
        public static var messageData: MessageViewData = MessageViewData("Default")
    }
    
    

    The data initialised in AppDelegate is injected into MessageView as shown below:

    @main
    struct WatchOSStartup_Watch_AppApp: App {
        
        @WKApplicationDelegateAdaptor
        var appDelegate: AppDelegate
        
        var body: some Scene {
            WindowGroup {
                
                // Inject data to SwiftUI view.
                // The Source of Truth is defined as a static member in
                // the AppDelegate.
                MessageView(messageData: (AppDelegate.messageData))
            }
        }
    }