swiftui

SwiftUI pattern for handling independent events/triggers in self-contained Views?


I have a View which is self-contained, in that it handles its own state.

The View maintains a list of Particle models. The View manages the life cycle and rendering of each Particle.

I want to trigger the View to create a new Particle, in an ad-hoc fashion, say in response to a timer or user input.

Each Particle is independent. There can be any number of Particles managed by the View, thus any number of calls to the trigger.

Currently, I am using an @Observable view model.

The view model has a flag 'particleRequested'.

To manage this flag, I call a convenience function on the view model:

func requestParticle() {
    guard particleRequested == false else { return }

    particleRequested = true
    
    // Wait a brief moment, then reset the flag.
    Task { @MainActor [weak self] in
        guard let self = self else { return }
        try await Task.sleep(for: .seconds(0.01))
        self.particleRequested = false
    }
}

In the View itself, I only observe changes to the flag:

.onChange(of: viewModel.particleRequested, { _, newValue in
    if newValue {
        addParticle()  // Modifies View's state.
    }
})

This feels... jank. The whole approach smells. Is there a better, cleaner way?


Solution

  • Your general idea is correct - use onChange to detect a change in a view update and perform the desired action. Your current code can be simplified a lot:

    struct Particles<Trigger: Equatable>: View {
        @State private var particles = [String]()
        let newParticleTrigger: Trigger
        
        var body: some View {
            List(particles, id: \.self) {
                Text($0)
            }
            .onChange(of: newParticleTrigger) {
                particles.append(UUID().uuidString)
            }
        }
    }
    
    // Example usage:
    struct ContentView: View {
        @State private var newParticleTrigger = false
        
        var body: some View {
            Button("Foo") {
                newParticleTrigger.toggle()
            }
            Particles(newParticleTrigger: newParticleTrigger)
        }
    }
    

    The same pattern is used in many SwiftUI built-in views and modifiers, such as PhaseAnimator, sensoryFeedback, task(id:) and arguably any presentation modifier like sheet, confirmationDialog etc.

    If you have multiple different things you want to trigger, just put all the triggers into a struct,

    struct Particles: View {
        struct Triggers: Hashable {
            fileprivate var trigger1 = false
            fileprivate var trigger2 = false
            fileprivate var trigger3 = false
            
            mutating func triggerThing1() {
                trigger1.toggle()
            }
            mutating func triggerThing2() {
                trigger2.toggle()
            }
            mutating func triggerThing3() {
                trigger3.toggle()
            }
        }
        
        let triggers: Triggers
        
        var body: some View {
            // ...
    
            .onChange(of: triggers.trigger1) {
                // ...
            }
            .onChange(of: triggers.trigger2) {
                // ...
            }
            .onChange(of: triggers.trigger3) {
                // ...
            }
        }
    }
    

    An alternative design is to use the environment:

    extension EnvironmentValues {
        @Entry var trigger1: Bool = false
        @Entry var trigger2: Bool = false
        @Entry var trigger3: Bool = false
    }
    
    struct OnEnvironmentChange<Trigger: Equatable>: ViewModifier {
        let environmentValueKeyPath: WritableKeyPath<EnvironmentValues, Bool>
        let trigger: Trigger
        @State private var currentState = false
        
        func body(content: Content) -> some View {
            content
                .environment(environmentValueKeyPath, currentState)
                .onChange(of: trigger) {
                    currentState.toggle()
                }
        }
    }
    
    extension View {
        func thing1<T: Equatable>(trigger: T) -> some View {
            modifier(OnEnvironmentChange(environmentValueKeyPath: \.trigger1, trigger: trigger))
        }
        func thing2<T: Equatable>(trigger: T) -> some View {
            modifier(OnEnvironmentChange(environmentValueKeyPath: \.trigger2, trigger: trigger))
        }
        func thing3<T: Equatable>(trigger: T) -> some View {
            modifier(OnEnvironmentChange(environmentValueKeyPath: \.trigger3, trigger: trigger))
        }
    }
    
    struct Particles: View {
        @Environment(\.trigger1) var trigger1
        @Environment(\.trigger2) var trigger2
        @Environment(\.trigger3) var trigger3
        var body: some View {
            // ...
    
            .onChange(of: trigger1) {
                // ...
            }
            .onChange(of: trigger2) {
                // ...
            }
            .onChange(of: trigger3) {
                // ...
            }
        }
    }
    

    This way, when using Particles, you don't need to know about the triggers that you don't want to trigger. Apply only the view modifiers that corresponds to the things you want to trigger.

    // suppose this view only wants to trigger thing1
    struct ContentView: View {
        @State private var thing1 = false
        
        var body: some View {
            Button("Foo") {
                thing1.toggle()
            }
            Particles()
                // then only apply this modifier
                .thing1(trigger: thing1)
                // don't need to care about thing2 or thing3
        }
    }