I have a two CoreData objects:
RoadTrip
StatePlate.
Each RoadTrip items holds an NSSet of StatePlate.
Screen 1 (TripList) shows a list of all RoadTrip items. Screen 2 (StateList) shows a list of all StatePlate items in associated with the RoadTrip that a user selects. Selecting a StatePlate item in Screen 2 will toggle a bool value associated with that item.
Even though I can show the data and can toggle the bool value of each StatePlate, I am not seeing an immediate change to the UI of the screen. The StatePlate should jump from Section to Section in Screen 2 when it's bool value is toggled.
How can I pass this FetchedObject correctly from Screen 1 to Screen 2 so the UI is binded with the data?
Screen 1 (TripList)
struct TripList: View {
#Environment(\.managedObjectContext) var managedObjectContext
#FetchRequest(entity: RoadTrip.entity(), sortDescriptors: []) var roadTripItems: FetchedResults<RoadTrip>
var body: some View {
List {
ForEach(roadTripItems, id: \.self) { trip in
NavigationLink(destination: StateList(trip: trip)
.environment(\.managedObjectContext, self.managedObjectContext)) {
TripRow(roadTrip: trip)
}
}
}
}
}
Screen 2 (StateList)
struct StateList: View {
#Environment(\.managedObjectContext) var managedObjectContext
var trip: RoadTrip
var plates: [StatePlate] {
trip.plateArray
}
var unseenPlates: [StatePlate] {
trip.plateArray.filter { !$0.hasBeenSeen }
}
var seenPlates: [StatePlate] {
trip.plateArray.filter { $0.hasBeenSeen }
}
var body: some View {
List {
if !unseenPlates.isEmpty {
Section(header: Text("Unseen Plates")) {
ForEach(unseenPlates, id: \.self) { plate in
StateRow(plate: plate)
}
}
}
if !seenPlates.isEmpty {
Section(header: Text("Seen Plates")) {
ForEach(seenPlates, id: \.self) { plate in
StateRow(plate: plate)
}
}
}
}
}
}
StateRow
struct StateRow: View {
#Environment(\.managedObjectContext) var managedObjectContext
#ObservedObject var plate: StatePlate
var body: some View {
Button(action: {
self.plate.hasBeenSeen.toggle()
try? self.managedObjectContext.save()
}) {
HStack {
Text(String(describing: plate.name!))
Spacer()
if plate.hasBeenSeen {
Image(systemName: "eye.fill")
} else {
Image(systemName: "")
}
}
}
}
}
Your trip as object is not changed when plate has changed, so even if it was observed UI was not refreshed.
Here is possible force-refresh approach.
struct StateList: View {
#Environment(\.managedObjectContext) var managedObjectContext
#ObservedObject var trip: RoadTrip // << make observed
// .. other code
and add handling for updated plate/s
StateRow(plate: plate)
.onReceive(plate.objectWillChange) { _ in
self.trip.objectWillChange.send()
}
Related
I'm trying to create a master detail relationship with CoreData. I have a settings tab that is used to select the master (it's global and not done very often by the user). There is another tab that shows the detail entries for the current master.
The master has one field, name, a string and the details array. The detail has one field, name, a string. I'm using UUID().uuidString to populate the names for the example.
The problem I'm having is that when I select the detail tab, it shows the details for the current master. If I add details (click the + button) they do not appear until I change the master (settings -> select master). If I edit the details and delete some, the list entries go away but when I finish editing, they immediately come back. I can switch masters and then go back to the edited master and the data looks correct (I have to change the activeMaster published property).
I'm thinking that the published property isn't forcing the update to the details view because swift doesn't see the master variable change. I may also not be adding or deleting the details correctly.
How is adding details to a master typically done (here master is one to many details)
How is deleting details from a master typically done?
Is the data no showing up due to the published property not "publishing" Any ideas on how to better do this?
Thanks.
Code is below.
Here's the global application data:
import Foundation
import CoreData
import SwiftUI
class ApplicationData: ObservableObject
{
let container: NSPersistentContainer
#Published var activeMaster: Master?
init(preview: Bool = false)
{
container = NSPersistentContainer(name: "MasterDetail")
if (preview)
{
container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
}
container.loadPersistentStores(completionHandler:
{ storeDescription, error in
if let error = error as NSError?
{
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
}
}
Just persistence and a single optional active master. The application data is created in the application code and set as an environment object:
import SwiftUI
#main
struct MasterDetailApp: App
{
#StateObject var appData = ApplicationData()
var body: some Scene {
WindowGroup {
MainView()
.environmentObject(appData)
.environment(\.managedObjectContext, appData.container.viewContext)
}
}
}
The tab view:
import Foundation
import SwiftUI
struct MainView: View
{
#AppStorage("selectedTab") var selectedTab: Int = 0
#EnvironmentObject var appData: ApplicationData
var body: some View
{
TabView(selection: $selectedTab)
{
DetailView()
.tabItem({Label("Detail", systemImage: "house")})
.tag(0)
SettingsView()
.tabItem({Label("Settings", systemImage: "gear")})
.tag(1)
}
.environment(\.managedObjectContext, appData.container.viewContext)
}
}
The detail tab allows the user to add details and to edit the list:
import Foundation
import SwiftUI
import CoreData
struct DetailView: View
{
#Environment(\.managedObjectContext) private var viewContext
#Environment(\.dismiss) var dismiss
#EnvironmentObject var appData: ApplicationData
var body: some View
{
NavigationView
{
List
{
ForEach(appData.activeMaster?.wrappedDetail ?? [])
{
detail in Text(detail.name ?? "None")
}
.onDelete(perform: { indexes in Task(priority: .high) { await deleteDetails(indexes: indexes) } } )
}
.toolbar
{
ToolbarItem(placement: .navigationBarTrailing)
{
EditButton()
}
ToolbarItem(placement: .navigationBarTrailing)
{
Button
{
let detail = Detail(context: viewContext)
detail.name = UUID().uuidString
detail.master = appData.activeMaster
do
{
try viewContext.save()
}
catch
{
print("Error adding master")
}
} label: { Image(systemName: "plus") }
.disabled(appData.activeMaster == nil)
}
}
}
}
/*
* Delete indexes - assumes that appData.activeWeapon is set.
*/
private func deleteDetails(indexes: IndexSet) async
{
await viewContext.perform
{
for index in indexes
{
print(index)
viewContext.delete(appData.activeMaster!.wrappedDetail[index])
}
do
{
try viewContext.save()
}
catch
{
print("Error deleting dope entry")
}
}
}
}
The settings view just has a navigation link to a view to select the master and an add button to add masters:
import Foundation
import SwiftUI
struct SettingsView: View
{
#Environment(\.managedObjectContext) private var viewContext
#EnvironmentObject var appData: ApplicationData
var body: some View
{
NavigationView
{
Form
{
Section(header: Text("Masters"))
{
NavigationLink(destination: SelectMastersView(selectedMaster: $appData.activeMaster), label:
{
Text(appData.activeMaster?.name ?? "Select Master")
})
Button
{
let master = Master(context: viewContext)
master.name = UUID().uuidString
do
{
try viewContext.save()
}
catch
{
print("Error adding master")
}
} label: { Image(systemName: "plus") }
}
}
}
}
}
The view for selecting the master just has a fetch request to get all masters and assign the selected one to the global app data published property:
import Foundation
import SwiftUI
struct SelectMastersView: View
{
#Environment(\.dismiss) var dismiss
#FetchRequest(entity: Master.entity(), sortDescriptors: [], animation: .default)
var masters: FetchedResults<Master>
#Binding var selectedMaster: Master?
var body: some View
{
List
{
ForEach(masters)
{ master in
Text(master.name ?? "None")
.onTapGesture
{
selectedMaster = master
dismiss()
}
}
}
.navigationBarTitle("Masters")
}
}
Edited to add extension to Master I forgot to post.
import Foundation
extension Master
{
var wrappedDetail: [Detail]
{
detail?.allObjects as! [Detail]
}
}
I finally figured it out this morning. I think putting the example code together last night helped quite a bit.
I got it work by creating fetch request in the detail view and passing the master into the view in init().
Here's the updated code for the tab view:
import Foundation
import SwiftUI
struct MainView: View
{
#AppStorage("selectedTab") var selectedTab: Int = 0
#EnvironmentObject var appData: ApplicationData
var body: some View
{
TabView(selection: $selectedTab)
{
DetailView(master: appData.activeMaster)
.tabItem({Label("Detail", systemImage: "house")})
.tag(0)
SettingsView()
.tabItem({Label("Settings", systemImage: "gear")})
.tag(1)
}
.environment(\.managedObjectContext, appData.container.viewContext)
}
}
and the updated detail view:
import Foundation
import SwiftUI
import CoreData
struct DetailView: View
{
#Environment(\.managedObjectContext) private var viewContext
#Environment(\.dismiss) var dismiss
#EnvironmentObject var appData: ApplicationData
#FetchRequest(entity: Detail.entity(), sortDescriptors: [])
var details: FetchedResults<Detail>
let master: Master?
init(master: Master?)
{
self.master = master
if master != nil
{
let predicate = NSPredicate(format: "%K == %#", #keyPath(Detail.master), master ?? NSNull())
_details = FetchRequest(sortDescriptors: [], predicate: predicate)
}
}
#ViewBuilder
var body: some View
{
NavigationView
{
List
{
if master != nil
{
ForEach(details)
{
detail in Text(detail.name ?? "None")
}
.onDelete(perform: { indexes in Task(priority: .high) { await deleteDetails(indexes: indexes) } } )
}
}
.toolbar
{
ToolbarItem(placement: .navigationBarTrailing)
{
EditButton().disabled(master == nil || details.isEmpty)
}
ToolbarItem(placement: .navigationBarTrailing)
{
Button
{
let detail = Detail(context: viewContext)
detail.name = UUID().uuidString
detail.master = appData.activeMaster
do
{
try viewContext.save()
}
catch
{
print("Error adding master")
}
} label: { Image(systemName: "plus") }
.disabled(appData.activeMaster == nil)
}
}
}
}
/*
* Delete indexes - assumes that appData.activeWeapon is set.
*/
private func deleteDetails(indexes: IndexSet) async
{
await viewContext.perform
{
for index in indexes
{
print(index)
viewContext.delete(appData.activeMaster!.wrappedDetail[index])
}
do
{
try viewContext.save()
}
catch
{
print("Error deleting dope entry")
}
}
}
}
This is not as clean as I'd like. I had to move to a view build for the list. I'd like to be able to create an empty fetch request so I don't have to use a view builder.
self-learning beginner here. I am trying to show an Int from Core Data in a VStack in ContentView, not in a List. But literally all the tutorials I can find about Core Data (tracking Books, Movies, Orders, Students) are using a List to show an array containing an Int. Nothing on showing an Int by itself.
Xcode can build countnum.countnum +=1 with no problem. Seems to me it is reading it fine. But once I try to show it, it just doesn’t work. I’m wrecking my brain here.
struct ContentView: View {
#Environment(\.managedObjectContext) var moc
#FetchRequest(sortDescriptors: []) var countnum: FetchedResults<CountNum>
var body: some View {
// let countnum = CountNum(context: moc)
VStack{
Text("+")
.padding()
.onTapGesture (count: 2){
let countnum = CountNum(context: moc)
countnum.countnum += 1
}
Text("\(countnum)") //No exact matches in call to instance method 'appendInterpolation'
}
}
}
Thanks
....all the tutorials ... show an array containing an Int. Yes, that's because CoreData
can contain many "objects". You get an array of your CountNum objects when
you do your .....var countnum: FetchedResults<CountNum>. So you need to decide which CountNum you want to
use. For example, if you want to use the first one, then:
struct ContentView: View {
#Environment(\.managedObjectContext) var moc
#FetchRequest(sortDescriptors: []) var countnum: FetchedResults<CountNum>
var body: some View {
VStack {
if let firstItem = countnum.first {
Text("+")
.padding()
.onTapGesture(count: 2) {
firstItem.countnum += 1
do {
try moc.save()
} catch {
print(error)
}
}
Text("\(firstItem.countnum)").foregroundColor(.green)
}
}
}
}
EDIT-1: adding new CountNum to CoreData example code in the add button.
struct ContentView: View {
#Environment(\.managedObjectContext) var moc
#FetchRequest(sortDescriptors: []) var countnum: FetchedResults<CountNum>
var body: some View {
Button(action: {add()}) { Text("add new CountNum").foregroundColor(.green) }
.padding(.top, 50)
List {
ForEach(countnum) { item in
HStack {
Text("++")
.onTapGesture(count: 2) { increment(item) }
Text("\(item.countnum)").foregroundColor(.blue)
Text("delete").foregroundColor(.red)
.onTapGesture { delete(item: item) }
}
}
}
}
func increment(_ item: CountNum) {
item.countnum += 1
save()
}
func add() {
let countnum = CountNum(context: moc)
countnum.countnum = 0
save()
}
func delete(item: CountNum) {
moc.delete(item)
save()
}
func save() {
do { try moc.save() } catch { print(error) }
}
}
When using the .searchable(text: $text) function, a cancel button appears in the search bar when searching.
Is there any way to perform an action when the cancel button is clicked? I would like to call a function when cancel is clicked, but cannot figure out how to perform an action when cancel is tapped.
The Apple Documentation does not mention anything about this. Back in UIKit there was the func searchBarCancelButtonClicked(searchBar: UISearchBar) { to do this.
Below is an image of the cancel button I am referring to:
You can use the isSearching environment value (https://developer.apple.com/documentation/swiftui/environmentvalues/issearching?changes=_6) to see if a search is being performed. To do an action upon cancelation, you could watch for a change from true to false using onChange:
struct ContentView: View {
#State private var searchText = ""
#Environment(\.dismissSearch) var dismissSearch
var body: some View {
NavigationView {
VStack {
ChildView()
Text("Searching for \(searchText)")
}
.searchable(text: $searchText)
.navigationTitle("Searchable Example")
}
}
}
struct ChildView : View {
#Environment(\.isSearching) var isSearching
var body: some View {
Text("Child")
.onChange(of: isSearching) { newValue in
if !newValue {
print("Searching cancelled")
}
}
}
}
Probably important to note that it seems like isSearching has to be inside a child view of the searchable modifier in order for it to work properly
Based on #jnpdx 's answer, something equivalent, but more generic is:
struct SearchView<Content: View>: View {
#Environment(\.isSearching) var isSearching
let content: (Bool) -> Content
var body: some View {
content(isSearching)
}
init(#ViewBuilder content: #escaping (Bool) -> Content) {
self.content = content
}
}
And then, use it like:
struct ContentView: View {
#State private var searchText = ""
#Environment(\.dismissSearch) var dismissSearch
var body: some View {
NavigationView {
VStack {
SearchView { isSearching in
Text("Child")
.onChange(of: isSearching) { newValue in
if !newValue {
print("Searching cancelled")
}
}
}
Text("Searching for \(searchText)")
}
.searchable(text: $searchText)
.navigationTitle("Searchable Example")
}
}
}
use isEmpty and onAppear.
struct SearchView: View {
#State var text: String = ""
var body: some View {
NavigationView {
VStack {
if text.isEmpty {
main.onAppear {
print("empty")
// code here
}
} else {
main
}
}.searchable(text: $text)
.onSubmit(of: .search) {
print("submit")
}
}
}
var main: some View {
Text("search").searchable(text: $text)
}
}
I am using a TabView/NavigationView & NavigationLink to programmatically navigate from a list view to detailed view and when I update the boolean property 'pinned' to true & save the core data entity in the detailed view, I am getting the following unfortunate side effects:
an involuntary navigation back to the list view and then again back to the detailed view or
an involuntary navigation to another copy of the same detailed view and then back to the list view.
I have prepared a small Xcode project with the complete sample code
In the list view I use #FetchRequest to query the list and sort on the following:
#FetchRequest(entity: Task.entity(),sortDescriptors: [NSSortDescriptor(key: "pinned", ascending: false),
NSSortDescriptor(key: "created", ascending: true),
NSSortDescriptor(key: "name", ascending: true)])
In the list view I use the following:
List() {
ForEach(tasks, id: \.self) { task in
NavigationLink(destination: DetailsView(task: task), tag: task.id!.uuidString, selection: self.$selectionId) {
HStack() {
...
}
}
}
.
(1) If I omit the 'NSSortDescriptor(key: "pinned" ...)' I don't see the behavior.
(2) If I omit the 'tag:' and the 'selection:' parameters in the NavigationLink() I don't see the behavior. But I need to be able to trigger the navigation link programmatically when I create a new Task entity.
(3) It seems never to happen when I have a single entity in the list or changing the value of the 'pinned' boolean property in the first entity in the list.
(4) I get the warning:
[TableView] Warning once only: UITableView was told to layout its visible cells and other contents without being in the view hierarchy (the table view or one of its superviews has not been added to a window)...
The parent view to the list view (TasksListView) contains a TabView:
struct ContentView: View {
var body: some View {
TabView {
NavigationView {
TasksListView()
}
.tabItem {
Image(systemName: "tray.full")
.font(.title)
Text("Master")
}
NavigationView {
EmptyView()
}
.tabItem {
Image(systemName: "magnifyingglass")
.font(.title)
Text("Search")
}
}
}
}
struct TasksListView: View {
// NSManagedObjectContext
#Environment(\.managedObjectContext) var viewContext
// Results of fetch request for tasks:
#FetchRequest(entity: Task.entity(),sortDescriptors: [NSSortDescriptor(key: "pinned", ascending: false),
NSSortDescriptor(key: "created", ascending: true),
NSSortDescriptor(key: "name", ascending: true)])
var tasks: FetchedResults<Task>
// when we create a new task and navigate to it programitically
#State var selectionId : String?
#State var newTask : Task?
var body: some View {
List() {
ForEach(tasks, id: \.self) { task in
NavigationLink(destination: DetailsView(task: task), tag: task.id!.uuidString, selection: self.$selectionId) {
HStack() {
VStack(alignment: .leading) {
Text("\(task.name ?? "unknown")")
.font(Font.headline.weight(.light))
.padding(.bottom,5)
Text("Created:\t\(task.created ?? Date(), formatter: Self.dateFormatter)")
.font(Font.subheadline.weight(.light))
.padding(.bottom,5)
if task.due != nil {
Text("Due:\t\t\(task.due!, formatter: Self.dateFormatter)")
.font(Font.subheadline.weight(.light))
.padding(.bottom,5)
}
}
}
}
}
}
.navigationBarTitle(Text("Tasks"),displayMode: .inline)
.navigationBarItems(trailing: rightButton)
}
var rightButton: some View {
Image(systemName: "plus.circle")
.foregroundColor(Color(UIColor.systemBlue))
.font(.title)
.contentShape(Rectangle())
.onTapGesture {
// create a new task and navigate to it's detailed view to add values
Task.create(in: self.viewContext) { (task, success, error) in
if success {
self.newTask = task
self.selectionId = task!.id!.uuidString
}
}
}
}
}
struct DetailsView: View {
// NSManagedObjectContext
#Environment(\.managedObjectContext) var viewContext
#ObservedObject var task : Task
#State var name : String = ""
#State var dueDate : Date = Date()
#State var hasDueDate : Bool = false
#State var isPinned : Bool = false
var body: some View {
List() {
Section() {
Toggle(isOn: self.$isPinned) {
Text("Pinned")
}
}
Section() {
TextField("Name", text: self.$name)
.font(Font.headline.weight(.light))
Text("\(task.id?.uuidString ?? "unknown")")
.font(Font.headline.weight(.light))
}
Section() {
HStack() {
Text("Created")
Spacer()
Text("\(task.created ?? Date(), formatter: Self.dateFormatter)")
.font(Font.subheadline.weight(.light))
}
Toggle(isOn: self.$hasDueDate) {
Text("Set Due Date")
}
if self.hasDueDate {
DatePicker("Due Date", selection: self.$dueDate, in: Date()... , displayedComponents: [.hourAndMinute, .date])
}
}
}
.navigationBarTitle(Text("Task Details"),displayMode: .inline)
.navigationBarItems(trailing: rightButton)
.listStyle(GroupedListStyle())
.onAppear() {
if self.task.pinned {
self.isPinned = true
}
if self.task.name != nil {
self.name = self.task.name!
}
if self.task.due != nil {
self.dueDate = self.task.due!
self.hasDueDate = true
}
}
}
// save button
var rightButton: some View {
Button("Save") {
// save values in task & save:
self.task.pinned = self.isPinned
if self.hasDueDate {
self.task.due = self.dueDate
}
if self.name.count > 0 {
self.task.name = self.name
}
Task.save(in: self.viewContext) { (success, error) in
DispatchQueue.main.async {
if success {
print("Task saved")
}
else {
print("****** Error: Task can't be saved, error = \(error!.localizedDescription)")
}
}
}
}
.contentShape(Rectangle())
}
}
extension Task {
static func save(in managedObjectContext: NSManagedObjectContext, completion: #escaping (Bool, NSError?) -> Void ) {
managedObjectContext.performAndWait() {
do {
try managedObjectContext.save()
completion(true, nil)
} catch {
let nserror = error as NSError
print("****** Error: Unresolved error \(nserror), \(nserror.userInfo)")
completion(false, nserror)
}
}
}
}
Any suggestions?
You seem to be creating your tab view in a different way than me. Those extra navigation views made cause an issue.
Not sure if it will help or not but I do it like that:
struct ContentView: View {
var body: some View {
TabView {
TasksListView()
.tabItem {
Image(systemName: "tray.full")
.font(.title)
Text("Master")
}
EmptyView()
.tabItem {
Image(systemName: "magnifyingglass")
.font(.title)
Text("Search")
}
}
}
}
This is a bug in iOS 13.
It has been fixed since iOS 14.0 beta 3.
You will find a similar question here (the accepted answer provides a workaround):
Issue when rearranging List item in detail view using SwiftUI Navigation View and Sorted FetchRequest
In a SwiftUI View i have a List based on #FetchRequest showing data of a Primary entity and the via relationship connected Secondary entity.
The View and its List is updated correctly, when I add a new Primary entity with a new related secondary entity.
The problem is, when I update the connected Secondary item in a detail view, the database gets updated, but the changes are not reflected in the Primary List.
Obviously, the #FetchRequest does not get triggered by the changes in another View.
When I add a new item in the primary view thereafter, the previously changed item gets finally updated.
As a workaround, i additionally update an attribute of the Primary entity in the detail view and the changes propagate correctly to the Primary View.
My question is:
How can I force an update on all related #FetchRequests in SwiftUI Core Data?
Especially, when I have no direct access to the related entities/#Fetchrequests?
import SwiftUI
extension Primary: Identifiable {}
// Primary View
struct PrimaryListView: View {
#Environment(\.managedObjectContext) var context
#FetchRequest(
entity: Primary.entity(),
sortDescriptors: [NSSortDescriptor(key: "primaryName", ascending: true)]
)
var fetchedResults: FetchedResults<Primary>
var body: some View {
List {
ForEach(fetchedResults) { primary in
NavigationLink(destination: SecondaryView(primary: primary)) {
VStack(alignment: .leading) {
Text("\(primary.primaryName ?? "nil")")
Text("\(primary.secondary?.secondaryName ?? "nil")").font(.footnote).foregroundColor(.secondary)
}
}
}
}
.navigationBarTitle("Primary List")
.navigationBarItems(trailing:
Button(action: {self.addNewPrimary()} ) {
Image(systemName: "plus")
}
)
}
private func addNewPrimary() {
let newPrimary = Primary(context: context)
newPrimary.primaryName = "Primary created at \(Date())"
let newSecondary = Secondary(context: context)
newSecondary.secondaryName = "Secondary built at \(Date())"
newPrimary.secondary = newSecondary
try? context.save()
}
}
struct PrimaryListView_Previews: PreviewProvider {
static var previews: some View {
let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext
return NavigationView {
PrimaryListView().environment(\.managedObjectContext, context)
}
}
}
// Detail View
struct SecondaryView: View {
#Environment(\.presentationMode) var presentationMode
var primary: Primary
#State private var newSecondaryName = ""
var body: some View {
VStack {
TextField("Secondary name:", text: $newSecondaryName)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding()
.onAppear {self.newSecondaryName = self.primary.secondary?.secondaryName ?? "no name"}
Button(action: {self.saveChanges()}) {
Text("Save")
}
.padding()
}
}
private func saveChanges() {
primary.secondary?.secondaryName = newSecondaryName
// TODO: ❌ workaround to trigger update on primary #FetchRequest
primary.managedObjectContext.refresh(primary, mergeChanges: true)
// primary.primaryName = primary.primaryName
try? primary.managedObjectContext?.save()
presentationMode.wrappedValue.dismiss()
}
}
I also struggled with this and found a very nice and clean solution:
You have to wrap the row in a separate view and use #ObservedObject in that row view on the entity.
Here's my code:
WineList:
struct WineList: View {
#FetchRequest(entity: Wine.entity(), sortDescriptors: [
NSSortDescriptor(keyPath: \Wine.name, ascending: true)
]
) var wines: FetchedResults<Wine>
var body: some View {
List(wines, id: \.id) { wine in
NavigationLink(destination: WineDetail(wine: wine)) {
WineRow(wine: wine)
}
}
.navigationBarTitle("Wines")
}
}
WineRow:
struct WineRow: View {
#ObservedObject var wine: Wine // !! #ObserveObject is the key!!!
var body: some View {
HStack {
Text(wine.name ?? "")
Spacer()
}
}
}
You need a Publisher which would generate event about changes in context and some state variable in primary view to force view rebuild on receive event from that publisher.
Important: state variable must be used in view builder code, otherwise rendering engine would not know that something changed.
Here is simple modification of affected part of your code, that gives behaviour that you need.
#State private var refreshing = false
private var didSave = NotificationCenter.default.publisher(for: .NSManagedObjectContextDidSave)
var body: some View {
List {
ForEach(fetchedResults) { primary in
NavigationLink(destination: SecondaryView(primary: primary)) {
VStack(alignment: .leading) {
// below use of .refreshing is just as demo,
// it can be use for anything
Text("\(primary.primaryName ?? "nil")" + (self.refreshing ? "" : ""))
Text("\(primary.secondary?.secondaryName ?? "nil")").font(.footnote).foregroundColor(.secondary)
}
}
// here is the listener for published context event
.onReceive(self.didSave) { _ in
self.refreshing.toggle()
}
}
}
.navigationBarTitle("Primary List")
.navigationBarItems(trailing:
Button(action: {self.addNewPrimary()} ) {
Image(systemName: "plus")
}
)
}
An alternative method: using a Publisher and List.id():
struct ContentView: View {
/*
#FetchRequest...
*/
private var didSave = NotificationCenter.default.publisher(for: .NSManagedObjectContextDidSave) //the publisher
#State private var refreshID = UUID()
var body: some View {
List {
...
}
.id(refreshID)
.onReceive(self.didSave) { _ in //the listener
self.refreshID = UUID()
print("generated a new UUID")
}
}
}
Every time you call save() of NSManagedObjects in a context, it genertates a new UUID for the List view, and it forces the List view to refresh.
To fix that you have to add #ObservedObject to var primary: Primary in SecondaryView to work List properly. Primary belong to NSManagedObject class, which already conforms to #ObservableObject protocol. This way the changes in instances of Primary are observed.
import SwiftUI
extension Primary: Identifiable {}
// Primary View
struct PrimaryListView: View {
#Environment(\.managedObjectContext) var context
#FetchRequest(
entity: Primary.entity(),
sortDescriptors: [NSSortDescriptor(key: "primaryName", ascending: true)]
)
var fetchedResults: FetchedResults<Primary>
var body: some View {
List {
ForEach(fetchedResults) { primary in
NavigationLink(destination: SecondaryView(primary: primary)) {
VStack(alignment: .leading) {
Text("\(primary.primaryName ?? "nil")")
Text("\(primary.secondary?.secondaryName ?? "nil")").font(.footnote).foregroundColor(.secondary)
}
}
}
}
.navigationBarTitle("Primary List")
.navigationBarItems(trailing:
Button(action: {self.addNewPrimary()} ) {
Image(systemName: "plus")
}
)
}
private func addNewPrimary() {
let newPrimary = Primary(context: context)
newPrimary.primaryName = "Primary created at \(Date())"
let newSecondary = Secondary(context: context)
newSecondary.secondaryName = "Secondary built at \(Date())"
newPrimary.secondary = newSecondary
try? context.save()
}
}
struct PrimaryListView_Previews: PreviewProvider {
static var previews: some View {
let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext
return NavigationView {
PrimaryListView().environment(\.managedObjectContext, context)
}
}
}
// Detail View
struct SecondaryView: View {
#Environment(\.presentationMode) var presentationMode
#ObservedObject var primary: Primary
#State private var newSecondaryName = ""
var body: some View {
VStack {
TextField("Secondary name:", text: $newSecondaryName)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding()
.onAppear {self.newSecondaryName = self.primary.secondary?.secondaryName ?? "no name"}
Button(action: {self.saveChanges()}) {
Text("Save")
}
.padding()
}
}
private func saveChanges() {
primary.secondary?.secondaryName = newSecondaryName
try? primary.managedObjectContext?.save()
presentationMode.wrappedValue.dismiss()
}
}
I tried to touch the primary object in the detail view like this:
// TODO: ❌ workaround to trigger update on primary #FetchRequest
if let primary = secondary.primary {
secondary.managedObjectContext?.refresh(primary, mergeChanges: true)
}
Then the primary list will update. But the detail view has to know about the parent object. This will work, but this is probably not the SwiftUI or Combine way...
Edit:
Based on the above workaround, I modified my project with a global save(managedObject:) function. This will touch all related Entities, thus updating all relevant #FetchRequest's.
import SwiftUI
import CoreData
extension Primary: Identifiable {}
// MARK: - Primary View
struct PrimaryListView: View {
#Environment(\.managedObjectContext) var context
#FetchRequest(
sortDescriptors: [
NSSortDescriptor(keyPath: \Primary.primaryName, ascending: true)]
)
var fetchedResults: FetchedResults<Primary>
var body: some View {
print("body PrimaryListView"); return
List {
ForEach(fetchedResults) { primary in
NavigationLink(destination: SecondaryView(secondary: primary.secondary!)) {
VStack(alignment: .leading) {
Text("\(primary.primaryName ?? "nil")")
Text("\(primary.secondary?.secondaryName ?? "nil")")
.font(.footnote).foregroundColor(.secondary)
}
}
}
}
.navigationBarTitle("Primary List")
.navigationBarItems(trailing:
Button(action: {self.addNewPrimary()} ) {
Image(systemName: "plus")
}
)
}
private func addNewPrimary() {
let newPrimary = Primary(context: context)
newPrimary.primaryName = "Primary created at \(Date())"
let newSecondary = Secondary(context: context)
newSecondary.secondaryName = "Secondary built at \(Date())"
newPrimary.secondary = newSecondary
try? context.save()
}
}
struct PrimaryListView_Previews: PreviewProvider {
static var previews: some View {
let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext
return NavigationView {
PrimaryListView().environment(\.managedObjectContext, context)
}
}
}
// MARK: - Detail View
struct SecondaryView: View {
#Environment(\.presentationMode) var presentationMode
var secondary: Secondary
#State private var newSecondaryName = ""
var body: some View {
print("SecondaryView: \(secondary.secondaryName ?? "")"); return
VStack {
TextField("Secondary name:", text: $newSecondaryName)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding()
.onAppear {self.newSecondaryName = self.secondary.secondaryName ?? "no name"}
Button(action: {self.saveChanges()}) {
Text("Save")
}
.padding()
}
}
private func saveChanges() {
secondary.secondaryName = newSecondaryName
// save Secondary and touch Primary
(UIApplication.shared.delegate as! AppDelegate).save(managedObject: secondary)
presentationMode.wrappedValue.dismiss()
}
}
extension AppDelegate {
/// save and touch related objects
func save(managedObject: NSManagedObject) {
let context = persistentContainer.viewContext
// if this object has an impact on related objects, touch these related objects
if let secondary = managedObject as? Secondary,
let primary = secondary.primary {
context.refresh(primary, mergeChanges: true)
print("Primary touched: \(primary.primaryName ?? "no name")")
}
saveContext()
}
}
If you are here, i don't find the reason why your view isn't updating, i think this will help you:
Always use the #ObservedObject when you declare a core data type.
If you are using MVVM, wrap the view model also with #ObservedObject, and in the VM create the core data type with #Published.
This is an example of creating a VM with #ObservedObject, so when core data receives the update, the instance of the view model recreate itself, and the view is updated.
class ProductTitleValueViewModel: BaseViewModel, ObservableObject {
// MARK: - Properties
#Published var product: Product
var colorSet: [Color]
var currency: Currency
// MARK: - Init
init(product: Product, colorSet: [Color], currency: Currency) {
self.product = product
self.colorSet = colorSet
self.currency = currency
}
}
struct ProductTitleValueView: View {
#ObservedObject var viewModel: ProductTitleValueViewModel
var body: some View {
VStack(alignment: .leading, spacing: 5) {
HStack {
Circle()
.fill(
LinearGradient(colors: viewModel.colorSet, startPoint: .leading, endPoint: .trailing)
)
.opacity(0.6)
.frame(width: 20, height: 20)
Text(viewModel.product.wrappedName)
.font(.callout.bold())
.foregroundColor(ThemeColor.lightGray)
}
Text(viewModel.product.balance.toCurrency(with: viewModel.currency))
.font(.callout.bold())
.padding(.leading, 28)
}
}
}
If you follow this 2 simple things, you are not going to have problem with core date updating your views.