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?
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:
true
. Trigger the action whenever the value changesstruct 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
}
}