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

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.)

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)
}
}
}

You’re trying to bind the Toggle to the model but also have a side effect that also changes the model, that isn’t going to work.
You’ll have to replace the Toggle with a Button and in the handler call userListModel.changeLogStatus. And remove onChange. That way the model can stay as logged out when the log in Button is pressed and after the log in action is completed and the model is changed, body will be recomputed due to the change and the Button will be updated to show log out. You can also change #Binding var user to let user because you no longer need write access.

Related

#Appstorage is not updating based on my picker selection - SwiftUI - WatchApp

I have a picker that updates an variable bmr.
the picker in not updating the value. I put a test Text on the second screen to see if I can call the new value, its always showing the default.
import SwiftUI
struct ProfileView: View {
#AppStorage("ProfileView") var profileView:Bool = false
#AppStorage("ContentView") var contentView:Bool = false
#AppStorage("TimerView") var timerView:Bool = false
#AppStorage("Name") var userName: String?
#AppStorage("BMR") var bmr: Int?
var body: some View {
VStack {
Picker(selection: $bmr, label: Text("Enter your BMR:")) {
Text("50").tag(50)
Text("60").tag(60)
Text("70").tag(70)
Text("80").tag(80)
}
NavigationLink(destination: ContentView()) {
Text("Save")
}
}
}
}
struct ContentView: View {
#AppStorage("ProfileView") var profileView:Bool = false
#AppStorage("ContentView") var contentView:Bool = false
#AppStorage("TimerView") var timerView:Bool = false
#AppStorage("Name") var userName: String?
#AppStorage("BMR") var bmr: Int?
var body: some View {
VStack {
Text("Your BMR: \(bmr ?? 50)")
}
}
}
I am learning, so I don't know what to really try. I tried binding and unbinding, its not really working.
I tried to store variables and pass through views previously to test if it would update, and it worked fine. But passing info through views doesn't work with my app as I need this data to to be stored even if I exit.
The issue is bmr is an Int? while your tags are Int. Since they are not the same thing, the selection won't update it. The trick is to cast your tag as an Int? like this:
struct ProfileView: View {
#AppStorage("ProfileView") var profileView:Bool = false
#AppStorage("ContentView") var contentView:Bool = false
#AppStorage("TimerView") var timerView:Bool = false
#AppStorage("Name") var userName: String?
#AppStorage("BMR") var bmr: Int?
var body: some View {
VStack {
Picker(selection: $bmr, label: Text("Enter your BMR:")) {
Text("50").tag(50 as Int?)
Text("60").tag(60 as Int?)
Text("70").tag(70 as Int?)
Text("80").tag(80 as Int?)
}
NavigationLink(destination: ContentView()) {
Text("Save")
}
}
}
}
The tag and the selection types must EXACTLY match.

Issue with viewModel and TextField

