84

In my SwiftUI view I have to trigger an action when a Toggle() changes its state. The toggle itself only takes a Binding. I therefore tried to trigger the action in the didSet of the @State variable. But the didSet never gets called.

Is there any (other) way to trigger an action? Or any way to observe the value change of a @State variable?

My code looks like this:

struct PWSDetailView : View {

    @ObjectBinding var station: PWS
    @State var isDisplayed: Bool = false {
        didSet {
            if isDisplayed != station.isDisplayed {
                PWSStore.shared.toggleIsDisplayed(station)
            }
        }
    }

    var body: some View {
            VStack {
                ZStack(alignment: .leading) {
                    Rectangle()
                        .frame(width: UIScreen.main.bounds.width, height: 50)
                        .foregroundColor(Color.lokalZeroBlue)
                    Text(station.displayName)
                        .font(.title)
                        .foregroundColor(Color.white)
                        .padding(.leading)
                }

                MapView(latitude: station.latitude, longitude: station.longitude, span: 0.05)
                    .frame(height: UIScreen.main.bounds.height / 3)
                    .padding(.top, -8)

                Form {
                    Toggle(isOn: $isDisplayed)
                    { Text("Wetterstation anzeigen") }
                }

                Spacer()
            }.colorScheme(.dark)
    }
}

The desired behaviour would be that the action "PWSStore.shared.toggleIsDisplayed(station)" is triggered when the Toggle() changes its state.

Brezentrager
  • 879
  • 1
  • 7
  • 9
  • Since I don't know everything that's happening behind the scenes in your app, this may not be a solution, but since `station` is a `BindableObject`, can't you just replace `Toggle(isOn: $isDisplayed)` with `Toggle(isOn: $station.isDisplayed)` and then update `PWSStore.shared` in the `didSet` on `isDisplayed` in your `PWS` class? – graycampbell Jul 12 '19 at 01:02
  • 1
    @graycampbell That theoretically works (and this was what I tried earlier). Unfortunately the didChangeValue(forKey:) function of my PWS class (which is a Core Date entity) is called quite often. In some cases (like pressing the toggle) the value of 'isDisplayed' did really change (--> the action should be triggered). In other cases the value of 'isDisplayed' gets "update" with the old value (--> action has not to be triggered). I haven't found I way to distinguish between those two cases. Therefore my attempt to trigger the action directly in the view. – Brezentrager Jul 12 '19 at 05:33

20 Answers20

152

iOS 17+ (beta)

In iOS 17 onChange with a single parameter is deprecated - instead we should:

Use onChange with a two or zero parameter action closure instead.

struct ContentView: View {
    @State private var isDisplayed = false
    
    var body: some View {
        Toggle("", isOn: $isDisplayed)
            .onChange(of: isDisplayed) {
                print("Action")
            }
            .onChange(of: isDisplayed) { oldValue, newValue in
                // action...
                print(oldValue, newValue)
            }
    }
}

We can also set the initial parameter to specify whether the action should be run when the view initially appears.

struct ContentView: View {
    @State private var isDisplayed = false
    
    var body: some View {
        Toggle("", isOn: $isDisplayed)
            .onChange(of: isDisplayed, initial: true) {
                print("Action")
            }
    }
}

iOS 14+

If you're using iOS 14 and higher you can use onChange:

struct ContentView: View {
    @State private var isDisplayed = false
    
    var body: some View {
        Toggle("", isOn: $isDisplayed)
            .onChange(of: isDisplayed) { value in
                // action...
                print(value)
            }
    }
}
pawello2222
  • 46,897
  • 22
  • 145
  • 209
  • 7
    This should be the top answer. – user482594 Dec 19 '20 at 21:52
  • 4
    Hi, When using this method, if the toggle value are saved on database, fetch operation will be called twice. Once at init() {} and when we change isDisplayed boolean value from view model, onChange is again activated. Is there any way to mitigate it? – mgh Aug 10 '21 at 05:01
  • 1
    its not working.. i have tried this. – Mehul Thakkar Jun 28 '22 at 17:34
  • @mgh I put the Toggle inside a Button and used the action of the Button to prevent the issue with onChange – iljer May 09 '23 at 09:06
45

Here is a version without using tapGesture.

