View not reloading when @Published property is updated SwiftUI iOS15

105 Views Asked by At

I am trying to maintain an MVVM structure, and I've run into this issue where the view will show the value of the @Published variable one behind. The updated value will only be shown in View after I close the app and reopen it. The @Published variable remindersCount is not triggering the reload of my view.

I've spent days attempting different things and reading stack overflow and other forums and it seems this should work but obviously, I am doing something wrong. If someone could point me in the right direction, I would really appreciate it. Thank you.

**The ViewModel has 2 published variables: **

class ReminderViewModel: ObservableObject {
   @Published private(set) var reminders: [ReminderModel] = []
   @Published private(set) var remindersCount: Int = 0

    init(dataService: any ReminderDataServiceProtocol) {
        self.dataService = dataService
        getReminders()
        getRemindersCount()
    }

The reminders and remindersCount variables are updated like so:

    func getReminders() {
        dataService.get()
            .sink { error in
                fatalError("\(error)")
            } receiveValue: { reminders in
                self.reminders = reminders
            }
            .store(in: &cancellables)
    }

    func getRemindersCount() {
        $reminders
            .map { (_: [ReminderModel]) -> Int in
                return self.reminders.count
            }
            .sink(receiveValue: { [weak self] count in
                self?.remindersCount = count
            })
            .store(in: &cancellables)
    }

After running user interactions through some logic, the Reminders database gets updated with this function, which lives in another ViewModel:

for time in randomTimes {
    let idNotification = UUID().uuidString
        let newReminder: ReminderModel = ReminderModel(idNotification: idNotification, random:             true, date: time)
            reminderVM.saveReminder(reminder: newReminder)
            notificationMgr.scheduleNotification(identifier: idNotification, date: time)
}

**Implementation of a simple functionality to showcase the issue **

First Scenario: I expected the reminderVM.remindersCount to update and trigger the reload, but it is always one behind. I have to close the app and reopen it to have the the view with the updated count data

struct PreferencesView: View {
    @EnvironmentObject private var reminderVM: ReminderViewModel

    var body: some View {

        NavigationView {
            Text("NUMBER OF REMINDERS: \(reminderVM.remindersCount)")
        }
    }
}

Second Scenario: Since that didn't work, I attempted to "force" an update by adding a button for testing purposes. Still encountered the same issue.

struct PreferencesView: View {
    @EnvironmentObject private var reminderVM: ReminderViewModel

    var body: some View {

        NavigationView {
            VStack {
                Button("reload") {
                    reminderVM.getRemindersCount()
                }
                Text("NUMBER OF REMINDERS: \(reminderVM.remindersCount)")
            }
        }
    }
}

################################################################# MINIMAL REPRODUCIBLE EXAMPLE #################################################################

import SwiftUI
import Foundation
import Combine
import CoreData

struct ContentView: View {
    @EnvironmentObject private var reminderVM: ReminderViewModel
    
    var body: some View {
        VStack {
            VStack (){
                Button("Add a reminder") {
                    let newReminder: ReminderModel = ReminderModel(idNotification: UUID().uuidString, random: true, date: Date.now)
                    reminderVM.saveReminder(reminder: newReminder)
                }
                .buttonStyle(.bordered)
                Text("NUMBER OF REMINDERS: \(reminderVM.remindersCount)")
                Button("Update") {
                    print("button pressed")
                    reminderVM.getRemindersCount()
                }
                .buttonStyle(.bordered)
            }
        }
        .padding()
    }
}


class ReminderViewModel: ObservableObject {
    
    private let dataService: ReminderDataService
    
    private var cancellables = Set<AnyCancellable>()
    @Published private(set) var reminders: [ReminderModel] = []
    @Published private(set) var remindersCount: Int = 0
    
    init(dataService: ReminderDataService) {
        self.dataService = dataService
        getReminders()
        getRemindersCount()
    }
    
    func saveReminder(reminder: ReminderModel) {
        dataService.add(reminder)
    }
    
