SwiftUI TextField not updating in sibling View (with video) - ios

I have a simple todo list demo app I built to understand the relationship between SwiftUI and Core Data. When I modify a Task in the TaskDetail view the changes are not reflected within the TextField residing in the TaskRow view. Both of these views are children of the ContentView.
Sudo Fix: If I change out TextField for Text the view is updated as expected; but, I need to edit the title attribute in Task from the row.
2nd Option: It seems like every tutorial avoids updating data inside a child view using Core Data. I can use #EnvironmentObject to sync data across views easily (with structs). However, keeping the environment data and the Core Data store synced sounds like a nightmare. I'd expect there to be an easier way :D
Video of Issue: https://youtu.be/JV-jQHpXE4Y
Code
ContentView.swift
import SwiftUI
import CoreData
struct ContentView: View {
#Environment(\.managedObjectContext) var context
#FetchRequest(entity: Task.entity(), sortDescriptors: [NSSortDescriptor(keyPath: \Task.position, ascending: true)]) var tasks: FetchedResults<Task>
init() {
print("INIT - Content View")
}
var body: some View {
NavigationView {
VStack {
todoList
newButton
}
}
}
}
extension ContentView {
var todoList: some View {
List {
ForEach(self.tasks, id: \.id) { task in
NavigationLink(destination: TaskDetail(task: task)) {
TaskRow(task: task)
}
}
.onDelete { indices in
for index in indices {
self.context.delete(self.tasks[index])
try? self.context.save()
}
}
.onMove(perform: move)
}
.navigationBarItems(trailing: EditButton())
}
var newButton: some View {
Button(action: {
self.newTask()
}, label: {
Text("Add Random Task")
}).padding([.bottom, .top], 20)
}
}
extension ContentView {
private func newTask() {
let things = ["Cook", "Clean", "Eat", "Workout", "Program"]
let newItem = Task(context: self.context)
newItem.id = UUID()
newItem.title = things.randomElement()!
newItem.position = Int64(self.tasks.count)
newItem.completed = Bool.random()
try? self.context.save()
}
private func move(from source: IndexSet, to destination: Int) {
// Make an array of items from fetched results
var revisedItems: [Task] = self.tasks.map{ $0 }
// change the order of the items in the array
revisedItems.move(fromOffsets: source, toOffset: destination )
// update the userOrder attribute in revisedItems to
// persist the new order. This is done in reverse order
// to minimize changes to the indices.
for reverseIndex in stride(from: revisedItems.count - 1, through: 0, by: -1) {
revisedItems[reverseIndex].position = Int64(reverseIndex)
try? self.context.save()
}
}
}
TaskRow.swift
import SwiftUI
import CoreData
struct TaskRow: View {
#Environment(\.managedObjectContext) var context
#ObservedObject var task: Task
#State private var title: String
init(task: Task) {
self.task = task
self._title = State(initialValue: task.title ?? "")
print("INIT - TaskRow Initialized: title=\(title), completed=\(task.completed)")
}
var body: some View {
HStack {
TextField(self.task.title ?? "", text: self.$title) {
self.task.title = self.title
self.save()
}.foregroundColor(.black)
// Text(self.task.title ?? "")
Spacer()
Text("\(self.task.position)")
Button(action: {
self.task.completed.toggle()
self.save()
}, label: {
Image(systemName: self.task.completed ? "checkmark.square" : "square")
}).buttonStyle(BorderlessButtonStyle())
}
}
}
extension TaskRow {
func save() {
try? self.context.save()
print("SAVE - TaskRow")
}
}
TaskDetail.swift
import SwiftUI
struct TaskDetail: View {
#Environment(\.managedObjectContext) var context
#ObservedObject var task: Task
#State private var title: String
init(task: Task) {
self.task = task
self._title = State(initialValue: task.title ?? "")
print("INIT - TaskDetail Initialized: title=\(title), completed=\(task.completed)")
}
var body: some View {
Form {
Section {
TextField(self.title, text: self.$title) {
self.task.title = self.title
self.save()
}.foregroundColor(.black)
}
Section {
Button(action: {
self.task.completed.toggle()
self.save()
}, label: {
Image(systemName: self.task.completed ? "checkmark.square" : "square")
}).buttonStyle(BorderlessButtonStyle())
}
}
}
}
extension TaskDetail {
func save() {
try? self.context.save()
print("SAVE - TaskDetail")
}
}
Core Data Model of Task
Edit
This has to do with the 'PlaceHolder' text (first argument) within the TextField. If I modify the Task in TaskDetail and then navigate back to ContentView it doesn't appear to update. But, if I remove the text in the row (highlight, backspace) the 'PlaceHolder' text contains the updated value.
What's strange is that exiting the app and restarting it displays the changes made in the TextField with dark font (expected behavior without the restart).