@State private var isDisplayed = false
Toggle("", isOn: $isDisplayed)
   .onReceive([self.isDisplayed].publisher.first()) { (value) in
        print("New value is: \(value)")           
   }
Jonas Deichelmann
  • 3,513
  • 1
  • 30
  • 45
TheLegend27
  • 741
  • 7
  • 8
44

iOS13+

Here is a more generic approach you can apply to any Binding for almost all built in Views like Pickers, Textfields, Toggle..

extension Binding {
    func didSet(execute: @escaping (Value) -> Void) -> Binding {
        return Binding(
            get: { self.wrappedValue },
            set: {
                self.wrappedValue = $0
                execute($0)
            }
        )
    }
}

And usage is simply;

@State var isOn: Bool = false
Toggle("Title", isOn: $isOn.didSet { (state) in
   print(state)
})

iOS14+

@State private var isOn = false

var body: some View {
    Toggle("Title", isOn: $isOn)
        .onChange(of: isOn) { _isOn in
            /// use _isOn here..
        }
}
Enes Karaosman
  • 1,889
  • 20
  • 27
  • 7
    This is the cleanest implementation. For me the onReceive triggered when ever any of the other state variable in the view changed. With this solution the action only runs when the attached state variable changes. – BitByteDog Aug 23 '20 at 07:00
16

The cleanest approach in my opinion is to use a custom binding. With that you have full control when the toggle should actually switch

import SwiftUI

struct ToggleDemo: View {
    @State private var isToggled = false

    var body: some View {

        let binding = Binding(
            get: { self.isToggled },
            set: {
                potentialAsyncFunction($0)
            }
        )

        func potentialAsyncFunction(_ newState: Bool) {
            //something async
            self.isToggled = newState
        }

        return Toggle("My state", isOn: binding)
   }
}
12

I think it's ok

struct ToggleModel {
    var isWifiOpen: Bool = true {
        willSet {
            print("wifi status will change")
        }
    }
}

struct ToggleDemo: View {
    @State var model = ToggleModel()

    var body: some View {
        Toggle(isOn: $model.isWifiOpen) {
            HStack {
                Image(systemName: "wifi")
                Text("wifi")
            }
       }.accentColor(.pink)
       .padding()
   }
}
Guanghui Liao
  • 121
  • 1
  • 5
  • That's my favorite answer since it's not relied on a new dedicated View-owned @State variable, thus it's a true View state-less implementation which publish the toggle value into the View Model. – Yizhar Jul 02 '22 at 12:00
6

This is how I code:

Toggle("Title", isOn: $isDisplayed)
.onReceive([self.isDisplayed].publisher.first()) { (value) in
    //Action code here
}

Updated code (Xcode 12, iOS14):

Toggle("Enabled", isOn: $isDisplayed.didSet { val in
        //Action here        
})
z33
  • 1,193
  • 13
  • 24
  • This is super clean and concise. This should be the correct answer IMHO – Sébastien Stormacq Sep 05 '20 at 21:01
  • I couldn’t get your second working, but the first version certainly solved my problem after a lot of searching. The second version wouldn’t compile for me. Thanks – Manngo Sep 30 '20 at 01:04
  • Thanks! @Manngo I tested it just now. it works on my Xcode12 & iOS14. What is your Xcode version? is there any compile error message? i believe the second is better :) – z33 Sep 30 '20 at 04:14
  • 1
    @z33 I’m on SCode 12, but targetting MacOS 10.15 Catalina. I don’t get an error message directly. The compiler takes forever to decide it can’t go ahead. – Manngo Sep 30 '20 at 06:46
  • Also agree that this should be the answer – Zack Shapiro Apr 28 '21 at 14:36
  • .onReceive() is the only thing that worked for me. I still get a _CFRunLoopError_RunCalledWithInvalidMode message, but at least the closure executes. – Jason Elwood Apr 15 '22 at 16:29
6

The .init is the constructor of Binding

@State var isDisplayed: Bool

Toggle("some text", isOn: .init(
    get: { isDisplayed },
    set: {
        isDisplayed = $0
        print("changed")
    }
))
joshmori
  • 438
  • 6
  • 11
5

I found a simpler solution, just use onTapGesture:D