    func getReminders() {
        dataService.get()
            .sink { error in
                fatalError("\(error)")
            } receiveValue: { reminders in
                self.reminders = reminders
            }
            .store(in: &cancellables)
    }
    
    func getRemindersCount() {
        $reminders
            .map { (_: [ReminderModel]) -> Int in
                return self.reminders.count
            }
            .sink(receiveValue: { [weak self] count in
                self?.remindersCount = count
            })
            .store(in: &cancellables)
    }
    
}


class ReminderDataService: ObservableObject {
    @Published private var reminderEntities: [Reminder] = []

    private let persistenceController: PersistenceController
    private let fetchRequest = NSFetchRequest<Reminder>(entityName: "Reminder")
    
    
    init(persistenceController: PersistenceController) {
        self.persistenceController = persistenceController
        fetch()
    }
    
    func get() -> AnyPublisher<[ReminderModel], Error> {
        $reminderEntities.tryMap { reminders in
            reminders.map {reminder in
                ReminderModel(reminder: reminder)
            }
        }
        .eraseToAnyPublisher()
    }
    
    func add(_ reminder: ReminderModel) {
        let reminderEntity = Reminder(context: persistenceController.container.viewContext)
        reminderEntity.id = reminder.id
        reminderEntity.idNotification = reminder.idNotification
        reminderEntity.random = reminder.random
        reminderEntity.date = reminder.date
        saveContext()
    }
    
    // MARK: Private functions
    private func fetch() {
        do {
            fetchRequest.predicate = nil
            reminderEntities = try persistenceController.container.viewContext.fetch(fetchRequest)
        } catch let error {
            print("Error: Unable to fetch core data using data services \(error.localizedDescription)")
        }
    }
    
    private func saveContext() {
        do {
            try persistenceController.container.viewContext.save()
        } catch let error{
            print("Error saving reminders in Core Data: \(error)")
        }
        fetch()
    }
}


class PersistenceController: ObservableObject {

    static let shared = PersistenceController()
        
    let container: NSPersistentContainer
    
    init() {
        container = NSPersistentContainer(name: "Model")
        container.loadPersistentStores { description, error in
            if let error = error {
                print("Core Data failed to load: \(error.localizedDescription)")
            }
        }
    }
}


struct ReminderModel: Hashable, Identifiable, Codable {
    var id = UUID()
    var idNotification: String
    var random: Bool
    var date: Date
}


extension ReminderModel {
    init(reminder: Reminder) {
        id = reminder.id ?? UUID()
        idNotification = reminder.idNotification ?? ""
        random = reminder.random
        date = reminder.date ?? Date.now
    }
}
import SwiftUI

@main
struct MRE_App: App {
    
    @StateObject private var reminderVM = ReminderViewModel(dataService: ReminderDataService(persistenceController: (PersistenceController.shared)))
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(reminderVM)
            
        }
    }
}

Core Data

Entity: Reminder

Attribute. Type
date Date
id UUID
idNotification String
random Boolean
2

There are 2 best solutions below

6
Andrew Bogaevskyi On

You should not use self.reminders because at the moment when a subscription gets new value, the property still have old value.

So when set new value to a @Published property the logic is next:

  1. all subscribers get new value
  2. underlying value of the property updated with the new value
func getRemindersCount() {
    $reminders
        .map { (newReminders: [ReminderModel]) -> Int in // <-- HERE
            return newReminders.count
        }
        .sink(receiveValue: { [weak self] count in
            self?.remindersCount = count
        })
        .store(in: &cancellables)
}

Or a short version with the same result:

func getRemindersCount() {
    $reminders
        .map(\.count)
        .assign(to: &$remindersCount)
}

enter image description here

0
malhal On

In SwiftUI the View struct is the view model already so you can just remove the ReminderViewModel class and do:

@State var reminders: [ReminderModel] = []

var remindersCount: Int {
    reminders.count
}

Then just make a func to return reminders and save it in the state. Best is an async func called from .task. Even better put the func in an EnvironmentKey so you can mock it in previews.