I'm not sure whether it's a SwiftUI bug or it's my fault:
When I type some text in a TextField and press the return button on my keyboard (in order to hide my keyboard), the typed text is removed and the TextField is empty again. I've tried this solution on different simulators and on a real device as well. The issue appears every time. I'm using iOS 14.3, Xcode 12.4
TextField view:
struct CreateNewCard: View {
#ObservedObject var viewModel: CreateNewCardViewModel
var body: some View {
TextField("placeholder...", text: $viewModel.definition)
.foregroundColor(.black)
}
}
ViewModel:
class CreateNewCardViewModel: ObservableObject {
#Published var definition: String = ""
}
Main View:
struct MainView: View {
#State var showNew = false
var body: some View {
Button(action: { showNew = true }, label: { Text("Create") })
.sheet(isPresented: $showNew, content: {
CreateNewCard(viewModel: CreateNewCardViewModel())
})
}
}
#SwiftPunk: Here is my second question:
Let's say my view model has an additional parameter (id):
class CreateNewCardViewModel: ObservableObject {
#Published var id: Int
#Published var definition: String = ""
}
This parameter needs to be passed when I create the view to my viewModel. For this example let's say we iterate over some elements that have the id:
struct MainView: View {
#State var showNew = false
var body: some View {
ForEach(0...10, id: \.self) { index in // <<<---- this represents the id
Button(action: { showNew = true }, label: { Text("Create") })
.sheet(isPresented: $showNew, content: {
// now I have to pass the id, but this
// is the same problem as before
// because now I create every time a new viewModel, right?
CreateNewCard(viewModel: CreateNewCardViewModel(id: index))
})
}
}
Your issue is here, that you did not create a StateObject in main View, and every time you pressed the key on keyboard you created a new model which it was empty as default!
import SwiftUI
struct ContentView: View {
#State var showNew = false
#StateObject var viewModel: CreateNewCardViewModel = CreateNewCardViewModel() // <<: Here
var body: some View {
Button(action: { showNew = true }, label: { Text("Create") })
.sheet(isPresented: $showNew, content: {
CreateNewCard(viewModel: viewModel)
})
}
}
struct CreateNewCard: View {
#ObservedObject var viewModel: CreateNewCardViewModel
var body: some View {
TextField("placeholder...", text: $viewModel.definition)
.foregroundColor(.black)
}
}
class CreateNewCardViewModel: ObservableObject {
#Published var definition: String = ""
}

SwiftUI - List / ForEach in combination with NavigationLink and isActive doesn't work properly

I'm trying to do a NavigationLink within a List or ForEach Loop in SwiftUI. Unfortunately I get a really weird behavior (e.g. when clicking on Leo it opens Karl, Opening Max points to Karl, too).
I've already figured out that it's related to the "isActive" attribute in the NavigationLink. Unfortunately, I need it to achieve a this behavior here: https://i.stack.imgur.com/g0BFz.gif which is also asked here SwiftUI - Nested NavigationView: Go back to root.
I also tried to work with selection and tag attribute but I wasn't able to achieve the "go back to root" mechanics.
Here's the Example:
import SwiftUI
struct Model: Equatable, Hashable {
var userId: String
var firstName: String
var lastName: String
}
struct ContentView: View {
#State var navigationViewIsActive: Bool = false
var myModelArray: [Model] = [
Model(userId: "27e880a9-54c5-4da1-afff-05b4584b1d2f", firstName: "Leo", lastName: "Test"),
Model(userId: "1050412a-cb12-4160-b7e4-2702ab8430c3", firstName: "Max", lastName: "Test"),
Model(userId: "1050412a-cb12-4160-b7e4-2702ab8430c3", firstName: "Karl", lastName: "Test")]
var body: some View {
NavigationView {
List(myModelArray, id: \.self) { model in
NavigationLink(destination: secondView(firstName: model.firstName), isActive: $navigationViewIsActive){ Text(model.firstName) }
}
.listStyle(PlainListStyle())
}
}
}
struct secondView: View {
#State var firstName: String
var body: some View {
NavigationView {
Text(firstName)
.padding()
}
}
}
Thanks!
This happened because of the using of only one state navigationViewIsActive
So when you click in a navigation link , the value will change to True , and all the links will be active
The solution for this scenario is like that :
Define a new State which will hold the selected model value
You need just one NavigationLink , and make it Hidden (put it inside a VStack)
In the List use Button instead of NavigationLink
When a Button is clicked : first change the selectedModel value , than make the navigationLink active (true)
Like the code below (Tested with IOS 14) :
import SwiftUI
struct Model: Equatable, Hashable {
var userId: String
var firstName: String
var lastName: String
}
struct ContentView: View {
#State var navigationViewIsActive: Bool = false
#State var selectedModel : Model? = nil
var myModelArray: [Model] = [
Model(userId: "27e880a9-54c5-4da1-afff-05b4584b1d2f", firstName: "Leo", lastName: "Test"),
Model(userId: "1050412a-cb12-4160-b7e4-2702ab8430c3", firstName: "Max", lastName: "Test"),
Model(userId: "1050412a-cb12-4160-b7e4-2702ab8430c3", firstName: "Karl", lastName: "Test")]
var body: some View {
NavigationView {
VStack {
VStack {
if selectedModel != nil {
NavigationLink(destination: SecondView(firstName: selectedModel!.firstName), isActive: $navigationViewIsActive){ EmptyView() }
}
}.hidden()
List(myModelArray, id: \.self) { model in
Button(action: {
self.selectedModel = model
self.navigationViewIsActive = true
}, label: {
Text(model.firstName)
})
}
.listStyle(PlainListStyle())
}
}
}
}
struct SecondView: View {
#State var firstName: String
var body: some View {
NavigationView {
Text(firstName)
.padding()
}
}
}
struct Test_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
PS : I wrote this : How to navigate with SwiftUI , it will help you to understand the ways to navigate in swiftUI
You don't need isActive in this case, just use
List(myModelArray, id: \.self) { model in
NavigationLink(destination: secondView(firstName: model.firstName)) {
Text(model.firstName)
}
}
and you have not use NavigationView in second view in this, ie.
struct secondView: View {
var firstName: String // you don't need state here as well
var body: some View {
Text(firstName)
.padding()
}
}

How to bind a function in view model to a custom view in swiftui?

I have a custom textfield:
struct InputField: View {
var inputText: Binding<String>
var title: String
var placeholder: String
#State var hasError = false
var body: some View {
VStack(spacing: 5.0) {
HStack {
Text(title)
.font(.subheadline)
.fontWeight(.semibold)
Spacer()
}
TextField(placeholder, text: inputText).frame(height: 50).background(Color.white)
.cornerRadius(5.0)
.border(hasError ? Color.red : Color.clear, width: 1)
}
}
}
my view model is:
class LoginViewModel: ObservableObject {
#Published var username = "" {
didSet {
print("username is: \(username)")
}
}
func checkUsernameisValid() -> Bool {
return username.count < 6
}
}
and my final login view:
#ObservedObject var loginViewModel = LoginViewModel()
var inputFields: some View {
VStack {
InputField(inputText: $loginViewModel.username, title: "Username:", placeholder: " Enter your username", hasError: $loginViewModel.checkUsernameisValid())
InputField(inputText: $loginViewModel.password, title: "Password:", placeholder: " Enter your password", hasError: $loginViewModel.checkUsernameisValid())
}
}
Now this complains at hasError:$loginViewModel.checkUsernameisValid() that I cannot bind a function to the state var hasError.
How can I make this work by still using the function checkUsernameisValid() to update my custom textfield view ?
One way I can solve this is by using another published var in my view model
#Published var validUsername = false
func checkUsernameisValid() {
validUsername = username.count < 6
}
and keep calling this function in the didSet of my username var
#Published var username = "" {
didSet {
print("username is: \(username)")
checkUsernameisValid()
}
}
finally use the new published var to bind the hasError:
hasError: $loginViewModel.validUsername
My question is, is this the only way ? i.e use #published var for binding, and I cannot use standalone functions directly to do the same thing instead of using more and more #Published variables ?
You don't need binding for error. The InputField will be updated by inputText, so you just need a regular property, like
struct InputField: View {
var inputText: Binding<String>
var title: String
var placeholder: String
var hasError = false // << here !!
// ...
}
and now pass just call
InputField(inputText: $loginViewModel.username, title: "Username:", placeholder: " Enter your username",
hasError: loginViewModel.checkUsernameisValid()) // << here !!
Tested with Xcode 12.1 / iOS 14.1
Try:
#ObservedObject var loginViewModel = LoginViewModel()
var inputFields: some View {
VStack {
InputField(inputText: $loginViewModel.username, title: "Username:", placeholder: " Enter your username", hasError: loginViewModel.checkUsernameisValid())
InputField(inputText: $loginViewModel.password, title: "Password:", placeholder: " Enter your password", hasError: loginViewModel.checkUsernameisValid())
}
}
The function works on the actual value on the bound variable, not the binding itself.

