SwiftUI view does not update when NSManagedObject is updated from CloudKit - core-data

I have a SwiftUI app with CoreData+CloudKit integrated. My data model is as follows:
MainElement <->> SubElement <-> SubElementMetadata
Each MainElement has one or more SubElements and each SubElement has exactly one SubElementMetadata.
Now I have a SwiftUI view to display the list of SubElements of a MainElement:
struct SubElementListView: View {
#ObservedObject private var mainElement: MainElement
init(mainElement: MainElement) {
self.mainElement = mainElement
}
var body: some View {
List(mainElement.subElements, id: \.self.objectID) { subElement in
VStack {
Text(subElement.title)
Text(subElement.elementMetadata.creationDateString)
}
}
}
}
The Issue:
When I update creationDateString of a SubElement somewhere in my code, the view updates to reflect the change. If, however, a change arrives over CloudKit, the view does not update.
If I navigate out and back into the view, the new data is displayed, but not while I stay on this view.
Why is this? I am aware I can probably trigger an update manually (e.g. by listening to CloudKit notifications), but I would like to understand why it doesn't "just work" - which is what I would have expected since NSManagedObject is also an ObservableObject.

Related

Adding to a one-to-many relationship not updating UI

I have a one-to-many relationship in core data of plan -> recipe. plan.recipes is of type NSSet?, so I have created custom NSManagedObject classes with computed properties to convert these into arrays, adding an extra property recipesArray:
public var recipesArray: [Recipe] {
let set = recipes as? Set<Recipe> ?? []
return set.sorted {
$0.wrappedName < $1.wrappedName
}
}
I then display this list in a View using a ForEach, using the recipesArray property. A subview of this view calls plan.addToRecipes(recipe: Recipe), to add a new object to the relationship. I then save.
The issue is, the ForEach in the parent view does not react to this addition. If I refresh the view by navigating away, then the new recipe is shown, but the View is not automatically updated when the new recipe is added.
Does anyone know how to do this? Should I be using the original recipes property instead of this custom array one?
You need to make another FetchRequest for Recipes using a predicate that equals a given plan, e.g. something like this:
struct RecipesView: View {
var fetchRequest: FetchRequest<Recipe>
var recipes: FetchedResults<Recipe> { fetchRequest.wrappedValue }
init(plan: Plan) {
let sortDescriptors = ...
let predicate = NSPredicate(format: "plan = %#", plan)
fetchRequest = FetchRequest(sortDescriptors: sortDescriptors, predicate: predicate, animation: .default)
}
var body: some View {
ForEach(recipes) { recipe in
RecipeView(recipe: recipe) // it's important to not access recipe's properties (firing a fault) until inside a sub-view's body.
}
}
}
Note: There is currently a bug in FetchRequest that results in body always invoked even when this View is init with the same plan and thus same FetchRequest. This is because the FetchRequest struct inits a new object inside it every time, causing it and consequently this View to appear as changed to SwiftUI. I reported this bug so hopefully they fix it. You could workaround it in the meantime with a wrapper View that takes the plan as a let so body won't be called.

Why does using FetchedResults work for Text and not TextField? SwiftUI, CoreData/ FetchRequest

