How can I run an action when a state changes?
Asked Answered
K

9

89
enum SectionType: String, CaseIterable {
    case top = "Top"
    case best = "Best"
}

struct ContentView : View {
    @State private var selection: Int = 0

    var body: some View {
        SegmentedControl(selection: $selection) {
            ForEach(SectionType.allCases.identified(by: \.self)) { type in
                Text(type.rawValue).tag(type)
            }
        }
    }
}

How do I run code (e.g print("Selection changed to \(selection)") when the $selection state changes? I looked through the docs and I couldn't find anything.

Kellie answered 11/6, 2019 at 19:41 Comment(8)
Sorry for my answer before, it was incorrect. I have deleted it for now but I am looking into this and will answer as soon as I find out :DCoupling
What is it that you want to do when the selection changes?Coupling
I haven't worked with this enough to say for sure - and will happily delete this comment if way off - but I tried executing a func with a print in it, even set a breakpoint, and no luck. Yet, when I put SwiftUI code (like creating a Text("got here") it worked. Keeping in mid it's all beta 1, I'm thinking this will be corrected (or at least better documented) in a later beta.Botvinnik
@Coupling I want to run a function which fetches stories from the Hacker News APIKellie
You might want to try using @Published decorator and subscribe to it.Covered
@DanieleBernardini I haven’t heard of that, is there a docs link that can tell me more?Kellie
Its the combine framework, check the videos from WWDC on CombineCovered
@DanieleBernardini Thanks, I'll take a look.Kellie
K
52

You can't use didSet observer on @State but you can on an ObservableObject property.

import SwiftUI
import Combine

final class SelectionStore: ObservableObject {
    var selection: SectionType = .top {
        didSet {
            print("Selection changed to \(selection)")
        }
    }

    // @Published var items = ["Jane Doe", "John Doe", "Bob"]
}

Then use it like this:

import SwiftUI

enum SectionType: String, CaseIterable {
    case top = "Top"
    case best = "Best"
}

struct ContentView : View {
    @ObservedObject var store = SelectionStore()

    var body: some View {
        List {
            Picker("Selection", selection: $store.selection) {
                ForEach(FeedType.allCases, id: \.self) { type in
                    Text(type.rawValue).tag(type)
                }
            }.pickerStyle(SegmentedPickerStyle())

            // ForEach(store.items) { item in
            //     Text(item)
            // }
        }
    }
}
Kellie answered 13/6, 2019 at 12:59 Comment(3)
You can also use onReceiveConsistence
And what if I want to change another @State property in the ContentView on didSet? With your code I stuck in the instance of the SelectionStoreAoudad
You can override didSet on a simple struct, no need to use an object.Cassowary
C
85

There are now two new onChange(of:initial:_:)'s, one with zero closure parameters and one with two.

Both now also provide the option to opt-in to running the code block when the view initially appears, by using initial: true (false by default). The former with zero closure parameters is now the new "default" for when you just want to get the new value. The latter with two closure parameters lets you get the old & new value to compare, similar to how we used to do a capture group to get the old value.

iOS 17.0+

struct ContentView: View {
    @State private var isLightOn = false

    var body: some View {
        Toggle("Light", isOn: $isLightOn)
            .onChange(of: isLightOn) {
                if isLightOn {
                    print("Light is now on!")
                } else {
                    print("Light is now off.")
                }
            }
    }
}

iOS 14.0+

You can use the onChange(of:perform:) modifier, like so:

struct ContentView: View {
    
    @State private var isLightOn = false

    var body: some View {
        Toggle("Light", isOn: $isLightOn)
            .onChange(of: isLightOn) { value in
                if value {
                    print("Light is now on!")
                } else {
                    print("Light is now off.")
                }
            }
    }
}

iOS 13.0+

The following as an extension of Binding, so you can execute a closure whenever the value changes.

extension Binding {
    
    /// When the `Binding`'s `wrappedValue` changes, the given closure is executed.
    /// - Parameter closure: Chunk of code to execute whenever the value changes.
    /// - Returns: New `Binding`.
    func onUpdate(_ closure: @escaping () -> Void) -> Binding<Value> {
        Binding(get: {
            wrappedValue
        }, set: { newValue in
            wrappedValue = newValue
            closure()
        })
    }
}

Used like so for example:

struct ContentView: View {
    
    @State private var isLightOn = false
    
    var body: some View {
        Toggle("Light", isOn: $isLightOn.onUpdate(printInfo))
    }
    
    private func printInfo() {
        if isLightOn {
            print("Light is now on!")
        } else {
            print("Light is now off.")
        }
    }
}

This example doesn't need to use a separate function. You only need a closure.

Cavallaro answered 6/8, 2020 at 18:42 Comment(2)
If you use $isLightOn.animation() for the toggle binding then your side effects in onChange aren't animated as they normally would so I don't think this is the right approach.Cassowary
This is the best approach for me. ThanksLindholm
K
52

You can't use didSet observer on @State but you can on an ObservableObject property.

import SwiftUI
import Combine

final class SelectionStore: ObservableObject {
    var selection: SectionType = .top {
        didSet {
            print("Selection changed to \(selection)")
        }
    }

    // @Published var items = ["Jane Doe", "John Doe", "Bob"]
}

Then use it like this:

import SwiftUI

enum SectionType: String, CaseIterable {
    case top = "Top"
    case best = "Best"
}

struct ContentView : View {
    @ObservedObject var store = SelectionStore()

    var body: some View {
        List {
            Picker("Selection", selection: $store.selection) {
                ForEach(FeedType.allCases, id: \.self) { type in
                    Text(type.rawValue).tag(type)
                }
            }.pickerStyle(SegmentedPickerStyle())

            // ForEach(store.items) { item in
            //     Text(item)
            // }
        }
    }
}
Kellie answered 13/6, 2019 at 12:59 Comment(3)
You can also use onReceiveConsistence
And what if I want to change another @State property in the ContentView on didSet? With your code I stuck in the instance of the SelectionStoreAoudad
You can override didSet on a simple struct, no need to use an object.Cassowary
H
26

iOS 13+

A universal solution that works for every SwiftUI version.

You can use onReceive:

import Combine
import SwiftUI

struct ContentView: View {
    @State private var selection = false

    var body: some View {
        Toggle("Selection", isOn: $selection)
            .onReceive(Just(selection)) { selection in
                // print(selection)
            }
    }
}
Humour answered 3/11, 2020 at 8:35 Comment(1)
You saved me the day with just that "Just". I had been looking for this for a long time. I needed a delay on a @State variable and with that "Just" this is now really simple and works fine. Thank you very much!Rhnegative
C
25

In iOS 14 there is now a onChange modifier you can use like so:

SegmentedControl(selection: $selection) {
    ForEach(SectionType.allCases.identified(by: \.self)) { type in
        Text(type.rawValue).tag(type)
    }
}
.onChange(of: selection) { value in
    print("Selection changed to \(selection)")
}
Comptom answered 3/7, 2020 at 19:40 Comment(0)
S
23

Here is another option if you have a component that updates a @Binding. Rather than doing this:

Component(selectedValue: self.$item, ...)

you can do this and have a little greater control:

Component(selectedValue: Binding(
    get: { self.item },
    set: { (newValue) in
              self.item = newValue
              // now do whatever you need to do once this has changed
    }), ... )

This way you get the benefits of the binding along with the detection of when the Component has changed the value.

Selenodont answered 12/2, 2020 at 15:49 Comment(3)
This throws runtime errors: "Modifying state during view update, this will cause undefined behavior."Ivette
Love this answer. This pattern has saved my butt a few times.Mid
To those who want an extension of Binding for this, see below. I find it much neater to call.Cavallaro
G
3

You can use Binding

let textBinding = Binding<String>(
    get: { /* get */ },
    set: { /* set $0 */ }
)
Guarneri answered 12/11, 2020 at 18:23 Comment(0)
L
3

iOS 17+

It has changed again! The one-parameter closure now gives this warning:

'onChange(of:perform:)' was deprecated in iOS 17.0: Use onChange with a two or zero parameter action closure instead.

So, now you write it one of 2 ways:

  1. The zero-parameter way.
  2. The 2-parameter way.

Zero-parameter

.onChange(of: playState) {
            model.playStateDidChange(state: playState)
        }

See https://developer.apple.com/documentation/swiftui/view/onchange(of:initial:_:)-8wgw9?ref=optimistic-closures.com

2-parameter

.onChange(of: playState) { oldState, newState in
            model.playStateDidChange(from: oldState, to: newState)
        }

See https://developer.apple.com/documentation/swiftui/view/onchange(of:initial:_:)-4psgg?ref=optimistic-closures.com

Lakisha answered 24/10, 2023 at 15:20 Comment(0)
S
1

Not really answering your question, but here's the right way to set up SegmentedControl (didn't want to post that code as a comment, because it looks ugly). Replace your ForEach version with the following code:

