I'm working on a validation routine for a form, but when the validation results come in, the onChange is not being triggered.
So I have a form that has some fields, and some nested items that have some more fields (the number of items may vary). Think of a form for creating teams where you get to add people.
When the form is submitted, it sends a message to each item to validate itself, and the results of the validation of each item are stored in an array of booleans. Once all the booleans of the array are true, the form is submitted.
Every time a change occurs in the array of results, it should change a flag that would check if all items are true, and if they are, submits the form. But whenever I change the flag, the onChange I have for it never gets called:
final class AddEditProjectViewModel: ObservableObject {
#Published var array = ["1", "2", "3", "hello"]
// In reality this array would be a collection of objects with many properties
}
struct AddEditItemView: View {
#State var text : String
#Binding var doValidation: Bool // flag to perform the item validation
#Binding var isValid : Bool // result of validating all fields in this item
init(text: String, isValid: Binding<Bool>, doValidation: Binding<Bool>) {
self._text = State(initialValue: text)
self._isValid = isValid
self._doValidation = doValidation
}
func validateAll() {
// here would be some validation logic for all form fields,
//but I'm simulating the result to all items passed validation
// Validation needs to happen here because there are error message
//fields within the item view that get turned on or off
isValid = true
}
var body: some View {
Text(text)
.onChange(of: doValidation, perform: { value in
validateAll() // when the flag changes, perform the validation
})
}
}
struct ContentView: View {
#ObservedObject var viewModel : AddEditProjectViewModel
#State var performValidateItems : Bool = false // flag to perform the validation of all items
#State var submitFormFlag = false // flag to detect when validation results come in
#State var itemsValidationResult = [Bool]() // store the validation results of each item
{
didSet {
print(submitFormFlag) // i.e. false
submitFormFlag.toggle() // Even though this gets changed, on changed on it won't get called
print(submitFormFlag) // i.e. true
}
}
init(viewModel : AddEditProjectViewModel) {
self.viewModel = viewModel
var initialValues = [Bool]()
for _ in (0..<viewModel.array.count) { // populate the initial validation results all to false
initialValues.append(false)
}
_itemsValidationResult = State(initialValue: initialValues)
}
//https://stackoverflow.com/questions/56978746/how-do-i-bind-a-swiftui-element-to-a-value-in-a-dictionary
func binding(for index: Int) -> Binding<Bool> {
return Binding(get: {
return self.itemsValidationResult[index]
}, set: {
self.itemsValidationResult[index] = $0
})
}
var body: some View {
HStack {
ForEach(viewModel.array.indices, id: \.self) { i in
AddEditItemView(
text: viewModel.array[i],
isValid: binding(for: i),
doValidation: $performValidateItems
)
}
Text(itemsValidationResult.description)
Button(action: {
performValidateItems.toggle() // triggers the validation of all items
}) {
Text("Validate")
}
.onChange(of: submitFormFlag, perform: { value in // this never gets called
print(value, "forced")
// if all validation results in the array are true, it will submit the form
})
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(viewModel: AddEditProjectViewModel())
}
}
You shouldn't use didSet on the #State - it's a wrapper and it doesn't behave like standard properties.
See SwiftUI — #State:
Declaring the #State isFilled variable gives access to three
different types:
isFilled — Bool
$isFilled — Binding
_isFilled — State
The State type is the wrapper — doing all the extra work for us — that stores an underlying wrappedValue,
directly accessible using isFilled property and a projectedValue,
directly accessible using $isFilled property.
Try onChange for itemsValidationResult instead:
var body: some View {
HStack {
// ...
}
.onChange(of: itemsValidationResult) { _ in
submitFormFlag.toggle()
}
.onChange(of: submitFormFlag) { value in
print(value, "forced")
}
}
You may also consider putting the code you had in .onChange(of: submitFormFlag) inside the .onChange(of: itemsValidationResult).
Related
This is a module where the user enters transaction data and then it is saved to coreData. EntryView calls getFormData for entry of form data then calls the function saveButton() for saving the data to coreData.
Things have been working great until I recently added two additional parameters gotCountry and gotHome. These parameters are defined in EntryView. The data I want is found in getFormData but I don't want to make it available to other parts of the app until the save button is pressed (func saveButton()) hence I need to pass the data from getFormData to saveButton().
One of the two warnings is Initialization of immutable value 'gotCountry' was never used; consider replacing with assignment to '_' or removing it if I place let in front of the parameters gotCountry and gotHome in getFormData. These are variables so shouldn't have let in front of them. Removing 'let' results in the error Type '()' cannot conform to 'View'
The parameters entryDT and entryPT are coming from form input data while gotCountry and gotHome are coming from calculated data available at time of entry.
Note that I have stripped out some of the code to see the passing of data better.
struct EntryView: View {
#EnvironmentObject var ctTotals: CountryTotals
#State private var entryDT = Date()
#State private var entryPT: Int = 0
#State private var gotCountry: String = ""
#State private var gotHome: Double = 0.0
var body: some View {
VStack (alignment: .leading){
ShowTabTitle(g: g, title: "Enter Transaction")
getFormData(entryDT: $entryDT, entryPT: $entryPT, gotCountry: $gotCountry, gotHome: $gotHome)
Button {
self.saveButton() // button pressed
} label: {
Text ("Save")
}
}
}
func saveButton() {
// save entry to core data
let newEntry = CurrTrans(context: viewContext)
// entry id
newEntry.id = UUID()
// entry date
newEntry.entryDT = entryDT
// entry payment type
newEntry.entryPT = Int64(entryPT)
ctTotals.sendTotals(gotCountry: gotCountry, gotHome: gotHome)
do {
try viewContext.save()
} catch {
}
// reset parameters for next entry
self.entryDT = Date()
self.entryPT = 0
}
}
struct getFormData: View {
#Binding var entryDT: Date
#Binding var entryPT: Int
#Binding var gotCountry: String
#Binding var gotHome: Double
var body: some View {
// get entry date and time
DatePicker("", selection: $entryDT, in: ...Date())
// select payment type
Picker(selection: $entryPT, label: Text("")) {}
// copy data to totals by country
gotCountry = currencies.curItem[userData.entryCur].cunName
gotHome = totalValue
}
}
struct CtModel: Codable, Identifiable, Hashable {
var id = UUID()
var ctName: String
var ctHome: Double
}
class CountryTotals: ObservableObject {
#Published var ctItem: [CtModel] {
didSet {
if let encoded = try? JSONEncoder().encode(ctItem) {
UserDefaults.standard.set(encoded, forKey: StorageKeys.ctTotals.rawValue)
}
}
}
init() {
}
func sendTotals(gotCountry: String, gotHome: Double) -> () {
let item = CtModel(ctName: gotCountry, ctHome: gotHome)
ctItem.append(item)
}
}
I basically have the same code as in this question. The problem I have is that when the tapGesture event happens, the sheet shows (the sheet code is called) but debug shows that showUserEditor is false (in that case, how is the sheet showing...?) and that selectedUserId is still nil (and therefore crashes on unwrapping it...)
The view:
struct UsersView: View {
#Environment(\.managedObjectContext)
private var viewContext
#FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \User.nickname, ascending: true)],
animation: .default)
private var users: FetchedResults<User>
#State private var selectedUserId : NSManagedObjectID? = nil
#State private var showUserEditor = false
var body: some View {
NavigationView {
List {
ForEach(users) { user in
UserRowView(user: user)
.onTapGesture {
self.selectedUserId = user.objectID
self.showUserEditor = true
}
}
}
}.sheet(isPresented: $showUserEditor) {
UserEditorView(userId: self.selectedUserId!)
}
}
}
If you want, I can publish the editor and the row but they seem irrelevant to the question as the magic should happen in the view.
So, I still haven't figured out WHY the code posted in the question didn't work, with a pointer from #loremipsum I got a working code by using another .sheet() method, one that takes an optional Item and not a boolean flag. The code now looks like this and works, but still if anyone can explain why the posted code didn't work I'd appreciate it.
struct UsersView: View {
#Environment(\.managedObjectContext)
private var viewContext
#FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \User.nickname, ascending: true)],
animation: .default)
private var users: FetchedResults<User>
#State private var selectedUser : User? = nil
var body: some View {
NavigationView {
List {
ForEach(users) { user in
UserRowView(user: user)
.onTapGesture {
self.selectedUser = user
}
}.onDelete(perform: deleteItems)
}
}.sheet(item: $selectedUser, onDismiss: nil) { user in
UserEditorView(user: user)
}
}
}
struct == immutable and SwiftUI decides when the struct gets init and reloaded
Working with code that depends on SwiftUI updating non-wrapped variables at a very specific time is not recommended. You have no control over this process.
To make your first setup work you need to use SwiftUI wrappers for the variables
.sheet(isPresented: $showUserEditor) {
//struct == immutable SwiftUI wrappers load the entire struct when there are changes
//With your original setup this variable gets created/set when the body is loaded so the orginal value of nil is what is seen in the next View
UserEditorView1(userId: $selectedUserId)
}
struct UserEditorView1: View {
//This is what you orginal View likely looks like it won't work because of the struct being immutable and SwiftUI controlling when the struct is reloaded
//let userId: NSManagedObjectID? <---- Depends on specific reload steps
//To make it work you would use a SwiftUI wrapper so the variable gets updated when SwiftUI descides to update it which is invisible to the user
#Binding var userId: NSManagedObjectID?
//This setup though now requres you to go fetch the object somehow and put it into the View so you can edit it.
//It is unnecessary though because SwiftUI provides the .sheet init with item where the item that is set gets passed directly vs waiting for the SwiftUi update no optionals
var body: some View {
Text(userId?.description ?? "nil userId")
}
}
Your answer code doesn't work because your parameter is optional and Binding does not like optionals
struct UserEditorView2: View {
//This is the setup that you posted in the Answer code and it doesn't work becaue of the ? Bindings do not like nil. You have to create wrappers to compensate for this
//But unecessary because all CoreData objects are ObservableObjects so you dont need Binding here the Binding is built-in the object for editing the variables
#Binding var user: User?
var body: some View {
TextField("nickname", text: $user.nickname)
}
}
Now for working code with an easily editable CoreData Object
struct UsersView: View {
#Environment(\.managedObjectContext)
private var viewContext
#FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \User.nickname, ascending: true)],
animation: .default)
private var users: FetchedResults<User>
//Your list view would use the CoreData object to trigger a sheet when the new value is available. When nil there will not be a sheet available for showing
#State private var selectedUser : User? = nil
var body: some View {
NavigationView {
List {
ForEach(users) { user in
UserRowView(user: user)
.onTapGesture {
self.selectedUser = user
}
}
}
}.sheet(item: $selectedUser, onDismiss: nil) { user in //This gives you a non-optional user so you don't have to compensate for nil in the next View
UserEditorView3(user: user)
}
}
}
Then the View in the sheet would look like this
struct UserEditorView3: View {
//I mentioned the ObservedObject in my comment
#ObservedObject var user: User
var body: some View {
//If your nickname is a String? you have to compensate for that optional but it is much simpler to do it from here
TextField("nickname", text: $user.nickname.bound)
}
}
//This comes from another very popular SO question (couldn't find it to quote it) that I could not find and is necessary when CoreData does not let you define a variable as non-optional and you want to use Binding for editing
extension Optional where Wrapped == String {
var _bound: String? {
get {
return self
}
set {
self = newValue
}
}
public var bound: String {
get {
//This just give you an empty String when the variable is nil
return _bound ?? ""
}
set {
_bound = newValue.isEmpty ? nil : newValue
}
}
}
Here is my code.
Run the MainView struct, and click on the button which should update the word first to the word hello.
It does not update at all even though the logs show that the data is correctly updated. Therefore is there no way to get the view to update when a value changes inside an enum?
The only way I got it to work was a nasty hack. To try the hack just uncomment the 3 lines of commented code and try it. Is there a better way?
I looked at this similar question, but the same problem is there -> SwiftUI two-way binding to value inside ObservableObject inside enum case
struct MainView: View {
#State var selectedEnum = AnEnum.anOption(AnObservedObject(string: "first"))
// #State var update = false
var body: some View {
switch selectedEnum {
case .anOption(var value):
VStack {
switch selectedEnum {
case .anOption(let val):
Text(val.string)
}
TestView(object: Binding(get: { value }, set: { value = $0 }),
callbackToVerifyChange: callback)
}
// .id(update)
}
}
func callback() {
switch selectedEnum {
case .anOption(let value):
print("Callback function shows --> \(value.string)")
// update.toggle()
}
}
}
class AnObservedObject: ObservableObject {
#Published var string: String
init(string: String) {
self.string = string
}
}
enum AnEnum {
case anOption(AnObservedObject)
}
struct TestView: View {
#Binding var object: AnObservedObject
let callbackToVerifyChange: ()->Void
var body: some View {
Text("Tap here to change the word 'first' to 'hello'")
.border(Color.black).padding()
.onTapGesture {
print("String before tapping --> \(object.string)")
object.string = "hello"
print("String after tapping --> \(object.string)")
callbackToVerifyChange()
}
}
}
You need to declare your enum Equatable.
Let’s say I have a model class Ball, that conforms to the ObservableObject protocol, and I define an optional instance of it (var ball: Ball?).
Is there a way to trigger a NavigationLink to display its destination view based on setting the value of the optional instance? So when I self.ball = Ball(), a NavigationLink will trigger?
One problem seems to be that an optional (Type?) can’t be an #ObservedObject.
The other problem seems to be that the isActive: parameter for NavigationLink can only take a Binding<Bool>.
/// Contrived minimal example to illustrate the problem.
include SwiftUI
class Ball: ObservableObject {
#Published var colour: String = "red"
// ...
}
struct ContentView: View {
// this won’t trigger view updates when it’s set because it’s not observed:
var ball: Ball?
// this line won’t compile:
#ObservedObject var observedBall: Ball?
// 🛑 Property type 'Ball?' does not match that of the
// 'wrappedValue' property of its wrapper type 'ObservedObject'
var body: some View {
NavigationView {
VStack {
// I want this to navigate to the ballView when isActive becomes true,
// but it won’t accept the test of nil state on the optional value:
NavigationLink(
destination: BallView(ball: self.ball), isActive: self.ball != nil
) {
EmptyView()
} // 🛑 compiler error because `self.ball != nil` isn’t valid for `isActive:`
// Button user taps to set the `ball`,
// which I want to trigger the BallView to be shown.
Button(action: { self.ball = Ball() }, label: { Text("Show Ball") })
}
}
}
}
struct BallView: View {
#ObservedObject var ball: Ball
// typical view stuff here ...
}
So far, the best workaround I have for the above limitations involves:
defining another ObservableObject-conforming class as a wrapper around an Optional Ball instance,
adding a #State var ballIsSet = false (or #Binding var ballIsSet: Bool) variable to my view,
passing in that ballIsSet boolean variable to the wrapper class object,
then having a didSet function on the wrapped Ball variable that updates the passed in boolean.
Phew!
Hopefully someone knows a simpler/better way to do this…
// Still a somewhat contrived example, but it illustrates the points…
class ObservableBall: ObservableObject {
// a closure to call when the ball variable is set
private var whenSetClosure: ((Bool) -> Void)?
#Published var ball: Ball? {
didSet {
// if the closure is assigned, call it when the ball gets set
if let setClosure = self.whenSetClosure {
setClosure(self.ball != nil)
}
}
}
init(_ ball: Ball? = nil, whenSetClosure: ((Bool) -> Void)? = nil) {
self.ball = ball
self.whenSetClosure = whenSetClosure
}
}
struct ContentView: View {
#ObservedObject var observedBall = ObservableBall()
#State var ballIsSet = false
var body: some View {
NavigationView {
VStack {
// Navigate to the ballView when ballIsSet becomes true:
NavigationLink(
// we can force unwrap observedBall.ball! here
// because this only gets called if ballIsSet is true:
destination: BallView(ball: self.observedBall.ball!),
isActive: $ballIsSet
) {
EmptyView()
}
// Button user taps to set the `ball`,
// triggering the BallView to be shown.
Button(
action: { self.observedBall.ball = Ball() },
label: { Text("Show Ball") }
)
}
.onAppear {
observedBall.whenSetClosure = { isSet in
self.ballIsSet = isSet
}
}
}
}
}
You can use the init(get:set:) initializer of the Binding to activate the destination view based on the optional instance or on an arbitrary conditional logic. For example:
struct Ball {
var colour: String = "red"
// ...
}
struct ContentView: View {
#State private var ball: Ball?
var body: some View {
NavigationLink(isActive: Binding<Bool>(get: {
ball != nil
}, set: { _ in
ball = nil
})) {
DestinationView()
} label: {
EmptyView()
}
}
}
I've used struct Ball instead of class Ball: ObservableObject, since #ObservedObject ball: Ball? represents a value semantic of type Optional<Ball>, thus cannot be combined with #ObservedObject or #StateObject, which are property wrappers for reference types. The value types (i.e., Optinal<Ball>) can be used with #State or #Binding, but this is most likely not what you want with an optional observable object.
I encountered similar problem and the way I tried to get my NavigationLink to render based on a nullable object is to wrap around the NavigationLink if a optional binding to that optional object, then attach the onAppear callback to modify the isActive binding boolean to turn on the navigation link (so that it become active after it added to the view hierarchy and thus keep the transition animation)
class ObservableBall: ObservableObject {
#Published var ball: Ball?
#Published var showBallView = false
}
struct ContentView: View {
#ObservedObject var observedBall: Ball
var body: some View {
NavigationView {
VStack {
if let ball = observedBall.ball {
NavigationLink(
destination: BallView(ball: ball),
isActive: $observedBall.showBallView)
{
EmptyView()
}
.onAppear {
observedBall.showBallView = true
}
}
// Button user taps to set the `ball`,
// which I want to trigger the BallView to be shown.
Button(action: { self.observedBall.ball = Ball() }, label: { Text("Show Ball") })
}
}
}
}
struct BallView: View {
#ObservedObject var ball: Ball
// typical view stuff here ...
}
I want to allow the user to filter data in a long list to more easily find matching titles.
I have placed a TextView inside my navigation bar:
.navigationBarTitle(Text("Library"))
.navigationBarItems(trailing: TextField("search", text: $modelData.searchString)
I have an observable object which responds to changes in the search string:
class DataModel: ObservableObject {
#Published var modelData: [PDFSummary]
#Published var searchString = "" {
didSet {
if searchString == "" {
modelData = Realm.studyHallRealm.objects(PDFSummary.self).sorted(by: { $0.name < $1.name })
} else {
modelData = Realm.studyHallRealm.objects(PDFSummary.self).sorted(by: { $0.name < $1.name }).filter({ $0.name.lowercased().contains(searchString.lowercased()) })
}
}
}
Everything works fine, except I have to tap on the field after entering each letter. For some reason the focus is taken away from the field after each letter is entered (unless I tap on a suggested autocorrect - the whole string is correctly added to the string at once)
The problem is in rebuilt NavigationView completely that result in dropped text field focus.
Here is working approach. Tested with Xcode 11.4 / iOS 13.4
The idea is to avoid rebuild NavigationView based on knowledge that SwiftUI engine updates only modified views, so using decomposition we make modifications local and transfer desired values only between subviews directly not affecting top NavigationView, as a result the last kept stand.
class QueryModel: ObservableObject {
#Published var query: String = ""
}
struct ContentView: View {
// No QueryModel environment object here -
// implicitly passed down. !!! MUST !!!
var body: some View {
NavigationView {
ResultsView()
.navigationBarTitle(Text("Library"))
.navigationBarItems(trailing: SearchItem())
}
}
}
struct ResultsView: View {
#EnvironmentObject var qm: QueryModel // << injected here from top
var body: some View {
VStack {
Text("Search: \(qm.query)") // receive query string
}
}
}
struct SearchItem: View {
#EnvironmentObject var qm: QueryModel // << injected here from top
#State private var query = "" // updates only local view
var body: some View {
let text = Binding(get: { self.query }, set: {
self.query = $0; self.qm.query = $0; // transfer query string
})
return TextField("search", text: text)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView().environmentObject(QueryModel())
}
}