Search code examples
swiftuitoggleonchange

Why isn't SwiftUI "Toggle.onChange" firing?


I have a toggle UI element in a List, and I need to execute code when the toggle state changes. As I understand it, that's accomplished by using a binding on the toggle, and then adding a ".onChange:of:" on the binding variable.

Done:

//

import Foundation
import SwiftUI

struct User : Hashable, Identifiable {
    
    var isToggled = false
    
    var id: String
    var firstName: String?
    var lastName: String?
    var loggedIn: Bool = false
    var loggedInSince: String?
    var isActive: Bool?
    var isAdmin: Bool?
    
}

struct ListRow: View {
 @EnvironmentObject var userListModel: UserListModel

 @Binding var user: User

 var body: some View {
     Toggle(isOn: $user.loggedIn) {
         VStack {
           Text("\(user.firstName!) \(user.lastName!)")
             if user.loggedIn {
                 Text("Logged in since \(user.loggedInSince!)")
                     .font(Font.system(size: 10))
             }
             else {
                 Text("Logged out since \(user.loggedInSince!)")
                     .font(Font.system(size: 10))
             }
         }
     }
     .disabled(self.cantLogInOut())
     .onChange(of: user.loggedIn) { value in  // THIS ISN'T WORKING, IT'S NOT GETTING CALLED
         // action...
         print(value)
         userListModel.changeLogStatus(user: user)
     }
 }

So, the code in the onChange ("print", "userListModel.changeLogStatus") is never called.

I'm getting this in the console:

2022-02-12 22:56:39.860044-0500 TimeCard[10104:4072116] invalid mode 'kCFRunLoopCommonModes' provided to CFRunLoopRunSpecific - break on _CFRunLoopError_RunCalledWithInvalidMode to debug. This message will only appear once per execution.

I have no idea what that means, googling it isn't helpful, and when I put a Symbolic breakpoint on that error, nothing useful is shown in the debugger (it shows a stack trace that is two assembly code segments out of main.)


Solution

  • Here is some code that you can use to "...execute code when the toggle state changes." Since you are using userListModel.changeLogStatus(user: user) to record the change in loggedIn, there is no need to have a @Binding var user: User use @State var user: User instead.

    class UserListModel: ObservableObject {
        @Published var users = [User(isToggled: false, id: "1", firstName: "firstName-1", lastName: "lastName-1", loggedIn: false, loggedInSince: "yesterday", isActive: false, isAdmin: false),
                                User(isToggled: false, id: "2", firstName: "firstName-2", lastName: "lastName-2", loggedIn: false, loggedInSince: "yesterday", isActive: false, isAdmin: false),
                                User(isToggled: false, id: "3", firstName: "firstName-3", lastName: "lastName-3", loggedIn: false, loggedInSince: "yesterday", isActive: false, isAdmin: false)
        ]
        
        func changeLogStatus(user: User) {
            if let ndx = users.firstIndex(of: user) {
                users[ndx].loggedIn = user.loggedIn
            }
        }
    }
    
    struct User: Hashable, Identifiable {
        var isToggled = false
        var id: String
        var firstName: String?
        var lastName: String?
        var loggedIn: Bool = false
        var loggedInSince: String?
        var isActive: Bool?
        var isAdmin: Bool?
    }
    
    struct ListRow: View {
        @EnvironmentObject var userListModel: UserListModel
        
        @State var user: User  // <-- use @State
        
        var body: some View {
            Toggle(isOn: $user.loggedIn) {
                VStack {
                    Text("\(user.firstName!) \(user.lastName!)")
                    if user.loggedIn {
                        Text("Logged in since \(user.loggedInSince!)").font(Font.system(size: 10))
                    }
                    else {
                        Text("Logged in since \(user.loggedInSince!)").font(Font.system(size: 10))
                    }
                }
            }
        //    .disabled(self.cantLogInOut())
            .onChange(of: user.loggedIn) { value in  // <-- THIS IS WORKING
                print("in onChange value: \(value)")
                userListModel.changeLogStatus(user: user)
            }
        }
    }
        
        struct ContentView: View {
            @StateObject var model = UserListModel()
            
            var body: some View {
                List(model.users) { user in
                    ListRow(user: user)
                }.environmentObject(model)
            }
        }
       
    

    You can also do the opposite, using only a binding, without the EnvironmentObject UserListModel, as in this code example:

    struct ListRow: View {
        @Binding var user: User  // <-- use @Binding, no need for userListModel here
        
        var body: some View {
            Toggle(isOn: $user.loggedIn) {
                VStack {
                    Text("\(user.firstName!) \(user.lastName!)")
                    if user.loggedIn {
                        Text("Logged in since \(user.loggedInSince!)").font(Font.system(size: 10))
                    }
                    else {
                        Text("Logged in since \(user.loggedInSince!)").font(Font.system(size: 10))
                    }
                }
            }
            //.disabled(self.cantLogInOut())
            .onChange(of: user.loggedIn) { value in  // <-- THIS IS WORKING
                print("in onChange value: \(value)")
            }
        }
    }
    
    struct ContentView: View {
        @StateObject var model = UserListModel()
        
        var body: some View {
            List($model.users) { $user in   // <-- note the $ 
                ListRow(user: $user)
            }
        }
    }