How to add an observable property when other properties change

I have the following model object that I use to populate a List with a Toggle for each row, which is bound to measurement.isSelected
final class Model: ObservableObject {
struct Measurement: Identifiable {
var id = UUID()
let name: String
var isSelected: Binding<Bool>
var selected: Bool = false
init(name: String) {
self.name = name
let selected = CurrentValueSubject<Bool, Never>(false)
self.isSelected = Binding<Bool>(get: { selected.value }, set: { selected.value = $0 })
}
}
#Published var measurements: [Measurement]
#Published var hasSelection: Bool = false // How to set this?
init(measurements: [Measurement]) {
self.measurements = measurements
}
}
I'd like the hasSelection property to be true whenever any measurement.isSelected is true. I'm guessing somehow Model needs to observe changes in measurements and then update its hasSelection property… but I've no idea where to start!
The idea is that hasSelection will be bound to a Button to enable or disable it.
Model is used as follows…
struct MeasurementsView: View {
#ObservedObject var model: Model
var body: some View {
NavigationView {
List(model.measurements) { measurement in
MeasurementView(measurement: measurement)
}
.navigationBarTitle("Select Measurements")
.navigationBarItems(trailing: NavigationLink(destination: NextView(), isActive: $model.hasSelection, label: {
Text("Next")
}))
}
}
}
struct MeasurementView: View {
let measurement: Model.Measurement
var body: some View {
HStack {
Text(measurement.name)
.font(.subheadline)
Spacer()
Toggle(measurement.name, isOn: measurement.isSelected)
.labelsHidden()
}
}
}
For info, here's a screenshot of what I'm trying to achieve. A list of selectable items, with a navigation link that is enabled when one or more is selected, and disabled when no items are selected.
#user3441734 hasSelection should ideally be a get only property, that
is true if any of measurement.isSelected is true
struct Data {
var bool: Bool
}
class Model: ObservableObject {
#Published var arr: [Data] = []
var anyTrue: Bool {
arr.map{$0.bool}.contains(true)
}
}
example (as before) copy - paste - run
import SwiftUI
struct Data: Identifiable {
let id = UUID()
var name: String
var on_off: Bool
}
class Model: ObservableObject {
#Published var data = [Data(name: "alfa", on_off: false), Data(name: "beta", on_off: false), Data(name: "gama", on_off: false)]
var bool: Bool {
data.map {$0.on_off} .contains(true)
}
}
struct ContentView: View {
#ObservedObject var model = Model()
var body: some View {
VStack {
List(0 ..< model.data.count) { idx in
HStack {
Text(verbatim: self.model.data[idx].name)
Toggle(isOn: self.$model.data[idx].on_off) {
EmptyView()
}
}
}
Text("\(model.bool.description)").font(.largeTitle).padding()
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
When the model.data is updated
#Published var data ....
its publisher calls objectWillChange on ObservableObject.
Next SwiftUI recognize that ObservedObject needs the View to be "updated". The View is recreated, and that will force the model.bool.description will have fresh value.
LAST UPDATE
change this part of code
struct ContentView: View {
#ObservedObject var model = Model()
var body: some View {
NavigationView {
List(0 ..< model.data.count) { idx in
HStack {
Text(verbatim: self.model.data[idx].name)
Toggle(isOn: self.$model.data[idx].on_off) {
EmptyView()
}
}
}.navigationBarTitle("List")
.navigationBarItems(trailing:
NavigationLink(destination: Text("next"), label: {
Text("Next")
}).disabled(!model.bool)
)
}
}
}
and it is EXACTLY, WHAT YOU HAVE in your updated question
Try it on real device, otherwise the NavigationLink is usable only once (this is well known simulator bug in current Xcode 11.3.1 (11C504)).
The problem with your code at the moment is that even if you observe the changes to measurements, they will not get updated when the selection updates, because you declared the var isSelected: Binding<Bool> as a Binding. This means that SwiftUI is storing it outside of your struct, and the struct itself doesn't update (stays immutable).
What you could try instead is declaring #Published var selectedMeasurementId: UUID? = nil on your model So your code would be something like this:
import SwiftUI
import Combine
struct NextView: View {
var body: some View {
Text("Next View")
}
}
struct MeasurementsView: View {
#ObservedObject var model: Model
var body: some View {
let hasSelection = Binding<Bool> (
get: {
self.model.selectedMeasurementId != nil
},
set: { value in
self.model.selectedMeasurementId = nil
}
)
return NavigationView {
List(model.measurements) { measurement in
MeasurementView(measurement: measurement, selectedMeasurementId: self.$model.selectedMeasurementId)
}
.navigationBarTitle("Select Measurements")
.navigationBarItems(trailing: NavigationLink(destination: NextView(), isActive: hasSelection, label: {
Text("Next")
}))
}
}
}
struct MeasurementView: View {
let measurement: Model.Measurement
#Binding var selectedMeasurementId: UUID?
var body: some View {
let isSelected = Binding<Bool>(
get: {
self.selectedMeasurementId == self.measurement.id
},
set: { value in
if value {
self.selectedMeasurementId = self.measurement.id
} else {
self.selectedMeasurementId = nil
}
}
)
return HStack {
Text(measurement.name)
.font(.subheadline)
Spacer()
Toggle(measurement.name, isOn: isSelected)
.labelsHidden()
}
}
}
final class Model: ObservableObject {
#Published var selectedMeasurementId: UUID? = nil
struct Measurement: Identifiable {
var id = UUID()
let name: String
init(name: String) {
self.name = name
}
}
#Published var measurements: [Measurement]
init(measurements: [Measurement]) {
self.measurements = measurements
}
}
I'm not sure exactly how you want the navigation button in the navbar to behave. For now I just set the selection to nil when it's tapped. You can modify it depending on what you want to do.
If you want to support multi-selection, you can use a Set of selected ids instead.
Also, seems like the iOS simulator has some problems with navigation, but I tested on a physical device and it worked.