Search code examples
swiftuiviewequality

Cannot disable SwiftUI View updates


My app has two modes:
A "run" mode executes very fast without any SwiftUI View update, and a "single step" mode where the user advances the app state manually and needs View updates at every step.
In the single step mode, SwiftUI updates my View as usual. For the run mode, I thought about to disable the automatic View update using a suggestion from this post.
To test the approach, I wrote the following minimal project.
If I did understand the suggestion right, CustomView should not be updated, since the equality function returns always true.
But the View is updated and I don't understand why.

EDIT:
My app requires to switch between the run mode and the single step mode at run time. Thus, it is not possible to rebuild the app for the one or the other mode.

@Observable
final class Model {
    
    var counter = 0
    var timer = Timer()

    init() {
        timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
            self.counter += 1
        }
    }   
}

@main
struct ViewEquatableApp: App {
    
    let model = Model()
    
    var body: some Scene {
        WindowGroup {
            CustomView(model: model)
                .equatable()
        }
    }
}

struct CustomView: View, Equatable {
    let model: Model
    
    static func == (lhs: CustomView, rhs: CustomView) -> Bool {
        // << return yes on view properties which identifies that the
        // view is equal and should not be refreshed (ie. `body` is not rebuilt)
        true // <- true should disable the View update
    }
    
    var body: some View {
        ContentView(model: model)
    }
}

struct ContentView: View {
    let model: Model

    var body: some View {
        Text("Counter: \(model.counter)")
    }
}

Solution

  • My next attempt to achieve disabling SwiftUI View updates is the following.
    I am learning SwiftUI, and I am sure this is not the best way to implement it. Any suggestion how to do it right is therefore highly welcome!
    The approach sketched in the question is, as lorem ipsum in his last comment pointed out, something that worked apparently with older SwiftUI versions but no longer when using the @Observable macro.
    In any case this approach was much too complicated. There is a simpler solution that should work always.
    A SwiftUI is normally re-drawn, when an observed property changes. This is normally controlled by a ViewModel, not by the DataModel itself.
    I thus changed the code so that the App creates a ViewModel that is passed to the ContentView:

    @main
    struct MyApp: App {
        
        let viewModel = ViewModel()
        
        var body: some Scene {
            WindowGroup {
                ContentView(viewModel: viewModel)
            }
        }
    }
    

    ContentView accesses viewModelCounter of the passed ViewModel, and displays it:

    struct ContentView: View {
        let viewModel: ViewModel
    
        var body: some View {
            Text("Counter: \(viewModel.viewModelCounter)")
        }
    }
    

    ViewModel is @Observable. It has a property model, the data model initialized here, and a property viewModelCounter that is displayed by the ContentView.

    Modal has a property mode that determines if data changes in the model should be forwarded back to ViewModel.viewModelCounter. If so, changes to ViewModel.viewModelCounter would let ContentView to re-draw its contents. Model has also a property modelCounter that is incremented repeatedly by a timer, to simulate changing data. Model has also a reference back to its ViewModel to be able to change viewModel.viewModelCounter if requested.

    class Model {
        var mode = Mode.singleStep
        var viewModel: ViewModel?
        var modelCounter = 0
        var changeDataTimer = Timer()
        
        func changeDataRepeatedly() {
            changeDataTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [self] _ in
                self.modelCounter += 1
                if mode == .singleStep {
                    viewModel?.viewModelCounter = modelCounter
                }
            }
        }
    }
    

    This test app works as required, but, as said before, it surely not the best solution.