TruMan1
TruMan1

Reputation: 36098

Combine onChange and onAppear events in SwiftUI view?

I'm observe a property on a view using the onChange modifier. However, I'd also like the same piece of code to run on the initial value as well because sometimes the data is injected in the initializer or asynchronously loaded later.

For example, I have a view that gets a model injected. Sometimes this model has data in it to begin with (like previews), or is asynchronously retrieved from the network.

class MyModel: ObservableObject {
    @Published var counter = 0
}

struct ContentView: View {
    @ObservedObject var model: MyModel
    
    var body: some View {
        VStack {
            Text("Counter: \(model.counter)")
            Button("Increment") { model.counter += 1 }
        }
        .onChange(of: model.counter, perform: someLogic)
        .onAppear { someLogic(counter: model.counter) }
    }
    
    private func someLogic(counter: Int) {
        print("onAppear: \(counter)")
    }
}

In both onAppear and onChange cases, I'd like to run someLogic(counter:). Is there a better way to get this behaviour or combine them?

Upvotes: 16

Views: 13011

Answers (3)

pawello2222
pawello2222

Reputation: 54506

iOS 17+

In iOS 17 onChange now has the initial parameter which is described as:

initial: Whether the action should be run when this view initially appears.

struct ContentView: View {
    @State private var counter = 1

    var body: some View {
        Text(counter, format: .number)
            .onChange(of: counter, initial: true) {
                print("onChange")
            }
    }
}

iOS 13+

It looks like onReceive may be what you need. Instead of:

.onChange(of: model.counter, perform: someLogic)
.onAppear { someLogic(counter: model.counter) }

you could do:

.onReceive(model.$counter, perform: someLogic)

The difference between onChange and onReceive is that the latter also fires when the view is initialised.


onChange

If you take a closer look at onChange, you'll see that it performs an action only when a value changes (and this doesn't happen when a view is initialised).

/// Adds a modifier for this view that fires an action when a specific
/// value changes.
/// ...
@inlinable public func onChange<V>(of value: V, perform action: @escaping (V) -> Void) -> some View where V : Equatable

onReceive

However, the counter's publisher will emit the value also when a view is initialised. This will make onReceive perform an action passed as a parameter.

/// Adds an action to perform when this view detects data emitted by the
/// given publisher.
/// ...
@inlinable public func onReceive<P>(_ publisher: P, perform action: @escaping (P.Output) -> Void) -> some View where P : Publisher, P.Failure == Never

Just note that onReceive is not an equivalent of onChange+onAppear.

onAppear is called when a view appears but in some cases a view may be initialised again without firing onAppear.

Upvotes: 18

malhal
malhal

Reputation: 30573

Apple understood this requirement so they gave us task(id:) which will run the operation both when the UI described by the View data struct appears on screen of if the id: value changes (it must be an Equatable). It has the added benefits that the operation is cancelled if the value is changed so the next operation doesn't overlap with the first; it also is cancelled if the UI disappears. .task removes the need for @StateObject so just make an async func that returns a result and save it in a @State.

Example

...
let number: Int
...
.task(id: number){
    print("task") // called both on appear and when changed. If an await method is called, it is cancelled if the number changes or if the View disappears.
    someState = await fetchSomething(number)
}
.onAppear {
    print("onAppear") // only called during appear.
}
.onChange(of: number) { number in 
    print("onChange") // only called when number is changed.
}

Upvotes: 4

LiangWang
LiangWang

Reputation: 8836

Tell you a real accident.

In a navigation stack, push A -> push B. Now B is the on the screen

onReceive can be received on A too even it's not on the screen, while onChange only can be triggered on B. In this case, onReceive is very dangerous.

For example, if you have a global state and try to push to another view once onReceived or onChange is triggered on both A and B, then you will see the view is pushed twice since both A and B triggers.

Upvotes: 2

Related Questions