ForEach(0..<SectionType.allCases.count) { index in 
    Text(SectionType.allCases[index].rawValue).tag(index)
}

Tagging views with enumeration cases or even strings makes it behave inadequately – selection doesn't work.

You might also want to add the following after the SegmentedControl declaration to ensure that selection works:

Text("Value: \(SectionType.allCases[self.selection].rawValue)")

Full version of body:

var body: some View {
    VStack {
        SegmentedControl(selection: self.selection) {
            ForEach(0..<SectionType.allCases.count) { index in
                Text(SectionType.allCases[index].rawValue).tag(index)
                }
            }

        Text("Value: \(SectionType.allCases[self.selection].rawValue)")
    }
}

Regarding your question – I tried adding didSet observer to selection, but it crashes Xcode editor and generates "Segmentation fault: 11" error when trying to build.

Sutphin answered 12/6, 2019 at 1:14 Comment(0)
C
0

I like to solve this by moving the data into a struct:

struct ContentData {
    var isLightOn = false {
        didSet {
            if isLightOn {
                print("Light is now on!")
            } else {
                print("Light is now off.")
            }
            // you could update another var in this struct based on this value
        }
    }
}

struct ContentView: View {
    
    @State private var data = ContentData()

    var body: some View {
        Toggle("Light", isOn: $data.isLightOn)
    }
}

The advantage this way is if you decide to update another var in the struct based on the new value in didSet, and if you make your binding animated, e.g. isOn: $data.isLightOn.animation() then any Views you update that use the other var will animate their change during the toggle. That doesn't happen if you use onChange.

E.g. here the list sort order change animates:

import SwiftUI

struct ContentData {
    var ascending = true {
        didSet {
            sort()
        }
    }
    
    var colourNames = ["Red", "Green", "Blue", "Orange", "Yellow", "Black"]
    
    init() {
        sort()
    }
    
    mutating func sort(){
        if ascending {
            colourNames.sort()
        }else {
            colourNames.sort(by:>)
        }
    }
}


struct ContentView: View {
    @State var data = ContentData()
    
    var body: some View {
        VStack {
            Toggle("Sort", isOn:$data.ascending.animation())
            List(data.colourNames, id: \.self) { name in
                Text(name)
            }
        }
        .padding()
    }
}
Cassowary answered 6/4, 2021 at 17:23 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.