Try the following
var body: some View {
HStack {
TextField(self.task.title ?? "", text: self.$title) {
self.task.title = self.title
self.save()
}.foregroundColor(.black)
.onReceive(task.objectWillChange) { _ in // << here !!
if task.title != self.title {
task.title = self.title
}
}

It's fun to play with SwiftUI (I have no experience with it). But what I can see in most of the questions in different forums about TextField, the binding value can be created by .constant. Therefore, use this:
TextField(self.task.title ?? "", text: .constant(self.task.title!))
This should work now.
Demo in GIF:

Use #Binding instead of #State
It is important to remember that TextField is actually a SwiftUI View (via inheritance). The parent child relationship is actually TaskRow -> TextField.
#State is used for representing the 'state' of a view. While this value can be passed around, it's not meant to be written to by other views (it has a single source of truth).
In the case above, I am actually passing title (via $ prefix) to another view while expecting either the parent or child to modify the title property. #Binding supports 2 way communication between views or a property and view.
#State Apple Docs: https://developer.apple.com/documentation/swiftui/state
#Binding Apple Docs: https://developer.apple.com/documentation/swiftui/binding
Jared Sinclair's Wrapper Rules: https://jaredsinclair.com/2020/05/07/swiftui-cheat-sheet.html
Changing the TaskRow and TaskDetail views fixed the behavior:
TaskRow.swift
import SwiftUI
import CoreData
struct TaskRow: View {
#Environment(\.managedObjectContext) var context
#ObservedObject var task: Task
#Binding private var title: String
init(task: Task) {
self.task = task
self._title = Binding(get: {
return task.title ?? ""
}, set: {
task.title = $0
})
print("INIT - TaskRow Initialized: title=\(task.title ?? ""), completed=\(task.completed)")
}
var body: some View {
HStack {
TextField("Task Name", text: self.$title) {
self.save()
}.foregroundColor(.black)
Spacer()
Text("\(self.task.position)")
Button(action: {
self.task.completed.toggle()
self.save()
}, label: {
Image(systemName: self.task.completed ? "checkmark.square" : "square")
}).buttonStyle(BorderlessButtonStyle())
}
}
}
extension TaskRow {
func save() {
try? self.context.save()
print("SAVE - TaskRow")
}
}
TaskDetail.swift
import SwiftUI
struct TaskDetail: View {
#Environment(\.managedObjectContext) var context
#ObservedObject var task: Task
#Binding private var title: String
init(task: Task) {
self.task = task
self._title = Binding(get: {
return task.title ?? ""
}, set: {
task.title = $0
})
print("INIT - TaskDetail Initialized: title=\(task.title ?? ""), completed=\(task.completed)")
}
var body: some View {
Form {
Section {
TextField("Task Name", text: self.$title) {
self.save()
}.foregroundColor(.black)
}
Section {
Button(action: {
self.task.completed.toggle()
self.save()
}, label: {
Image(systemName: self.task.completed ? "checkmark.square" : "square")
}).buttonStyle(BorderlessButtonStyle())
}
}
}
}
extension TaskDetail {
func save() {
try? self.context.save()
print("SAVE - TaskDetail")
}
}

Related

SwiftUI: Checkmarks disappear when changing from one view to another using NavigationLink

I'm trying to make an app that is displaying lists with selections/checkmarks based on clicked NavigationLink. The problem I encountered is that my selections disappear when I go back to main view and then I go again inside the NavigationLink. I'm trying to save toggles value in UserDefaults but it's not working as expected. Below I'm pasting detailed and main content view.
Second view:
struct CheckView: View {
#State var isChecked:Bool = false
#EnvironmentObject var numofitems: NumOfItems
var title:String
var count: Int=0
var body: some View {
HStack{
ScrollView {
Toggle("\(title)", isOn: $isChecked)
.toggleStyle(CheckToggleStyle())
.tint(.mint)
.onChange(of: isChecked) { value in
if isChecked {
numofitems.num += 1
print(value)
} else{
numofitems.num -= 1
}
UserDefaults.standard.set(self.isChecked, forKey: "locationToggle")
}.onTapGesture {
}
.onAppear {
self.isChecked = UserDefaults.standard.bool(forKey: "locationToggle")
}
Spacer()
}.frame(maxWidth: .infinity,alignment: .topLeading)
}
}
}
Main view:
struct CheckListView: View {
#State private var menu = Bundle.main.decode([ItemsSection].self, from: "items.json")
var body: some View {
NavigationView{
List{
ForEach(menu){
section in
NavigationLink(section.name) {
VStack{
ScrollView{
ForEach(section.items) { item in
CheckView( title: item.name)
}
}
}
}
}
}
}.navigationBarHidden(true)
.navigationViewStyle(StackNavigationViewStyle())
.listStyle(GroupedListStyle())
.navigationViewStyle(StackNavigationViewStyle())
}
}
ItemsSection:
[
{
"id": "9DC6D7CB-B8E6-4654-BAFE-E89ED7B0AF94",
"name": "Africa",
"items": [
{
"id": "59B88932-EBDD-4CFE-AE8B-D47358856B93",
"name": "Algeria"
},
{
"id": "E124AA01-B66F-42D0-B09C-B248624AD228",
"name": "Angola"
}
Model:
struct ItemsSection: Codable, Identifiable, Hashable {
var id: UUID = UUID()
var name: String
var items: [CountriesItem]
}
struct CountriesItem: Codable, Equatable, Identifiable,Hashable {
var id: UUID = UUID()
var name: String
}
As allready stated in the comment you have to relate the isChecked property to the CountryItem itself. To get this to work i have changed the model and added an isChecked property. You would need to add this to the JSON by hand if the JSON allread exists.
struct CheckView: View {
#EnvironmentObject var numofitems: NumOfItems
//use a binding here as we are going to manipulate the data coming from the parent
//and pass the complete item not only the name
#Binding var item: CountriesItem
var body: some View {
HStack{
ScrollView {
//use the name and the binding to the item itself
Toggle("\(item.name)", isOn: $item.isChecked)
.toggleStyle(.button)
.tint(.mint)
// you now need the observe the isChecked inside of the item
.onChange(of: item.isChecked) { value in
if value {
numofitems.num += 1
print(value)
} else{
numofitems.num -= 1
}
}.onTapGesture {
}
Spacer()
}.frame(maxWidth: .infinity,alignment: .topLeading)
}
}
}
struct CheckListView: View {
#State private var menu = Bundle.main.decode([ItemsSection].self, from: "items.json")
var body: some View {
NavigationView{
List{
ForEach($menu){ // from here on you have to pass a binding on to the decendent views
// mark the $ sign in front of the property name
$section in
NavigationLink(section.name) {
VStack{
ScrollView{
ForEach($section.items) { $item in
//Pass the complete item to the CheckView not only the name
CheckView(item: $item)
}
}
}
}
}
}
}.navigationBarHidden(true)
.navigationViewStyle(StackNavigationViewStyle())
.listStyle(GroupedListStyle())
.navigationViewStyle(StackNavigationViewStyle())
}
}
Example JSON:
[
{
"id": "9DC6D7CB-B8E6-4654-BAFE-E89ED7B0AF94",
"name": "Africa",
"items": [
{
"id": "59B88932-EBDD-4CFE-AE8B-D47358856B93",
"name": "Algeria",
"isChecked": false
},
{
"id": "E124AA01-B66F-42D0-B09C-B248624AD228",
"name": "Angola",
"isChecked": false
}
]
}
]
Remarks:
The aproach with JSON and storing this in the bundle will prevent you from persisting the isChecked property between App launches. Because you cannot write to the Bundle from within your App. The choice will persist as long as the App is active but will be back to default as soon as you either reinstall or force quit it.
As already mentioned in the comment, I don'r see where you read back from UserDefaults, so whatever gets stored there, you don't read it. But even if so, each Toggle is using the same key, so you are overwriting the value.
Instead of using the #State var isChecked, which is used just locally, I'd create another struct item which gets the title from the json and which contains a boolean that gets initialized with false.
From what I understood, I assume a solution could look like the following code. Just a few things:
I am not sure how your json looks like, so I am not loading from a json, I add ItemSections Objects with a title and a random number of items (actually just titles again) with a function.
Instead of a print with the number of checked toggles, I added a text output on the UI. It shows you on first page the number of all checked toggles.
Instead of using UserDefaults I used #AppStorage.
To make that work you have to make Array conform to RawRepresentable you achieve that with the following code/extension (just add it once somewhere in your project)
Maybe you should thing about a ViewModel (e.g. ItemSectionViewModel), to load the data from the json and provide it to the views as an #ObservableObject.
The code for the views:
//
// CheckItem.swift
// CheckItem
//
// Created by Sebastian on 24.08.22.
//
import SwiftUI
struct ContentView: View {
var body: some View {
VStack() {
CheckItemView()
}
}
}
struct CheckItemView: View {
let testStringForTestData: String = "Check Item Title"
#AppStorage("itemSections") var itemSections: [ItemSection] = []
func addCheckItem(title: String, numberOfItems: Int) {
var itemArray: [Item] = []
for i in 0...numberOfItems {
itemArray.append(Item(title: "item \(i)"))
}
itemSections.append(ItemSection(title: title, items: itemArray))
}
func getSelectedItemsCount() -> Int{
var i: Int = 0
for itemSection in itemSections {
let filteredItems = itemSection.items.filter { item in
return item.isOn
}
i = i + filteredItems.count
}
return i
}
var body: some View {
NavigationView{
VStack() {
List(){
ForEach(itemSections.indices, id: \.self){ id in
NavigationLink(destination: ItemSectionDetailedView(items: $itemSections[id].items)) {
Text(itemSections[id].title)
}
.padding()
}
}
Text("Number of checked items: \(self.getSelectedItemsCount())")
.padding()
Button(action: {
self.addCheckItem(title: testStringForTestData, numberOfItems: Int.random(in: 0..<4))
}) {
Text("Add Item")
}
.padding()
}
}
}
}
struct ItemSectionDetailedView: View {
#Binding var items: [Item]
var body: some View {
ScrollView() {
ForEach(items.indices, id: \.self){ id in
Toggle(items[id].title, isOn: $items[id].isOn)
.padding()
}
}
}
}
struct ItemSection: Identifiable, Hashable, Codable {
var id: String = UUID().uuidString
var title: String
var items: [Item]
}
struct Item: Identifiable, Hashable, Codable {
var id: String = UUID().uuidString
var title: String
var isOn: Bool = false
}
Here the adjustment to work with #AppStorage:
extension Array: RawRepresentable where Element: Codable {
public init?(rawValue: String) {
guard let data = rawValue.data(using: .utf8),
let result = try? JSONDecoder().decode([Element].self, from: data)
else {
return nil
}
self = result
}
public var rawValue: String {
guard let data = try? JSONEncoder().encode(self),
let result = String(data: data, encoding: .utf8)
else {
return "[]"
}
return result
}
}

SwiftUI: Saving likes in CoreData for each individual cell

How do I save the like state for each individual cell? I decided to save via CoreData, but the like is saved for all cells at once.
In the Core Data model (LikedDB) there is an attribute such as isLiked
In the class there is a variable isLiked, which changes the state of the like:
class ModelsViewModel: ObservableObject{
#Published var isLiked = false
func like() {
isLiked.toggle()
}
}
This is how I save the like state from ModelsViewModel
And in label I use
struct CellView: View{
//For CoreData
#Environment(\.managedObjectContext) var managedObjectContext
#FetchRequest(entity: LikedDBE.entity(), sortDescriptors: [NSSortDescriptor(keyPath: \LikedDBE.name, ascending: true)]) var manyLikedDB: FetchedResults<LikedDBE>
//For like
#ObservedObject var cellViewModel: ModelsViewModel = ModelsViewModel()
var body: some View{
Button(action: {
let likedDBE = LikedDBE(context: self.managedObjectContext)
likedDBE.isLiked = cellViewModel.isLiked //I indicate that isLiked from CoreData = isLiked from ModelsViewModel()
do{
cellViewModel.like() //func from ModelsViewModel()
try self.managedObjectContext.save() //Save
} catch{
print(error)
}
}, label: {
Image(systemName: cellViewModel.isLiked ? "heart.fill" : "heart") //Here I use
.frame(width: 22, height: 22)
.foregroundColor(cellViewModel.isLiked ? .red : .black) //And here I use
})
And if I use cellViewModel.isLiked, then when I click like, the like is displayed only on the one I clicked on, but the state is not saved when restarting the application, if I use likedDB.isLiked, then the like is displayed on all cells at once, but the like is saved after restarting.
I want the like to be only on the cell I clicked on and it will be saved after restarting the application.
Short answer you need something like this.
Button("add", action: {
//Create a new object
let new: LikedDBE = store.create()
//Trigger a child view that observes the object
selection = new.objectID
})
It is a button that creates the object and triggers a child view so you can observe and edit it.
Long answer will be below just copy and paste all the code into your ContentView file there are comment throughout.
import SwiftUI
import CoreData
struct ContentView: View {
#StateObject var store: CoreDataPersistence = .init()
var body: some View{
LikesListView()
//This context is aware of you are in canvas/preview
.environment(\.managedObjectContext, store.context)
}
}
struct LikesListView: View {
#EnvironmentObject var store: CoreDataPersistence
#FetchRequest( sortDescriptors: [NSSortDescriptor(keyPath: \LikedDBE.name, ascending: true)]) var manyLikedDB: FetchedResults<LikedDBE>
//Control what NavigationLink is opened
#State var selection: NSManagedObjectID? = nil
var body: some View {
NavigationView{
List{
ForEach(manyLikedDB){ object in
NavigationLink(object.name ?? "no name", tag: object.objectID, selection: $selection, destination: {LikeEditView(obj: object)})
}
}.toolbar(content: {
Button("add", action: {
//Create a new object
let new: LikedDBE = store.create()
//Trigger a child view that observes the object
selection = new.objectID
})
})
}.environmentObject(store)
}
}
struct LikeEditView: View{
#EnvironmentObject var store: CoreDataPersistence
#Environment(\.dismiss) var dismiss
//Observe the CoreData object so you can see changes and make them
#ObservedObject var obj: LikedDBE
var body: some View{
TextField("name", text: $obj.name.bound).textFieldStyle(.roundedBorder)
Toggle(isOn: $obj.isLiked, label: {
Text("is liked")
})
.toolbar(content: {
ToolbarItem(placement: .navigationBarLeading){
Button("cancel", role: .destructive, action: {
store.resetStore()
dismiss()
})
}
ToolbarItem(placement: .navigationBarTrailing){
Button("save", action: {
store.update(obj)
dismiss()
})
}
})
.navigationBarBackButtonHidden(true)
}
}
struct LikesListView_Previews: PreviewProvider {
static var previews: some View {
LikesListView()
}
}
///Generic CoreData Helper not needed jsuto make stuff easy.
class CoreDataPersistence: ObservableObject{
//Use preview context in canvas/preview
//The context is for both Entities,
let context = ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" ? PersistenceController.preview.container.viewContext : PersistenceController.shared.container.viewContext
///Non observing array of objects
func getAllObjects<T: NSManagedObject>() -> [T]{
let listRequest = T.fetchRequest()
do {
return try context.fetch(listRequest).typeArray()
} catch let error {
print ("Error fetching. \(error)")
return []
}
}
///Creates an NSManagedObject of any type
func create<T: NSManagedObject>() -> T{
T(context: context)
//Can set any defaults in awakeFromInsert() in an extension for the Entity
//or override this method using the specific type
}
///Updates an NSManagedObject of any type
func update<T: NSManagedObject>(_ obj: T){
//Make any changes like a last modified variable
save()
}
///Creates a sample
func addSample<T: NSManagedObject>() -> T{
let new: T = create()
//Can add sample data here by type checking or overriding this method
return new
}
///Deletes an NSManagedObject of any type
func delete(_ obj: NSManagedObject){
context.delete(obj)
save()
}
func resetStore(){
context.rollback()
save()
}
private func save(){
do{
try context.save()
}catch{
print(error)
}
}
}
extension Optional where Wrapped == String {
var _bound: String? {
get {
return self
}
set {
self = newValue
}
}
var bound: String {
get {
return _bound ?? ""
}
set {
_bound = newValue
}
}
}

How to pass a CoreData model item into a view for editing

I have a minimal sample project at CDPassingQ
My main (ContentView) looks like:
import SwiftUI
import CoreData
struct ContentView: View {
#Environment( \.managedObjectContext ) private var viewContext
#FetchRequest( sortDescriptors: [ NSSortDescriptor( keyPath: \Item.name, ascending: true ) ],
animation: .default )
private var items: FetchedResults<Item>
var body: some View {
NavigationView {
List {
ForEach( items ) { item in
NavigationLink {
NameViewer( itemID: item.objectID )
} label: {
Text( item.name! )
}
}
.onDelete( perform: deleteItems )
}
.toolbar {
ToolbarItem( placement: .navigationBarTrailing ) {
EditButton()
}
ToolbarItem {
Button() {
print( "Add Item" )
} label: {
NavigationLink {
NameViewer();
} label: {
Label( "Add Item", systemImage: "plus" )
}
}
}
}
}
}
private func deleteItems(offsets: IndexSet) {
withAnimation {
offsets.map { items[$0] }.forEach(viewContext.delete)
do {
try viewContext.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
}
}
and NameViewer looks like:
import SwiftUI
import CoreData
enum TrustReason: String, Identifiable, CaseIterable
{
var id: UUID
{
return UUID();
}
case unknown = "Unknown";
case legalOnly = "Legal Only";
case goodLabeling = "Good Labeling";
case facilityClean = "Facility Clean";
case detailedAnswers = "Detailed Answers";
case unresponsive = "Unresponsive";
}
extension TrustReason
{
var title: String
{
switch self
{
case .unknown:
return "Unknown";
case .legalOnly:
return "Legal Only";
case .goodLabeling:
return "Good Labeling";
case .facilityClean:
return "Facility Clean";
case .detailedAnswers:
return "Detailed Answers";
case .unresponsive:
return "Unresponsive";
}
}
}
struct NameViewer: View {
#Environment( \.presentationMode ) var presentationMode
#Environment( \.managedObjectContext ) private var moc
#State private var name: String = ""
#State private var reason: TrustReason = .unknown
var itemID: NSManagedObjectID?
var body: some View {
Form {
Section( header: Text( "Information" ) ) {
TextField( "Name", text: $name )
}
Section( header: Text( "Trust" ) ) {
Picker( "Reason", selection: $reason ) {
ForEach( TrustReason.allCases ) { trustReason in
Text( trustReason.title ).tag( trustReason )
}
}
}
}
.toolbar {
Button() {
if ( saveName() ) {
self.presentationMode.wrappedValue.dismiss()
}
} label: {
Text( "Save" )
}
}
.onAppear {
print( "on appear" )
guard let theID = itemID,
let item = moc.object( with: theID ) as? Item else {
return
}
print( "passed guard" )
if let itemName = item.name {
name = itemName
}
print( name )
}
}
private func saveName() -> Bool {
let item = Item( context: moc )
do {
print( self.name )
item.name = self.name
try moc.save()
return true
} catch {
print( error )
print( error.localizedDescription )
}
self.moc.rollback();
return false
}
}
struct NameViewer_Previews: PreviewProvider {
static var previews: some View {
NameViewer()
}
}
I can create new items to be displayed in the list in ContentView.
Then, when I select an item in the list, I am passing that item to NameViewer. I can confirm that I am successfully finding the correct object in the .onAppear code.
However, there are two problems:
If I select an item in the list, the item name does not appear in the Name TextField unless I click in the text field first.'
Using .onAppear does not seem to be the right place to put that code. The reason is the Picker pushes another view onto the stack and once the item is picked, .onAppear runs again and I lose changes name to the name field.
How can I change the code to resolve these issues?
To implement the desired functionality, I would alter your architecture both on the UI and Core Data sides.
In terms of the user interface, it is best to use navigation links for displaying static data detail views and use modals to carry out data operations, such as creating and editing objects. So have one view to display object detail (e.g. NameViewer) and another to edit objects (e.g. NameEditor). Also, bind properties of your NSManagedObject subclasses directly to SwiftUI controls. Don’t create extra #State properties and then copy over the values. You’re introducing a shared state, something that SwiftUI is there to eliminate.
On the Core Data side, in order to perform create and update operations, you need to use child contexts. Any time you’re creating or updating your objects show a modal editor view with child context injected. That way if we’re unhappy with our changes, we can simply dismiss that modal and changes are magically discarded without ever needing to call rollback(), since that child context gets destroyed with the view. Since you’re now using child contexts, don’t forget to save your main view context somewhere too, like when the user navigates out of your app.
So to implement that in code, we need some structs to store our newly created objects as well as child contexts for them:
struct CreateOperation<Object: NSManagedObject>: Identifiable {
let id = UUID()
let childContext: NSManagedObjectContext
let childObject: Object
init(with parentContext: NSManagedObjectContext) {
let childContext = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
childContext.parent = parentContext
let childObject = Object(context: childContext)
self.childContext = childContext
self.childObject = childObject
}
}
struct UpdateOperation<Object: NSManagedObject>: Identifiable {
let id = UUID()
let childContext: NSManagedObjectContext
let childObject: Object
init?(
withExistingObject object: Object,
in parentContext: NSManagedObjectContext
) {
let childContext = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
childContext.parent = parentContext
guard let childObject = try? childContext.existingObject(with: object.objectID) as? Object else { return nil }
self.childContext = childContext
self.childObject = childObject
}
}
And the UI code is as follows:
struct ContentView: View {
#Environment(\.managedObjectContext) private var viewContext
#FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Item.name, ascending: true)], animation: .default
) private var items: FetchedResults<Item>
#State private var itemCreateOperation: CreateOperation<Item>?
var body: some View {
NavigationView {
List {
ForEach(items) { item in
NavigationLink {
NameViewer(item: item)
} label: {
Text(item.name ?? "")
}
}
}
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
EditButton()
Button(action: {
itemCreateOperation = CreateOperation(with: viewContext)
}) {
Label("Add Item", systemImage: "plus")
}
}
}
.sheet(item: $itemCreateOperation) { createOperation in
NavigationView {
NameEditor(item: createOperation.childObject)
.navigationTitle("New Item")
}
.environment(\.managedObjectContext, createOperation.childContext)
}
}
}
}
struct NameViewer: View {
#Environment(\.managedObjectContext) private var viewContext
#State private var itemUpdateOperation: UpdateOperation<Item>?
#ObservedObject var item: Item
var body: some View {
Form {
Section {
Text(item.name ?? "")
}
}
.navigationTitle("Item")
.toolbar {
Button("Update") {
itemUpdateOperation = UpdateOperation(withExistingObject: item, in: viewContext)
}
}
.sheet(item: $itemUpdateOperation) { updateOperation in
NavigationView {
NameEditor(item: updateOperation.childObject)
.navigationTitle("Update Item")
}
.environment(\.managedObjectContext, updateOperation.childContext)
}
}
}
struct NameEditor: View {
#Environment(\.dismiss) private var dismiss
#Environment(\.managedObjectContext) private var childContext
#ObservedObject var item: Item
var body: some View {
Form {
Section(header: Text("Information")) {
if let name = Binding($item.name) {
TextField("Name", text: name)
}
}
}
.toolbar {
Button() {
try? childContext.save()
dismiss()
} label: {
Text("Save")
}
}
}
}
For more information, see my related answers:
How do I implement a child context (CoreData) in SwiftUI
environment?
SwiftUI - Use #Binding with Core Data
NSManagedObject?

How to fill TextField with data from Core Data and update changes?

I am trying to learn how to Save, Edit and Delete data using Core Data. So far, with the help of this great community, I have managed to Save and Delete, but I don't know how to Edit and Update currently saved data.
Here is a simple example of an app I am working on. It is a list with items from Core Data. I am adding new list entries on a modal (AddItemView) and deleting them on EditItemView.
I would like to edit and update data as well on the AddItemView view.
I managed to pass data to hint of TextField, but what I wanted is:
pass the current data to text of TextField and make it editable
Save/update the data after tapping
Core Data has 1 Entity: ToDoItem. It has 1 Attribute: title (String). Codegen: Class Definition, Module: Current Product Module.
I have added some additional comments in the code.
ContentView
import SwiftUI
struct ContentView: View {
#Environment(\.managedObjectContext) var managedObjectContext
#FetchRequest(
entity: ToDoItem.entity(),
sortDescriptors: [
NSSortDescriptor(keyPath: \ToDoItem.title, ascending: true)
]
) var toDoItems: FetchedResults<ToDoItem>
#State private var show_modal: Bool = false
var body: some View {
NavigationView {
List{
ForEach(toDoItems, id: \.self) {todoItem in
NavigationLink(destination: EditItemView(todoItem: todoItem)) {
Text(todoItem.title ?? "")
.font(.headline)
}
}
}
.navigationBarTitle(Text("My List"))
.navigationBarItems(trailing:
Button(action: {
self.show_modal = true
}) {
Text("Add")
}.sheet(isPresented: self.$show_modal) {
AddItemView().environment(\.managedObjectContext, self.managedObjectContext)
}
)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext
return ContentView().environment(\.managedObjectContext, context)
}
}
AddItemView
import SwiftUI
struct AddItemView: View {
#Environment(\.presentationMode) var presentationMode
#Environment(\.managedObjectContext) var managedObjectContext
#State private var title = ""
var body: some View {
NavigationView {
ScrollView {
TextField("to do item...", text: $title)
.font(Font.system(size: 30))
Spacer()
}
.padding()
.navigationBarTitle(Text("Add Item"))
.navigationBarItems(
leading:
Button(action: {
self.presentationMode.wrappedValue.dismiss()
}) {
Text("Cancel")
},
trailing:
Button(action: {
let toDoItem = ToDoItem(context: self.managedObjectContext)
toDoItem.title = self.title
do {
try self.managedObjectContext.save()
}catch{
print(error)
}
self.presentationMode.wrappedValue.dismiss()
}) {
Text("Done")
}
)
}
}
}
struct AddItemView_Previews: PreviewProvider {
static var previews: some View {
AddItemView()
}
}
EditItemView
import SwiftUI
struct EditItemView: View {
#Environment(\.managedObjectContext) var managedObjectContext
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var todoItem: ToDoItem
//I am only using this newTitle variable, because I don't know how to properly bind TextField to todoItem.title
#State private var newTitle = ""
var body: some View {
ScrollView {
TextField(todoItem.title != nil ? "\(todoItem.title!)" : "", text: $newTitle)
//TextField("to do...", text: (todoItem.title != nil ? "\(todoItem.title!)" : ""))
//ERROR
//I need something like the above, but this gives me an error: Cannot convert value of type 'String' to expected argument type 'Binding<String>'
}
.padding()
.navigationBarTitle(Text("Edit item"))
.navigationBarItems(
trailing:
Button(action: {
print("Delete")
self.managedObjectContext.delete(self.todoItem)
do {
try self.managedObjectContext.save()
self.presentationMode.wrappedValue.dismiss()
}catch{
print(error)
}
}) {
Text("Delete")
.foregroundColor(.red)
}
)
}
}
struct EditItemView_Previews: PreviewProvider {
static var previews: some View {
let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext
//Test data
let todoItem = ToDoItem.init(context: context)
todoItem.title = "Title"
return EditItemView(todoItem: todoItem).environment(\.managedObjectContext, context)
}
}
I would do it in the following way
TextField("_here_is_label_name_", text: $newTitle, onCommit: {
self.todoItem.title = self.newTitle
try? self.managedObjectContext.save()
})
.onAppear {
self.newTitle = self.todoItem.title != nil ? "\(self.todoItem.title!)" : ""
}
.onDisappear {
self.todoItem.title = self.newTitle
try? self.managedObjectContext.save()
}
Update: added .onDisappear modifier; duplicated code can be extracted in dedicated private function to have good design.

FetchedResult views do not update after navigating away from view & back

SwiftUI FetchedResult views fail to update when you navigate away from them and return.
I have a simple todo list app I've created as an example. This app consists of 2 entities:
A TodoList, which can contain many TodoItem(s)
A TodoItem, which belongs to one TodoList
First, here are my core data models:
For the entities, I am using Class Definition in CodeGen.
There are only 4 small views I am using in this example.
TodoListView:
struct TodoListView: View {
#Environment(\.managedObjectContext) var managedObjectContext
#FetchRequest(
entity: TodoList.entity(),
sortDescriptors: []
) var todoLists: FetchedResults<TodoList>
#State var todoListAdd: Bool = false
var body: some View {
NavigationView {
List {
ForEach(todoLists, id: \.self) { todoList in
NavigationLink(destination: TodoItemView(todoList: todoList), label: {
Text(todoList.title ?? "")
})
}
}
.navigationBarTitle("Todo Lists")
.navigationBarItems(trailing:
Button(action: {
self.todoListAdd.toggle()
}, label: {
Text("Add")
})
.sheet(isPresented: $todoListAdd, content: {
TodoListAdd().environment(\.managedObjectContext, self.managedObjectContext)
})
)
}
}
}
This simply fetches all TodoList(s) and spits them out in a list. There is a button in the navigation bar which allows for adding new todo lists.
TodoListAdd:
struct TodoListAdd: View {
#Environment(\.presentationMode) var presentationMode
#Environment(\.managedObjectContext) var managedObjectContext
#State var todoListTitle: String = ""
var body: some View {
NavigationView {
Form {
TextField("Title", text: $todoListTitle)
Button(action: {
self.saveTodoList()
self.presentationMode.wrappedValue.dismiss()
}, label: {
Text("Save")
})
Button(action: {
self.presentationMode.wrappedValue.dismiss()
}, label: {
Text("Cancel")
})
}
.navigationBarTitle("Add Todo List")
}
.navigationViewStyle(StackNavigationViewStyle())
}
func saveTodoList() {
let todoList = TodoList(context: managedObjectContext)
todoList.title = todoListTitle
do { try managedObjectContext.save() }
catch { print(error) }
}
}
This simply saves a new todo list and then dismisses the modal.
TodoItemView:
struct TodoItemView: View {
#Environment(\.managedObjectContext) var managedObjectContext
var todoList: TodoList
#FetchRequest var todoItems: FetchedResults<TodoItem>
#State var todoItemAdd: Bool = false
init(todoList: TodoList) {
self.todoList = todoList
self._todoItems = FetchRequest(
entity: TodoItem.entity(),
sortDescriptors: [],
predicate: NSPredicate(format: "todoList == %#", todoList)
)
}
var body: some View {
List {
ForEach(todoItems, id: \.self) { todoItem in
Button(action: {
self.checkTodoItem(todoItem: todoItem)
}, label: {
HStack {
Image(systemName: todoItem.checked ? "checkmark.circle" : "circle")
Text(todoItem.title ?? "")
}
})
}
}
.navigationBarTitle(todoList.title ?? "")
.navigationBarItems(trailing:
Button(action: {
self.todoItemAdd.toggle()
}, label: {
Text("Add")
})
.sheet(isPresented: $todoItemAdd, content: {
TodoItemAdd(todoList: self.todoList).environment(\.managedObjectContext, self.managedObjectContext)
})
)
}
func checkTodoItem(todoItem: TodoItem) {
todoItem.checked = !todoItem.checked
do { try managedObjectContext.save() }
catch { print(error) }
}
}
This view fetches all of the TodoItem(s) that belong to the TodoList that was tapped. This is where the problem is occurring. I'm not sure if it is because of my use of init() here, but there is a bug. When you first enter this view, you can tap a todo item in order to "check" it and the changes show up in the view immediately. However, when you navigate to a different TodoItemView for a different TodoList and back, the views no longer update when tapped. The checkmark image does not show up, and you need to leave that view and then re-enter it in order for said changes to actually appear.
TodoItemAdd:
struct TodoItemAdd: View {
#Environment(\.presentationMode) var presentationMode
#Environment(\.managedObjectContext) var managedObjectContext
var todoList: TodoList
#State var todoItemTitle: String = ""
var body: some View {
NavigationView {
Form {
TextField("Title", text: $todoItemTitle)
Button(action: {
self.saveTodoItem()
self.presentationMode.wrappedValue.dismiss()
}, label: {
Text("Save")
})
Button(action: {
self.presentationMode.wrappedValue.dismiss()
}, label: {
Text("Cancel")
})
}
.navigationBarTitle("Add Todo Item")
}
.navigationViewStyle(StackNavigationViewStyle())
}
func saveTodoItem() {
let todoItem = TodoItem(context: managedObjectContext)
todoItem.title = todoItemTitle
todoItem.todoList = todoList
do { try managedObjectContext.save() }
catch { print(error) }
}
}
This simply allows the user to add a new todo item.
As I mentioned above, the views stop updating automatically when you leave and re-enter the TodoItemView. Here is a recording of this behaviour:
https://i.imgur.com/q3ceNb1.mp4
What exactly am I doing wrong here? If I'm not supposed to use init() because views in navigation links are initialized before they even appear, then what is the proper implementation?
Found the solution after hours of googling various different phrases of the issue: https://stackoverflow.com/a/58381982/10688806
You must use a "lazy view".
Code:
struct LazyView<Content: View>: View {
let build: () -> Content
init(_ build: #autoclosure #escaping () -> Content) {
self.build = build
}
var body: Content {
build()
}
}
Usage:
NavigationLink(destination: LazyView(TodoItemView(todoList: todoList)), label: {
Text(todoList.title ?? "")
})

Resources