How can I run an action when a state changes?
IosSwiftStateSwiftuiIos Problem Overview
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.
Ios Solutions
Solution 1 - Ios
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.
Solution 2 - Ios
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)
// }
}
}
}
Solution 3 - Ios
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.
Solution 4 - Ios
SwiftUI 1 & 2 (iOS 13 & 14)
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)
}
}
}
Solution 5 - Ios
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)")
}
Solution 6 - Ios
You can use Binding
let textBinding = Binding<String>(
get: { /* get */ },
set: { /* set $0 */ }
)
Solution 7 - Ios
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.
Solution 8 - Ios
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()
}
}