Understanding SwiftUI's onChange

Published on

Get weekly handpicked updates on Swift and SwiftUI!

Starting from iOS 14, SwiftUI provides the onChange modifier for views. By using onChange, we can observe specific values in the view and trigger actions when they change. This article will introduce the characteristics, usage, precautions, and alternative solutions of onChange.

How to use onChange

The definition of onChange is as follows:

Swift
func onChange<V>(of value: V, perform action: @escaping (V) -> Void) -> some View where V : Equatable

onChange calls the operation within the closure when a specific value changes.

Swift
struct OnChangeDemo:View{
    @State var t = 0
    var body: some View{
        Button("change"){
            t += 1
        }
        .onChange(of: t, perform: { value in
            print(value)
        })
    }
}

Click the Button and t will increment. onChange will compare the t value, and if it changes, it will call a closure to print the new value.

The closure can perform side effects or modify other mutable content in the view.

The value passed to the closure (such as the above value) is immutable. If modification is necessary, directly modify the mutable value in the view (t).

The closure of onChange runs on the main thread and should avoid executing long-running tasks.

How to get the OldValue of the observed value

onChange allows us to capture the old value (oldValue) of the observed value through a closure. For example:

Swift
struct OldValue: View {
    @State var t = 1
    var body: some View {
        Button("change") {
            t = Int.random(in: 1...5)
        }
        .onChange(of: t) { [t] newValue in
            let oldValue = t
            if newValue % oldValue == 2 {
                print("余值为 2")
            } else {
                print("不满足条件")
            }
        }
    }
}

As t is captured in the closure, self.t should be used to call t in the view.

For structure types, an instance of the structure should be used when capturing, and the properties within the structure cannot be directly captured. For example:

Swift
struct OldValue1:View{
    @State var data = MyData()
    var body: some View{
        Button("change"){
            data.t = Int.random(in: 1...5)
        }
        .onChange(of: data.t){ [data] newValue in
            let oldValue = data.t
            if newValue % oldValue == 2 {
                print("余值为 2")
            } else {
                print("不满足条件")
            }
        }
    }
}

struct MyData{
    var t = 0
}

When changing to [data.t], the following error message will be displayed:

Bash
Fields may only be captured by assigning to a specific name

For reference types, weak should be added when capturing.

What values can be observed by onChange

Any type that conforms to the Equatable protocol can be observed by onChange. For optional values, only the Wrapped type needs to conform to Equatable.

Usually we use onChange to observe changes in data wrapped by @State, @StateObject, or @ObservableObject. However, in certain specific scenarios, we can also use onChange to observe data that is not the source of truth for the view. For example:

Swift
struct NonStateDemo: View {
    let store = Store.share
    @State var id = UUID()
    var body: some View {
        VStack {
            Button("refresh") {
                id = UUID()
            }
            .id(id)
            .onChange(of: store.date) { value in
                print(value)
            }
        }
    }
}

class Store {
    var date = Date()
    var cancellables = Set<AnyCancellable>()
    init(){
        Timer.publish(every: 3,  on: .current, in: .common)
            .autoconnect()
            .assign(to: \.date, on: self)
            .store(in: &cancellables)
    }

    static let share = Store()
}

Store is not an element that can trigger view refresh. The view is refreshed by changing the id through clicking the Button.

This example may seem a bit nonsensical, but it provides a good insight into the characteristics of onChange.

Characteristics of onChange

When onChange was introduced, most people saw it as an implementation of didSet for @State. However, there are significant differences between the two.

didSet calls the operation in the closure when the value changes, regardless of whether the new value is different from the old one. For example,

Swift
class MyStore{
    var i = 0{
        didSet {
            print("oldValue:\(oldValue),newValue:\(i)")
        }
    }
}

let store = MyStore()
store.i = 0

//oldValue:0,newValue:0

onChange has its own running logic.

In the example from the previous section, even though the date in the Store changes every three seconds, it does not cause the view to be redrawn. onChange is only triggered when the button is clicked to force the view to be redrawn.

If the button is clicked multiple times within three seconds, the console will not print more time information.

Changes in the observed value do not trigger onChange. onChange is only triggered each time the view is redrawn. After onChange is triggered, it compares the changes in the observed value. Only when the new and old values are different, the operations in the onChange closure are called.

FAQ about onChange

How many onChange can be placed in a view?

As many as desired. However, since the closure of onChange runs on the main thread, it is better to limit the usage of onChange to avoid affecting the rendering efficiency of the view.

What is the execution order of multiple onChange?

Strictly follows the rendering order of the view tree. In the following code, the execution order of onChange is from the inside out:

Swift
struct ContentView: View {
    @State var text = ""
    var body: some View {
        VStack {
            Button("Change") {
                text += "1"
            }
            .onChange(of: text) { _ in
                print("TextField1")
            }
            .onChange(of: text) { _ in
                print("TextField2")
            }
        }
        .onChange(of: text, perform: { _ in
            print("VStack")
        })
    }
}

// Output:
// TextField1
// TextField2
// VStack

Observing the Same Value with Multiple onChange Events

Within a rendering cycle, observing the same value with multiple onChange events will result in the same old and new values, regardless of the order in which they occur. The value will not change due to modifications made by earlier onChange events in the sequence.

Swift
struct InOneLoop: View {
    @State var t = 0
    var body: some View {
        VStack {
            Button("change") {
                t += 1 // t = 1
            }
            // onChange1
            .onChange(of: t) { [t] newValue in
                print("onChange1: old:\(t) new:\(newValue)")
                    self.t += 1
            }
            // onChange2
            .onChange(of: t) { [t] newValue in
                print("onChange2 old:\(t) new:\(newValue)")
            }
        }
    }
}