Toggle(isOn: $stateChange) {
  Text("...")
}
.onTapGesture {
  // Any actions here.
}
Legolas Wang
  • 1,951
  • 1
  • 13
  • 26
4

Based on @Legolas Wang's answer.

When you hide the original label from the toggle you can attach the tapGesture only to the toggle itself

HStack {
    Text("...")
    Spacer()
    Toggle("", isOn: $stateChange)
        .labelsHidden()
        .onTapGesture {
            // Any actions here.
        }
     }
  • Best solution here! FYI - onTap is called before the isOn state actually changes, so I also had to add a 0.1 second delay to the onTap action to that the isOn state has time to switch before the action is called. Thanks! – nicksarno Aug 26 '20 at 15:29
3
class PWSStore : ObservableObject {
    ...
    var station: PWS
    @Published var isDisplayed = true {
        willSet {
            PWSStore.shared.toggleIsDisplayed(self.station)
        }
    }   
}

struct PWSDetailView : View {
    @ObservedObject var station = PWSStore.shared
    ...

    var body: some View {
        ...
        Toggle(isOn: $isDisplayed) { Text("Wetterstation anzeigen") }
        ...
    }   
}

Demo here https://youtu.be/N8pL7uTjEFM

Victor Kushnerov
  • 3,706
  • 27
  • 56
2

Here's my approach. I was facing the same issue, but instead decided to wrap UIKit's UISwitch into a new class conforming to UIViewRepresentable.

import SwiftUI

final class UIToggle: UIViewRepresentable {

    @Binding var isOn: Bool
    var changedAction: (Bool) -> Void

    init(isOn: Binding<Bool>, changedAction: @escaping (Bool) -> Void) {
        self._isOn = isOn
        self.changedAction = changedAction
    }

    func makeUIView(context: Context) -> UISwitch {
        let uiSwitch = UISwitch()
        return uiSwitch
    }

    func updateUIView(_ uiView: UISwitch, context: Context) {
        uiView.isOn = isOn
        uiView.addTarget(self, action: #selector(switchHasChanged(_:)), for: .valueChanged)

    }

    @objc func switchHasChanged(_ sender: UISwitch) {
        self.isOn = sender.isOn
        changedAction(sender.isOn)
    }
}

And then its used like this:

struct PWSDetailView : View {
    @State var isDisplayed: Bool = false
    @ObservedObject var station: PWS
    ...

    var body: some View {
        ...

        UIToggle(isOn: $isDisplayed) { isOn in
            //Do something here with the bool if you want
            //or use "_ in" instead, e.g.
            if isOn != station.isDisplayed {
                PWSStore.shared.toggleIsDisplayed(station)
            }
        }
        ...
    }   
}
paescebu
  • 179
  • 2
  • 4
  • For @Philipp Serflings Approaches: Attaching a TapGestureRecognizer was not an option for me, since it's not triggering when you perform a "swipe" to toggle the Toggle. And I would prefer not to lose on functionality of the UISwitch. And using a binding as proxy does the trick, but I dont feel like this is a SwiftUI way of doing it, but this could be a matter of taste. I prefer closures within the View Declaration itself – paescebu Apr 21 '20 at 10:30
  • Very nice. Avoids any timing issues and makes for a 'clean' view and maintains all the UISwitch features. –  Apr 16 '21 at 12:52
  • Thx @Tall Dane! But I think now I would go with the onChanged modifier which came with SwiftUI 2 :). – paescebu Apr 17 '21 at 13:11
1

First, do you actually know that the extra KVO notifications for station.isDisplayed are a problem? Are you experiencing performance problems? If not, then don't worry about it.

If you are experiencing performance problems and you've established that they're due to excessive station.isDisplayed KVO notifications, then the next thing to try is eliminating unneeded KVO notifications. You do that by switching to manual KVO notifications.

Add this method to station's class definition:

@objc class var automaticallyNotifiesObserversOfIsDisplayed: Bool { return false }

And use Swift's willSet and didSet observers to manually notify KVO observers, but only if the value is changing:

