Search code examples
swiftswiftuicomputed-properties

Computed property from @Published array of objects not updating SwiftUI view


I have a StateController class:

import Foundation
import Combine

class StateController: ObservableObject {
    
    // Array of subjects loaded in init() with StorageController
    @Published var subjects: [Subject]
    
    private let storageController = StorageController()
    
    init() {
        self.subjects = storageController.fetchData()
    }
    
    // MARK: - Computed properties
    
    // Array with all tasks from subjects, computed property
    var allTasks: [Task] {
        var all: [Task] = []
        
        for subject in subjects {
            all += subject.tasks
        }
        print("Computed property updated!")
        return all
    }

    var numberofCompletedTasks: Int {
        return subjects.map({$0.tasks.map({$0.isCompleted == true})}).count
    }
    
    var numberOfHighPriorityTasks: Int {
        return subjects.map({$0.tasks.map({$0.priority == 1})}).count
    }
    var numberOfMediumPriorityTasks: Int {
        return subjects.map({$0.tasks.map({$0.priority == 2})}).count
    }
    var numberOfLowPriorityTasks: Int {
        return subjects.map({$0.tasks.map({$0.priority == 3})}).count
    }
}

And a SwiftUI view:

import SwiftUI

struct SmartList: View {
    
    // MARK: - Properties
    let title: String
    
    @EnvironmentObject private var stateController: StateController
    
    // MARK: - View body
    
    var body: some View {
        
        List(stateController.allTasks, id: \.taskID) { task in
            
            TaskView(task: task)
                .environmentObject(self.stateController)
            
        }.listStyle(InsetGroupedListStyle())
        .navigationTitle(LocalizedStringKey(title))
    }
}

When I update "Task" objects inside "subjects" @Published array, for example checking them as complete, SwiftUI should automatically update the view because computed properties are derived from @Published property of an ObservableObject (declared as @EnvironmentObject inside view) but it doesn't work.

How can I bind my SwiftUI view to computed properties derived from a @Published property??


Solution

  • Sadly, SwiftUI automatically updating views that are displaying computed properties when an @EnvironmentObject/@ObservedObject changes only works in very limited circumstances. Namely, the @Published property itself cannot be a reference type, it needs to be a value type (or if it's a reference type, the whole reference needs to be replaced, simply updating a property of said reference type won't trigger an objectWillChange emission and hence a View reload).

    Because of this, you cannot rely on computed properties with SwiftUI. Instead, you need to make all properties that your view needs stored properties and mark them as @Published too. Then you need to set up a subscription on the @Published property, whose value you need for your computed properties and hence update the value of your stored properties each time the value they depend on changes.

    class StateController: ObservableObject {
    
        // Array of subjects loaded in init() with StorageController
        @Published var subjects: [Subject]
    
        @Published var allTasks: [Task] = []
    
        private let storageController = StorageController()
    
        private var subscriptions = Set<AnyCancellable>()
    
        init() {
            self.subjects = storageController.fetchData()
            // Closure for calculating the value of `allTasks`
            let calculateAllTasks: (([Subject]) -> [Task]) = { subjects in subjects.flatMap { $0.tasks } }
            // Subscribe to `$subjects`, so that each time it is updated, pass the new value to `calculateAllTasks` and then assign its output to `allTasks`
            self.$subjects.map(calculateAllTasks).assign(to: \.allTasks, on: self).store(in: &subscriptions)
        }
    }