Output:

Bash
render loop
onChange1: old:3 new:4
onChange2 old:3 new:4
render loop
onChange1: old:4 new:5
onChange2 old:4 new:5
render loop
onChange(of: Int) action tried to update multiple times per frame.

In each loop iteration, the content of onChange2 did not change due to the modification of t by onChange1.

Why does onChange give an error

In the code above, at the end of the output, we get an error message saying onChange(of: Int) action tried to update multiple times per frame..

This is because, due to the modification of the observed value in onChange, the modification will refresh the view again, causing an infinite loop. SwiftUI has a protection mechanism to avoid app freeze, which forcibly interrupts the execution of onChange.

As for the number of allowed loop iterations, there is no clear agreement. In the example above, changes triggered by the Button are usually limited to 2 times, while changes triggered by onAppear may be around 6-7 times.

Swift
struct LoopTest: View {
    @State var t = 0
    var body: some View {
        let _ = print("frame")
        VStack {
            Text("\(t)")
                .onChange(of: t) { _ in
                    t += 1
                    print(t)
                }
                .onAppear(perform: { t += 1 })
        }
    }
}

Output:

Bash
 frame
 2
 frame
 3
 frame
 4
 frame
 5
 frame
 6
 frame
 7
 frame
 onChange(of: Int) action tried to update multiple times per frame.

Therefore, we need to avoid modifying the observed value as much as possible in onChange. If necessary, use conditional statements to limit the number of changes and ensure that the program runs as expected.

Alternatives to onChange

In this section, we will introduce several implementations similar to onChange, but with different behaviors, characteristics, and suitable scenarios.

task(id:)

SwiftUI 3.0 introduces the task modifier, which asynchronously runs the contents of the closure when the view appears, and restarts the task when the id value changes.

When the task unit in the task closure is simple enough, it behaves similarly to onChange, equivalent to a combination of onAppear and onChange.

Swift
struct AsyncTest: View {
    @State var t: CGFloat = 0
    var body: some View {
        let _ = print("frame")
        VStack {
            Text("\(t)")
                .task(id: t) {
                    t += 1
                    print(t)
                }
        }
    }
}

Output:

Bash
frame
1.0
frame
2.0
...

However, one thing to note is that since the closure of the task runs asynchronously, in theory it should not affect the rendering of the view, so SwiftUI will not limit its execution frequency. In this example, the tasks in the closure of the task will continue to run, and the content in Text will also keep changing (if the task is replaced with onChange, it will be automatically interrupted by SwiftUI).

The Combine version of onChange

Before the release of onChange, most people used the Combine framework to achieve similar effects.

Swift
import Combine
struct CombineVersion: View {
    @State var t = 0
    var body: some View {
        VStack {
            Button("change") {
                t += 1
            }
        }
        .onAppearAndOnChange(of: t, perform: { value in
            print(value)
        })
    }
}

public extension View {
    func onAppearAndOnChange<V>(of value: V, perform action: @escaping (_ newValue: V) -> Void) -> some View where V: Equatable {
        onReceive(Just(value), perform: action)
    }
}

Its behavior is similar to a combination of onAppear and onChange. The biggest difference is that this approach does not compare whether the observed value has changed (the new and old values are different).

Swift
struct CombineVersion: View {
    @State var t = 0
    @State var n = 0
    var body: some View {
        VStack {
            Text("\(n)")
            Button("change n"){
                n += 1
                t += 0
            }
        }
        .onAppearAndOnChange(of: t, perform: { value in
            print("combine \(t)")
        })
        .onChange(of: t){ value in
            print("onChange \(t)")
        }
    }
}

The closure of onChange will not be called if the content of t does not change, while the closure of onAppearAndOnChange will be called every time t is assigned a value.

Sometimes, this behavior is exactly what we need.

onChange for Binding

This approach can only be applied to data of Binding type. By adding a layer of logic in the Set of Binding, we can respond to changes in content.

Swift
extension Binding {
    func didSet(_ didSet: @escaping (Value) -> Void) -> Binding<Value> {
        Binding(get: { wrappedValue },
                set: { newValue in
                    self.wrappedValue = newValue
                    didSet(newValue)
                })
    }
}

struct BindingVersion2: View {
    @State var text = ""
    var body: some View {
        Form {
            TextField("text:", text: $text.didSet { print($0) })
        }
    }
}

Perhaps you may think this is unnecessary and can be achieved using onChange, but using Binding allows us to perform pre-modification checks on the data. When used properly, it can greatly reduce the refresh rate of the view.

For example, we can also perform pre-checks on new data to determine whether or not to modify the original data.值:

Swift
extension Binding {
    func conditionSet(_ condition: @escaping (Value) -> Bool) -> Binding<Value> {
        Binding(get: { wrappedValue },
                set: { newValue in
                    if condition(newValue) {
                        self.wrappedValue = newValue
                    }
                })
    }
}

Please note that this approach may not be well compatible with system controls that support binding, because system controls do not produce corresponding effects when we limit the modification of values (system controls still retain their own set of data, unless the view is forcibly refreshed, it does not guarantee full synchronization with external data). For example, the code below may not behave as expected.

Swift
struct BindingVersion3: View {
    @State var text = ""
    var body: some View {
        Form {
            Text(text)
            TextField("text:", text: $text.conditionSet { text in
                return text.count < 5
            })
        }
    }
}

Summary

The onChange function provides us with convenience for logic processing in the view. It is important to understand its characteristics and limitations, and choose the appropriate scenarios to use it. In necessary situations, separate logic processing from the view to ensure rendering efficiency.

Weekly Swift & SwiftUI insights, delivered every Monday night. Join developers worldwide.
Easy unsubscribe, zero spam guaranteed