@objc dynamic var isDisplayed = false {
    willSet {
        if isDisplayed != newValue { willChangeValue(for: \.isDisplayed) }
    }
    didSet {
        if isDisplayed != oldValue { didChangeValue(for: \.isDisplayed) }
    }
}
rob mayoff
  • 375,296
  • 67
  • 796
  • 848
  • Thanks, Rob! Your first line of code already did the job. `@objc class var automaticallyNotifiesObserversOfIsDisplayed: Bool { return false }` I do not fully understand the mechanics in the background (and the Apple documentation didn't help much), but it seems that this line only silences some notifications. When an instance of the PWS class is created or when a value for `isDisplayed` is set (but not changed), there is no notification sent. But when a SwiftUI view actually changes the value of `isDisplayed`, there still is a notification. For my app that's exactly the behaviour I need. – Brezentrager Jul 12 '19 at 12:50
1

You can try this(it's a workaround):

@State var isChecked: Bool = true
@State var index: Int = 0
Toggle(isOn: self.$isChecked) {
        Text("This is a Switch")
        if (self.isChecked) {
            Text("\(self.toggleAction(state: "Checked", index: index))")
        } else {
            CustomAlertView()
            Text("\(self.toggleAction(state: "Unchecked", index: index))")
        }
    }

And below it, create a function like this:

func toggleAction(state: String, index: Int) -> String {
    print("The switch no. \(index) is \(state)")
    return ""
}
Karanveer Singh
  • 961
  • 12
  • 27
1

Here is a handy extension I wrote to fire a callback whenever the toggle is pressed. Unlike a lot of the other solutions this truly only will fire when the toggle is switched and not on init which for my use case was important. This mimics similar SwiftUI initializers such as TextField for onCommit.

USAGE:

Toggle("My Toggle", isOn: $isOn, onToggled: { value in
    print(value)
})

EXTENSIONS:

extension Binding {
    func didSet(execute: @escaping (Value) -> Void) -> Binding {
        Binding(
            get: { self.wrappedValue },
            set: {
                self.wrappedValue = $0
                execute($0)
            }
        )
    }
}
extension Toggle where Label == Text {

    /// Creates a toggle that generates its label from a localized string key.
    ///
    /// This initializer creates a ``Text`` view on your behalf, and treats the
    /// localized key similar to ``Text/init(_:tableName:bundle:comment:)``. See
    /// `Text` for more information about localizing strings.
    ///
    /// To initialize a toggle with a string variable, use
    /// ``Toggle/init(_:isOn:)-2qurm`` instead.
    ///
    /// - Parameters:
    ///   - titleKey: The key for the toggle's localized title, that describes
    ///     the purpose of the toggle.
    ///   - isOn: A binding to a property that indicates whether the toggle is
    ///    on or off.
    ///   - onToggled: A closure that is called whenver the toggle is switched.
    ///    Will not be called on init.
    public init(_ titleKey: LocalizedStringKey, isOn: Binding<Bool>, onToggled: @escaping (Bool) -> Void) {
        self.init(titleKey, isOn: isOn.didSet(execute: { value in onToggled(value) }))
    }

    /// Creates a toggle that generates its label from a string.
    ///
    /// This initializer creates a ``Text`` view on your behalf, and treats the
    /// title similar to ``Text/init(_:)-9d1g4``. See `Text` for more
    /// information about localizing strings.
    ///
    /// To initialize a toggle with a localized string key, use
    /// ``Toggle/init(_:isOn:)-8qx3l`` instead.
    ///
    /// - Parameters:
    ///   - title: A string that describes the purpose of the toggle.
    ///   - isOn: A binding to a property that indicates whether the toggle is
    ///    on or off.
    ///   - onToggled: A closure that is called whenver the toggle is switched.
    ///    Will not be called on init.
    public init<S>(_ title: S, isOn: Binding<Bool>, onToggled: @escaping (Bool) -> Void) where S: StringProtocol {
        self.init(title, isOn: isOn.didSet(execute: { value in onToggled(value) }))
    }
}
SchmidtyApps
  • 145
  • 1
  • 4
1

Available for Xcode 13.4

import SwiftUI

struct ToggleBootCamp: View {
    @State var isOn: Bool = true
    @State var status: String = "ON"
    
    var body: some View {
        NavigationView {
            VStack {
                Toggle("Switch", isOn: $isOn)
                    .onChange(of: isOn, perform: {
                        _isOn in
                        // Your code here...
                        status = _isOn ? "ON" : "OFF"
                    })
                Spacer()
            }.padding()
            .navigationTitle("Toggle switch is: \(status)")
        }
    }
}
Pete in CO
  • 11
  • 1
0

Just in case you don't want to use extra functions, mess the structure - use states and use it wherever you want. I know it's not a 100% answer for the event trigger, however, the state will be saved and used in the most simple way.

struct PWSDetailView : View {


@State private var isToggle1  = false
@State private var isToggle2  = false

var body: some View {

    ZStack{

        List {
            Button(action: {
                print("\(self.isToggle1)")
                print("\(self.isToggle2)")

            }){
                Text("Settings")
                    .padding(10)
            }

                HStack {

                   Toggle(isOn: $isToggle1){
                      Text("Music")
                   }
                 }

                HStack {

                   Toggle(isOn: $isToggle1){
                      Text("Music")
                   }
                 }
        }
    }
}
}
J A S K I E R
  • 1,976
  • 3
  • 24
  • 42
0

lower than iOS 14:

extension for Binding with Equatable check

public extension Binding {
    func onChange(_ handler: @escaping (Value) -> Void) -> Binding<Value> where Value: Equatable {
        Binding(
            get: { self.wrappedValue },
            set: { newValue in
                if self.wrappedValue != newValue { // equal check
                    self.wrappedValue = newValue
                    handler(newValue)
                }
            }
        )
    }
}

Usage:

Toggle(isOn: $pin.onChange(pinChanged(_:))) {
    Text("Equatable Value")
}

func pinChanged(_ pin: Bool) {

}
hstdt
  • 5,652
  • 2
  • 34
  • 34
0

Add a transparent Rectangle on top, then:

ZStack{
            
            Toggle(isOn: self.$isSelected, label: {})
            Rectangle().fill(Color.white.opacity(0.1))
            
        }
        .contentShape(Rectangle())
        .onTapGesture(perform: {
            
            self.isSelected.toggle()
            
        })
YESME
  • 21
  • 1
0

I used did set to monitor the toggle value

and

used generic subview

enter image description here

import SwiftUI
    
    
    
    class ViewModel : ObservableObject{
        
        @Published var isDarkMode = false{
            
            didSet{
                
                print("isDarkMode \(isDarkMode)")
            }
        }
        
        @Published var isShareMode = false{
            
            didSet{
                
                print("isShareMode \(isShareMode)")
            }
        }
        
        @Published var isRubberMode = false{
            
            didSet{
                
                print("isRubberMode \(isRubberMode)")
            }
        }
        
        
    }
    
    
    
    struct ToggleDemo: View {
        
        @StateObject var vm = ViewModel()
        
        
        var body: some View {
            
            VStack{
                
                VStack{
    
                    ToggleSubView(mode: $vm.isDarkMode, pictureName: "moon.circle.fill")
                  ToggleSubView(mode: $vm.isShareMode, pictureName: "square.and.arrow.up.on.square")
                   ToggleSubView(mode: $vm.isRubberMode, pictureName: "eraser.fill")
                }
            
            
                Spacer()
                
            
            }
            
            .background(vm.isDarkMode ? Color.black : Color.white)
        }
    }
    
    struct ToggleDemo_Previews: PreviewProvider {
        static var previews: some View {
            ToggleDemo()
        }
    }
    
    
    struct ToggleSubView : View{
        
        @Binding var mode : Bool
        
        var pictureName : String
        
        var body : some View{
            
            
            Toggle(isOn: $mode) {
                Image(systemName: pictureName)
            }
            .padding(.horizontal)
            .frame(height:44)
            .background(Color(.systemGroupedBackground))
            .cornerRadius(10)
        
            .padding()
            
        }
    }
chings228
  • 1,859
  • 24
  • 24
-1

Available for XCode 12

import SwiftUI

struct ToggleView: View {
    
    @State var isActive: Bool = false
    
    var body: some View {
        Toggle(isOn: $isActive) { Text(isActive ? "Active" : "InActive") }
            .padding()
            .toggleStyle(SwitchToggleStyle(tint: .accentColor))
    }
}
gandhi Mena
  • 2,115
  • 1
  • 19
  • 20