Mastering SwiftUI’s onChange

fatbobman ( 东坡肘子)
9 min readApr 15, 2023

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.

A Chinese version of this post is available here.

Don’t miss out on the latest updates and excellent articles about Swift, SwiftUI, Core Data, and SwiftData. Subscribe to fatbobman’s Swift Weekly and receive weekly insights and valuable content directly to your inbox.

In light of the fact that my blog, Fatbobman’s Blog, now offers all articles in English, starting from April 1, 2024, I will no longer continue updating articles on Medium. You are cordially invited to visit my blog for more content.

How to use onChange

The definition of onChange is as follows:

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.

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:

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:

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:

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:

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,

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:

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.

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:

render looponChange1: old:3 new:4onChange2 old:3 new:4render looponChange1: old:4 new:5onChange2 old:4 new:5render looponChange(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.

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:

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.

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:

frame1.0frame2.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.

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).

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.

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.值:

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.

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.

If you found this article helpful or enjoyed reading it, consider making a donation to support my writing. Your contribution will help me continue creating valuable content for you.
Donate via Patreon, Buy Me aCoffee or PayPal.

Want to Connect?

@fatbobman on Twitter.

--

--

fatbobman ( 东坡肘子)

Blogger | Sharing articles at https://fatbobman.com | Publisher of a weekly newsletter on Swift at http://https://weekly.fatbobman.com