Search code examples
iosswiftuimvvm

SwiftUI MVVM I don't know why it's not being redrawn


I manage the model as an array in viewModel and change the model's properties.

I don't know why the view doesn't change as the model's properties change.

I would appreciate your advice and feedback.

Here is the code:

model

class RoutineUnit: Identifiable, Equatable {
    
    static func == (lhs: RoutineUnit, rhs: RoutineUnit) -> Bool {
        return lhs.id == rhs.id
    }
    
    
    let id: String
    var title: String
    var isSelected: Bool
    var targetTask: RoutineUnitTask
    var tags: [RoutineUnitTag?]
    var tipComment: String
}

viewModel

class RoutineDetailViewModel: ObservableObject {
    @Published var routineUnits: [RoutineUnit]

 func toggleSelection(for unitID: String) {
        if let index = routineUnits.firstIndex(where: { $0.id == unitID }) {
             routineUnits[index].isSelected.toggle()
        }
    }

}

view

struct RoutineUnitView: View {
    
    @ObservedObject var viewModel: RoutineDetailViewModel
    
    var unitID: String
    
    var body: some View {
        if let routineUnit = viewModel.getRoutineUnitByID(unitID) {
            RoundedRectangle(cornerRadius: 10)
                .fill(Color.white)
                .overlay {
                   ...
                }
                .frame(height: 84)
                .onTapGesture {
                    withAnimation(.spring) {
                        if(viewModel.isEditingEnabled) {
                            viewModel.toggleSelection(for: unitID)
                        }
                    }
                }
        }
    }
}

viewModel.toggleSelection(for: unitID)Even though it is obviously running, the view is not redrawn. Maybe I don't understand mvvm?


Solution

  • Maybe I don't understand mvvm?

    Your misunderstanding is not with MVVM, but how SwiftUI and state changes work.

    All a Published property wrapper does is call the objectWillChange publisher of an ObservedObject in the willSet Handler.

    This is all well documented by Apple. I recommend watching the related WWDC videos like Demystify SwiftUI.

    Changing the property of an element in a Swift array does not call the willSet handler of the array itself. In your case changing the isSelected property of a RoutineUnit does not change the routineUnits array or your RoutineDetailViewModel. And since the willSet handler is not called, no event is emitted by the objectWillChange publisher and no SwiftUI re-layout / state update is triggered.

    In other words, with a Published property wrapper you do not automatically get a “deep observation” of a property. Of course, this also applies to collection properties and their elements.

    There are several ways to achieve what you want.

    If your app does not require support for iOS versions prior to iOS 17, you can migrate to the Observable macro, which supports collections and the kind of deep observation you're trying to use.

    Otherwise you will have to change your state handling or your data/view modeling.

    You could also manually trigger the objectWillChange publisher in your toggleSelection method, but that would not be an ideal solution.

    If you want changes to a RoutineUnit model in SwiftUI to result in a state change, they themselves must fulfill the ObservableObject protocol and you should have a separate view for a single RoutineUnit object.

    Something like this:

    final class RoutineUnit: Identifiable, Equatable, ObservableObject {
       // ...
    }
    
    struct RoutineUnitView: View {
        @ObservedObject var routineUnit: RoutineUnit
    
        var body: some View {
            VStack {
                Text("title: \(routineUnit.title)")
    
                if routineUnit.isSelected {
                    // ...
                }
            }
        }
    }
    
    struct RoutineUnitListView: View {
        @ObservedObject var viewModel: RoutineDetailViewModel
    
        var body: some View {
            VStack {
                ForEach(viewModel.routineUnits) { unit in
                    RoutineUnitView(routineUnit: unit)
                }            
            }
        }
    }
    

    I hope you understand the idea, i.e. one view/model for the list, one view/model for each detailed view.

    This allows you to change the list as a whole and also the individual elements of the collection.