Refresh SwiftUI View Based on Local Notification

Viewed 1612

I have the following code:

  @State private var isDataImported: Bool = false
    
    init() {
        NotificationCenter.default.addObserver(forName: .onDataImported, object: nil, queue: nil) { [self] notification in
            print("Data has been imported...")
            
            DispatchQueue.main.async {
                self.isDataImported = true
                print(self.isDataImported) // prints out false 
            }
            
           
        }
    }

I can debug and see that the notification is being fired. The line "Data has been imported" is printing. I want to update self.isDataImported property and then refresh my view.

 if isDataImported {
              ShowDataView() 
            } else {
                ProgressView()
            }

But the view ShowDataView never shows up since isDataImported is always false. What am I missing?

2 Answers

If you are using SwiftUI, you should consider using all the reactive tools available to you and subscribe to Publishers.

SwiftUI has a method onReceive(_:perform:) which takes a Publisher and closure to run when it receives an event from this publisher. You can use that to listen for your notification and change any state based on that.

Doing it this way, rather than creating and manually subscribing to a publisher in .onAppear, means that you don't need to retain the cancellable.

import SwiftUI
import Combine

extension NSNotification.Name {
    static let onDataImported = Notification.Name("onDataImported")
}

struct ContentView: View {
    @State private var dataReceived = false

    var body: some View {
        VStack {
            Text(dataReceived ? "Received" : "Waiting")
                .padding()

            Button("Simulate Notification") {
                NotificationCenter.default.post(name: .onDataImported, object: nil)
            }
            .padding()
        }
        .onReceive(NotificationCenter.default.publisher(for: .onDataImported), perform: { _ in
            self.dataReceived = true
        })
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

I've added a button to send the notification so you can see it being received.

The transitive nature of SwiftUI views makes doing things like trying to capture a reference to self in your view's init problematic. Here are a couple solutions:

Option 1

Keep everything in the View:

struct ContentView: View {
    @State private var isDataImported: Bool = false
    @State private var cancellable : AnyCancellable?
    
    var body: some View {
        Group {
            if isDataImported {
                Text("Has data")
            } else {
                Text("Does not have data")
            }
        }.onAppear {
            cancellable = NotificationCenter.default.publisher(for: .onDataImported)
                .receive(on: RunLoop.main)
                .sink { notification in
                    self.isDataImported = true
            }
            NotificationCenter.default.post(name: .onDataImported, object: nil)
        }
    }
}

Option 2

This is usually what I'd do, moving it to a view model, so that you can keep onAppear a little cleaner. Because the view model is a class and have a reliable, reference-based lifetime, the assignment to self is less problematic:

class ViewModel: ObservableObject {
    @Published var isDataImported: Bool = false
    private var cancellable : AnyCancellable?
    
    init() {
        cancellable = NotificationCenter.default.publisher(for: .onDataImported)
        .receive(on: RunLoop.main)
        .sink { notification in
            self.isDataImported = true
        }
    }
}

struct ContentView : View {
    @StateObject var viewModel = ViewModel()
    
    var body: some View {
        Group {
            if viewModel.isDataImported {
                Text("Has data")
            } else {
                Text("Does not have data")
            }
        }.onAppear {
            NotificationCenter.default.post(name: .onDataImported, object: nil)
        }
    }
}
Related