Starting with iOS 14, SwiftUI provides the onChange decorator for views. Using onChange, we can observe specific values in a view and trigger actions when they change. This article will introduce the features, usage, precautions and alternatives of onChange.
The original post was posted on my blog wwww.fatbobman.com
Public number: [Swift Notepad for elbow]
How to use onChange
OnChange is defined as follows:
func onChange<V> (of value: V.perform action: @escaping (V) - >Void) -> some View where V : Equatable
Copy the code
OnChange calls the operation in the closure when it detects that a particular value has changed.
struct OnChangeDemo:View{
@State var t = 0
var body: some View{
Button("change"){
t + = 1
}
.onChange(of: t, perform: { value in
print(value)
})
}
}
Copy the code
Click Button, t will increment by one, onChange will compare the t value, and if the value changes, the closure will be called to print the new value.
You can do side effects in closures, or modify other mutable content in the view.
Values passed into closures (such as value above) are immutable, and if you need to change them, change the mutable value (t) in the view directly.
The closure of onChange runs on the main thread, so you should avoid performing long-running tasks in the closure.
How do I get the OldValue of the observed value
OnChange allows us to get the oldValue of the observed value by capturing it in a closure. Such as:
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("Residual value is 2")}else {
print("The condition is not met.")}}}}Copy the code
Because t is captured in the closure, you need to use self.t to call t in the view.
For structure types, use the structure instance instead of directly capturing the attributes in the structure, for example:
struct OldValue1:View{
@State var data = MyData(a)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("Residual value is 2")}else {
print("The condition is not met.")}}}}struct MyData{
var t = 0
}
Copy the code
Replace [data.t] with the following error:
Fields may only be captured by assigning to a specific name
Copy the code
For reference types, you need to add weak when capturing.
OnChange can observe which values
Any type that matches the Equatable protocol can be observed by onChange. For optional values, as long as Wrapped conforms to Equatable.
Normally we would use onChange to observe changes to the @State, @StateObject, or @observableObject wrapped data. However, in some specific scenarios, we can also use onChange to observe the data that is not the Source of truth view. Such as:
struct NonStateDemo: View {
let store = Store.share
@State var id = UUID(a)var body: some View {
VStack {
Button("refresh") {
id = UUID()
}
.id(id)
.onChange(of: store.date) { value in
print(value)
}
}
}
}
class Store {
var date = Date(a)var cancellables = Set<AnyCancellable> ()init(a){
Timer.publish(every: 3, on: .current, in: .common)
.autoconnect()
.assign(to: \.date, on: self)
.store(in: &cancellables)
}
static let share = Store()}Copy the code
Store is not an element that can trigger a view refresh; refresh the view by clicking Button to change the ID.
This example may seem silly, but it provides a good insight into the nature of onChange.
The characteristics of the onChange
At the time of onChange’s launch, most people saw it as a didSet implementation of @State. But in fact there is a big difference.
DidSet calls an operation in a closure when a 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
Copy the code
OnChange has its own running logic.
In the example in the previous section, although the date in the Store changes every three seconds, it does not cause the view to be redrawn. OnChange is triggered by forcing the view to be redrawn by clicking the button.
If the button is clicked multiple times within three seconds, the console does not print any more time information.
OnChange is not triggered by a change in the observed value, only onChnage is triggered each time the view is redrawn. OnChange is triggered to compare changes in observed values, and the operation in the onChange closure is invoked only if the old and new values are inconsistent.
FAQ about onChange
How many onchanges can be placed in the view
Any number of them. However, since the closure of onChange runs in the main thread, it is best to limit the use of onChange to avoid compromising the rendering efficiency of the view.
Multiple onchanges are executed inline
In strict order of rendering the view tree, onChange is executed from inside out in the following code:
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
Copy the code
Multiple onchanges observe the same value
Observing the onChange of the same value in a rendering cycle, regardless of the order, yields the same old and new values of the observed value. The content of the value does not change because of an earlier onChange.
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)")}}}}Copy the code
The output is:
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.
Copy the code
In each loop, the contents of onChange2 do not change as onChange1 changes t.
Why does onChange report an error
In the code above, at the end of the output, we get onChange(of: Int) action tried to update multiple times per frame. Error message.
This is because, since we made changes to the observed value in onChange, the changes refresh the view again, causing an infinite loop. SwiftUI’s protection mechanism to avoid app locking forces the continuation of onChange.
There is no clear agreement on the number of cycles allowed. In the example above, changes triggered by Button are usually limited to 2, while changes triggered by onAppear may be 6-7.
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})}}}Copy the code
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.
Copy the code
Therefore, we need to avoid modifying observed values in onChange as much as possible. If necessary, please use conditional judgment statements to limit the number of changes and ensure that the program is executed as expected.
An alternative to onChange
In this section we will introduce several implementations similar to onChange, which do not behave exactly like onChange, but have their own characteristics and appropriate scenarios.
task(id:)
Task modifiers are new in SwiftUI 3.0. Tasks run the contents of closures asynchronously when a view appears and restart the task when the ID value changes.
When the task unit in a Task closure is simple enough, it behaves like 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)
}
}
}
}
Copy the code
Output:
Frame 1.0 Frame 2.0...Copy the code
It is important to note, however, that since task closures run asynchronously and theoretically have no impact on rendering views, SwiftUI will not limit its execution times. In this case, the task in the closure of the Task will be constantly running, and the contents of the Text will be constantly changing (replacing task with onChange will be automatically interrupted by SwiftUI).
Combine version of onChange
Before onChange was introduced, most people would use the Combine framework to achieve effects similar to onChange.
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)
}
}
Copy the code
It behaves like onAppear plus onChange. The biggest difference is that this scheme does not compare whether the observed values have changed.
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)")}}}Copy the code
The closure of onChange will not be called because the contents of T do not change, while the closure of onAppearAndOnChange will be called every time t is assigned.
Sometimes, this kind of behavior is exactly what we need.
Binding version of onChange
This approach only applies to data of a Binding type, by adding a layer of logic to the Binding Set to 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)})}}}Copy the code
You might think it’s unnecessary to use onChange, but Binding makes it possible to determine the data before it changes, which greatly reduces the need for view refreshes.
For example, we can also prejudge the new data to decide whether to change the original value:
extension Binding {
func conditionSet(_ condition: @escaping (Value) - >Bool) -> Binding<Value> {
Binding(get: { wrappedValue },
set: { newValue in
if condition(newValue) {
self.wrappedValue = newValue
}
})
}
}
Copy the code
Please note that this way does not support Binding with a good system of control, because we limit the numerical control system are not corresponding to the effect of the changes (system controls also retained its own set of data, unless forced to refresh the view, otherwise does not guarantee complete synchronization with external data). The following code, for example, behaves differently from what you expect.
struct BindingVersion3: View {
@State var text = ""
var body: some View {
Form {
Text(text)
TextField("text:", text: $text.conditionSet { text in
return text.count < 5})}}}Copy the code
conclusion
OnChange makes it easy to do logic in a view, understand its features and limitations, and choose the right scenario to use it. When necessary, separate logical processing from the view to ensure efficient rendering of the view.
Hope you found this article helpful.
The original post was posted on my blog wwww.fatbobman.com
Public number: [Swift Notepad for elbow]