I am getting CoreData properties from a FetchRequest and want to use it to pre-populate a text field (a user's Email).
Here is the FetchRequest
#FetchRequest(
entity: Account.entity(),
sortDescriptors:[
NSSortDescriptor(keyPath: \Account.id, ascending: true)
]
)var accounts: FetchedResults<Account>
Note I have all of the proper persistant container and #environment stuff set up to use #FetchRequest like this.
Then in my View stack I have:
var body: some View{
ZStack {
Form {
Section(header: Text("EMAIL")) {
ForEach(accounts, id: \.self) {account in
TextField("Email", text:account.email) // This is where I get an error (see error below)
}
}
}
Error is : Cannot convert value of type 'String' to expected argument type 'Binding<String>'
However is I simply list accounts in a textfield it works. Like so:
var body: some View{
ZStack {
Form {
List(accounts, id: \.self) { account in
Text(account.email ?? "Unknown")
}
}
}
Why does the second code that uses List not give me the same error?
I thought it had something to do with the ?? operator but after research I realized that it perfectly fine to do given that email in my coredata object is String?.
Now my thought is that I am getting this error here because TextField needs a Binding wrapper? If that is true I'm not sure how to get this to work. All I want to do is have this TextField pre-populated with the single email record the FetchRequest retrieves from my Account Core Data object.
Thanks.
Edit: I want to add that I have found this post https://www.hackingwithswift.com/quick-start/swiftui/how-to-fix-cannot-convert-value-of-type-string-to-expected-argument-type-binding-string
and now I think what I need to do is store this account.email result into a State variable. My question still remains however, I'm not sure how to do this as I am looking for clean way to do it right in the view stack here. Thanks again.
TextField needs a binding to a string variable. Account is and ObservableObject, like all NSManagedObjects, so if you refactor your TextField into a separate View you could do this:
Form {
Section(header: Text("EMAIL")) {
ForEach(accounts, id: \.self) { account in
Row(account: account)
}
}
}
...
struct Row: View {
#ObservedObject var account: Account
var body: some View {
TextField("Email", text: $account.email)
}
}
Note the $account.email — the $ turns this into a binding.
Unfortunately, this new code also fails, because email is a String? (i.e., it can be nil) but TextField doesn’t allow nil. Thankfully that’s not too hard to fix, because we can define a custom Binding like so:
struct Row: View {
#ObservedObject var account: Account
var email: Binding<String> {
Binding<String>(get: {
if let email = account.email {
return email
} else {
return "Unknown"
}
},
set: { account.email = $0 })
}
var body: some View {
TextField("Email", text: email)
}
}
Notice we don’t have a $ this time, because email is itself a Binding.
This answer details getting a CoreData request into a #State variable:
Update State-variable Whenever CoreData is Updated in SwiftUI
What you'll end up with is something like this:
#State var text = ""
In your view (maybe attached to your ZStack) an onReceive property that tells you when you have new CoreData to look at:
ZStack {
...
}.onReceive(accounts.publisher, perform: { _ in
//you can reference self.accounts at this point
//grab the index you want and set self.text equal to it
})
However, you'll have to do some clever stuff to make sure setting it the first time and then probably not modifying it again once the user starts typing.
This could also get complicated by the fact that you're in a list and have multiple accounts -- at this point, I may split every list item out into its own view with a separate #State variable.
Keep in mind, you can also write custom bindings like this:
Binding<String>(get: {
//return a value from your CoreData model
}, set: { newValue in })
But unless you get more clever with how you're returning in the get section, the user won't be able to edit the test. You could shadow it with another #State variable behind the scenes, too.
Finally, here's a thread on the Apple Developer forums that gets even more in-depth about possible ways to address this: https://developer.apple.com/forums/thread/128195

How to access a single core data object

In a core data entity containing multiple objects, how to access a specific object outside of using ForEach
I have tried using filtering the results with NSPredicate but that does not work even when only one object fits the description
For example, I want to be able to edit something outside the ForEach
loop, and update it into the latest core data entry
struct ContentView: View {
#FetchRequest(entity: NameData.entity(), sortDescriptors: []) var names : FetchedResults<NameData>
var body: some View{
VStack{
Text("Edit Latest Entry")
// Textfields for editing first and last names
ForEach(self.names){ name in
Text(name.firstName)
Text(name.lastName)
}
}
}
}

Editing CoreData and view's update

I'm having trouble updating my view after editing CoreData.
I have this entry in my CoreData entity named "TST" and through a NavigationLink I'm editing it's name
NavigationLink(destination: editingPage(thread: tr.title)){
Text(tr.title)}
#State var thread : String
Form {
TextField("thread", text:$thread)
}.onDisappear{
do {
try self.managedObjectContext.save()
} catch {
print(error.localizedDescription)
}
}
after editing and saving the MOC It doesn't display the changes until I either restart the app, or go back to the beginning of my navigation View.
Should I have a fetch request on all page displaying CoreData's data? or having some kind of ObservedObject ?
What I'm looking for is the same as changing your device's name in the device settings menu.
Thanks
Tim

Save string from TextField in UserDefaults with SwiftUI

How can the string from a TextField be stored in UserDefaults using SwiftUI?
I have seen this tutorial on how to save the state of a toggle in UserDefaults and that looks promising, but I can't figure out how to use the same idea for storing the content from a TextField: https://www.youtube.com/watch?v=nV-OdfQhStM&list=PLerlU8xuZ7Fk9zRMuh7JCKy1eOtdxSBut&index=3&t=329s
I still want to be able to update the string by typing new text in the TextField, but unless I do that, I want the string to be maintained in the view. Both when changing page and completely exitting the app.
For things like these I suggest you use the .debounce publisher.
import SwiftUI
import Combine
class TestViewModel : ObservableObject {
private static let userDefaultTextKey = "textKey"
#Published var text = UserDefaults.standard.string(forKey: TestViewModel.userDefaultTextKey) ?? ""
private var canc: AnyCancellable!
init() {
canc = $text.debounce(for: 0.2, scheduler: DispatchQueue.main).sink { newText in
UserDefaults.standard.set(newText, forKey: TestViewModel.userDefaultTextKey)
}
}
deinit {
canc.cancel()
}
}
struct ContentView: View {
#ObservedObject var viewModel = TestViewModel()
var body: some View {
TextField("Type something...", text: $viewModel.text)
}
}
The .debounce publisher documentation says:
Use this operator when you want to wait for a pause in the delivery of
events from the upstream publisher. For example, call debounce on the
publisher from a text field to only receive elements when the user
pauses or stops typing. When they start typing again, the debounce
holds event delivery until the next pause.
You don't really want to save the text in the UserDefaults each time the user types something (i.e. for every character he/she types). You just want the text to be ultimately saved to the UserDefaults. The debounce publisher waits for the user to stop typing according to the time you set in the debounce init (in the example above 0.2 secs) and then forwards the event to the sink subscriber that actually saves the text. Always use the debounce publisher when you have a lot of events (for example new text typed in a text field) that may trigger "heavy" operations (whatever you can think of: something related to CoreData, network calls and so forth).
You can create a custom binding as described here and call UserDefaults.standard.set when the text binding is set.
struct ContentView: View {
#State var location: String = ""
var body: some View {
let binding = Binding<String>(get: {
self.location
}, set: {
self.location = $0
// Save to UserDefaults here...
})
return VStack {
Text("Current location: \(location)")
TextField("Search Location", text: binding)
}
}
}
Copied from answer to 'TextField changes in SwiftUI'

Resources