I have two views. One view is a list of objects (persons) fetched from a core data store using #fetchRequest. The other view is a detail view which the user can navigate to by tapping on a person item in the list view. The idea is that the user can edit the details of a person in the detail view (e.g. name, age). So far so good.
The crucial point is that the list view is designed such that not all persons are necessarily fetched from the core data store. Only persons which fulfil a certain criteria are fetched. Let us assume that the criteria is that the person must be between the age of 30 and 40 years.
My problem is that when the user changes the age of a person in the detail view to some age which does not fulfil the criteria of the fetch request (e.g. he changes the age from 35 to 20 years), the detail view will pop once the user taps the save button and the managed object context saves, which is registered by #fetchRequest in the list view.
I understand that this happens, because the fetchRequest of persons driving the list view changes, as the edited person is removed, because he does not fulfil being between 30 and 40 years anymore. But I don't want this to happen while the user is still in the detail view. Tapping the save button should not automatically pop the detail view. Is there any way to prevent this from happening while using #fetchRequest?
Here is a condensed version of the code resulting in the described issue:
struct PersonList: View {
#FetchRequest var persons: FetchedResults<Person>
init() {
let lowerAge = NSPredicate(format: "%K >= %#", #keyPath(Person.age), 30 as CVarArg)
let upperAge = NSPredicate(format: "%K <= %#", #keyPath(Person.age), 40 as CVarArg)
let agePredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [lowerAge, upperAge])
_persons = FetchRequest(entity: Person.entity(), sortDescriptors: [], predicate: agePredicate)
}
var body: some View {
NavigationView {
ScrollView(showsIndicators: false) {
LazyVStack(spacing: 10) {
ForEach(persons, id: \.id) { person in
NavigationLink(destination: PersonDetail(person: person)) {
Text(person.name)
}
}
}
}
}
}
struct PersonDetail: View {
#Environment(\.managedObjectContext) var viewContext
#State var ageInput: String = ""
var person: Person
var body: some View {
TextField("Enter Age", text: $ageInput)
.onAppear(perform: {
ageInput = "\(person.age)"
})
Button("Save", action: { save() } )
}
}
func save() {
if let age = Int(ageInput) {
viewContext.saveContext()
}
}
}
As you pointed out, the NavigationLink is popped because the underlying model is removed from the fetch request. Unfortunately this means that you'll have to move the navigation link outside your list view so that you can cache the model even if it gets removed from the fetch results list.
One way to do that is to embed your VStack/ScrollView inside a ZStack with a hidden "singleton" navigation link and a #State variable that keep track of the actively selected Person:
struct PersonList: View {
#FetchRequest var persons: FetchedResults<Person>
#State private var selectedPerson = Person(entity: Person.entity(), insertInto: nil)
#State private var showPersonDetail = false
var body: some View {
NavigationView {
ZStack {
NavigationLink(destination: PersonDetail(person: selectedPerson), isActive: $showPersonDetail) { EmptyView() }.hidden()
ScrollView(showsIndicators: false) {
LazyVStack(spacing: 10) {
ForEach(persons, id: \.id) { person in
Button(action: {
selectedPerson = person
showPersonDetail = true
}, label: {
// You could hack out the click/tap listener for a navigation link instead of
// emulating its appearance, but that hack could stop working in a future iOS release.
HStack {
Text(person.name)
Spacer()
Image(systemName: "chevron.right")
}
})
}
}
}
}
}
}
Add .navigationViewStyle(.stack) to the outermost NavigationLink.
Related
Searched around and have not found an answer. Believe I know what the issue is, but not sure how to resolve it.
I have a swiftUI list that displays a context menu when a certain type of row is selected(not all rows qualify, this works as it should. When the context menu is displayed, the label is generated by the index of the array populating the lists object property.
The context menu selection performs performs a task that should result in the context menu label changing. And sometimes it works, other times it does not. This is resolved by scrolling that particular row off screen and scrolling back too it (has to be far enough away). The Object array is from a singleton data store passed as an environment object.
I believe this is related to the size of the array and the data being lazy loaded in swiftUI lists. I also would use the List selection property for this, but the context menu being populated by the row does not update the lists selected row.
A snippet example of my code is below.
#EnvironmentObject var singletonStore: MyObjectStore
#State private var selectedRow: Int?
var body: some View {
VStack {
List(singletonStore.myArray.indices, id: \.self, selection: $selectedRow) { index in
LazyVGrid(columns: gridColumns) {
ItemGridView(item: $singletonStore.myArray[index], opacityOffset: getRowOpacity(index: index))
}
.contextMenu {
if singletonStore.myArray[index].thisDate == nil {
if singletonStore.myArray[index].thisNeedsDone > 0 {
Button {
selectedRow = index
//these functions will add or remove a users id or initials to the appropriate property, and this updates values in my list view.
if singletonStore.myArray[index].id != nil {
//do this
} else {
//do that
}
} label: {
Label{
//This is where my issue is - even though the items in the list view are updating, the label of the context menu is not updating until the row is reloaded
Text(singletonStore.myArray[index].initials != nil ? "This Label" : "That Label") } icon: {
Image(systemName: "aqi.medium")
}
}
}
}
} //context menu close
} // list close
.listStyle(PlainListStyle())
}
}
My closures may be off, but its because I modified the code significantly on this platform to make is easier to follow. Nothing removed would affect this issue.
if there was a way to have opening the context menu update the lists selected row, that would solve the issue and I could use the selected row for the index of the singletonStores array objects property, but I may be approaching the problem from thinking the index is incorrect when the actual issue is the context menu is not being reloaded with the environment objects new information. Any help is always appreciated!
EDIT:
After some tinkering I further found that the issue must be related to the context menu itself not refreshing its data. I separated my views and used a #Viewbuilder function to return the needed view for the button - however it still does not refresh the context menus data.
EDIT 2:
currently (and subject to change) my SingletonStore class loads the data from another network class and publishes that data in the form of an array
final class SingletonStore: ObservableObject {
static private(set) var shared = singletonStore()
static func reset() {
shared = StagingStore()
}
#Published var myArray: [CustomObject] = []
private func getMyData() {
//uses other class and methods to retrieve and set data
//works and updates view on refresh
}
}
My View is called from a different View that is just a Tab bar controller, that code looks as follows:
struct ContainerView: View {
#StateObject var singletonStore = SingletonStore.shared
var body: some View {
TabView{
GenericView().environmentObject(singletonStore)
.tabItem {
Label("This View", systemImage: "camera.metering.matrix")
}
}
}
I have created a demo project inspired by your sample code above. In order to reproduce the issue I had to improvise some.
List is binded to a collection, when any of the item change, view hierarchy gets built and changes reflects.
Code for reference is as follow. Notice I am calling a view model method from button action, which makes a change in the collection that is binded.
import Foundation
class ContentViewModel: ObservableObject {
#Published var myArray: [Item] = []
init() {
for i in 0...100 {
let obj = Item(id: UUID().uuidString, thisDate: Date.now, thisNeedsDone: i, initials: "That Label")
myArray.append(obj)
}
}
func updateTheRow(item: Item) {
if let indexOfItem = myArray.firstIndex(where: { obj in
obj.id == item.id
})
{
myArray[indexOfItem] = item
}
}
}
struct Item: Identifiable, Equatable, Hashable {
var id: String
var thisDate: Date?
var thisNeedsDone: Int
var initials: String?
}
import SwiftUI
struct ContentView: View {
#StateObject var viewModel = ContentViewModel()
let columns = [
GridItem(.adaptive(minimum: 80))
]
var body: some View {
VStack {
List(viewModel.myArray, id: \.self) { item in
LazyVGrid(columns: columns) {
VStack{
Text("My bad")
}
}
.contextMenu {
if item.thisDate != nil {
if item.thisNeedsDone > 0 {
Button {
//these functions will add or remove a users id or initials to the appropriate property, and this updates values in my list view.
var modifiedItem = item
modifiedItem.initials = "Modified Label"
viewModel.updateTheRow(item: modifiedItem)
} label: {
Label{
//This is where my issue is - even though the items in the list view are updating, the label of the context menu is not updating until the row is reloaded
Text(item.initials!) } icon: {
Image(systemName: "aqi.medium")
}
}
}
}
} //context menu close
} // list close
.listStyle(PlainListStyle())
}
}
}
To explain this behavior, I have built a simplified project that creates same behavior as my working project. Please keep in mind that my real project is much bigger and have more constraints than this one.
I have a simple struct with only an Int as property (named MyObject) and a model that stores 6 MyObject.
struct MyObject: Identifiable {
let id: Int
}
final class Model: ObservableObject {
let objects: [MyObject] = [
MyObject(id: 1),
MyObject(id: 2),
MyObject(id: 3),
MyObject(id: 4),
MyObject(id: 5),
MyObject(id: 6)
]
}
Please note that in my real project, Model is more complicated (loading from JSON).
Then I have a FavoritesManager that can add and remove a MyObject as favorite. It has a #Published array of ids (Int):
final class FavoritesManager: ObservableObject {
#Published var favoritesIds = [Int]()
func addToFavorites(object: MyObject) {
guard !favoritesIds.contains(object.id) else { return }
favoritesIds.append(object.id)
}
func removeFromFavorites(object: MyObject) {
if let index = favoritesIds.firstIndex(of: object.id) {
favoritesIds.remove(at: index)
}
}
}
My App struct creates Model and FavoritesManager and injects them in my first view:
#main
struct testAppApp: App {
#StateObject private var model = Model()
#StateObject private var favoritesManager = FavoritesManager()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(model)
.environmentObject(favoritesManager)
}
}
}
Now I have few views that display my objects:
ContentView displays a link to ObjectsListView using NavigationLink and a FavoritesView. In my real project, ObjectsListView does not display all objects, only a selection (by category for example).
struct ContentView: View {
#EnvironmentObject var model: Model
var body: some View {
NavigationView {
VStack {
NavigationLink(
destination: ObjectsListView(objects: model.objects), label: {
Text("List of objects")
}
)
FavoritesView()
.padding()
}
}
}
}
FavoritesView displays ids of current selected favorites. It observes FavoritesManager to update its body when a favorite is added/removed.
Important: it creates a new NavigationLink to favorites details view. If you remove this link, bug does not happen.
struct FavoritesView: View {
#EnvironmentObject var model: Model
#EnvironmentObject var favoritesManager: FavoritesManager
var body: some View {
HStack {
Text("Favorites:")
let favoriteObjects = model.objects.filter( { favoritesManager.favoritesIds.contains($0.id) })
ForEach(favoriteObjects) { object in
NavigationLink(destination: DetailsView(object: object), label: {
Text("\(object.id)")
})
}
}
}
}
ObjectsListView simply displays objects and makes a link to DetailsView.
struct ObjectsListView: View {
#State var objects: [MyObject]
var body: some View {
List {
ForEach(objects) { object in
NavigationLink(destination: DetailsView(object: object), label: { Text("Object \(object.id)")})
}
}
}
}
DetailsView displays object details and add a button to add/remove current object as favorite.
If you press the star, current object will be added as favorite (as expected) and FavoritesView will be updated to display/remove this new id (as expected).
But here is the bug: as soon as you press the star, current view (DetailsView) disappears and it goes back (without animation) to previous view (ObjectsListView).
struct DetailsView: View {
#EnvironmentObject var favoritesManager: FavoritesManager
#State var object: MyObject
var body: some View {
VStack {
HStack {
Text("You're on object \(object.id) detail page")
Button(action: {
if favoritesManager.favoritesIds.contains(object.id) {
favoritesManager.removeFromFavorites(object: object)
},
else {
favoritesManager.addToFavorites(object: object)
}
}, label: {
Image(systemName: favoritesManager.favoritesIds.contains(object.id) ? "star.fill" : "star")
.foregroundColor(.yellow)
})
}
Text("Bug is here: if your press the star, it will go back to previous view.")
.font(.caption)
.padding()
}
}
}
You can download complete example project here.
You may ask why I don't display objects list in ContentView directly. It is because of my real project. It displays objects classified by categories in lists. I have reproduce this behavior with ObjectsListView.
I also need to separated Model and FavoritesManager because in my real project I don't want that my ContentView's body is updated everytime a favorite is added/removed. That's why I have moved all favorites displaying in FavoritesView.
I believe that this issue happens because I add/remove NavigationLinks in current NavigationView. And it looks like it is reloading whole navigation hierarchy. But I can't figure out how to get expected result (staying in DetailsView when pressing star button) with same views hierarchy. I may do something wrong somewhere...
Add .navigationViewStyle(.stack) to your NavigationView in ContentView.
With that, your code works well for me, I can press the star in DetailsView, and it remains displayed. It does not
go back to the previous View. Using macos 12.2, Xcode 13.2,
targets ios 15.2 and macCatalyst 12.1. Tested on real devices.
I have an app with multiple Detail Views that use as source instances of NSManagedObject.
Imagine View 1 fetches all persistent instances of Entity Item with #FetchRequeset and displays them in a List View.
When clicking on one item in the list, a second View (Detail-View) is opened.
If a user navigates from View 1 to View 2 a persistence instance is shared with the View 2.
View 2 has a NavigationLink zu another Detail-View View3. View 2 also shares the persistence instance with View 3.
On View3 a user can click on a Button ("DELETE this Item"), which initiates the deletion of the CoreData persistence instance and a save of the NSManagedObjectContext.
After saving I want that all my Detail-Views (View2 and View3) are dismissed, and a user returns back to the entry view, View 1 (List-View).
My app listens for Notifications of NSManagedObjectContextDidSave and sets Bindings for isActive on NavigationLink instances to false. Instead of working with Bindings to dismiss the DetailViews, I also tried to use the presentationMode environment Variable with self.presentationMode.wrappedValue.dismiss().
However, it does not work to dismiss View 2 and View 3. After saving the NSManagedObjectContext just View 3 gets dismissed and View 2 is stuck and cannot be dismissed.
I hope someone also faces this issue and knows how to solve it. I appreciate any support! Thank you!
1. UPDATE on 13th of January 2020: Let me clarify my post here: My notification closures are executed and Bindings representing whether my Views are presented are also updated. However, my only question here is why my View 2 is not dismissed and stuck, after View 3 has been dismissed. Am I understanding something wrong? My example code is quite big, but for reproducing the issue it needs at least 3 Views (i.e. 2 Detail-Views). With just 1 List and 1 Detail-View the issue will not occur.
The following GIF shows the issue.
I built an example project for reproducibility. First, I created a new Xcode Project with Core Data enabled. I modified the existing Item entity just a little bit, by adding a name attribute of type String. I currently use Xcode 12.2 and iOS 14.2.
This is the SwiftUI code for View 1, View 2 and View 3:
import SwiftUI
struct View1: View {
#FetchRequest(entity: Item.entity(), sortDescriptors: [])
private var items: FetchedResults<Item>
var body: some View {
NavigationView {
List {
ForEach(self.items, id: \.self) { item in
View1_Row(item: item)
}
}.listStyle(InsetGroupedListStyle())
.navigationTitle("View 1")
}
}
}
struct View1_Row: View {
#ObservedObject var item: Item
#State var isView2Presented: Bool = false
var body: some View {
NavigationLink(
destination: View2(item: item, isView2Presented: $isView2Presented),
isActive: $isView2Presented,
label: {
Text("\(item.name ?? "missing item name") - View 2")
})
.isDetailLink(false)
}
}
struct View2: View {
#Environment(\.managedObjectContext) var moc
#ObservedObject var item: Item
#Binding var isView2Presented: Bool
var body: some View {
List {
Text("Item name: \(item.name ?? "item name unknown")")
View2_Row(item: item)
Button(action: { isView2Presented = false }, label: {Text("Dismiss")})
}
.listStyle(InsetGroupedListStyle())
.navigationTitle("View 2")
.onReceive(NotificationCenter.default.publisher(for: Notification.Name(rawValue: "Reset"))) { _ in
print("\(Self.self) inside reset notification closure")
self.isView2Presented = false
}
.onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextDidSave, object: self.moc),
perform: dismissIfObjectIsDeleted(_:))
}
private func dismissIfObjectIsDeleted(_ notification: Notification) {
if notification.isDeletion(of: self.item) {
print("\(Self.self) dismissIfObjectIsDeleted Dismiss view after deletion of Item")
isView2Presented = false
}
}
}
struct View2_Row : View {
#ObservedObject var item: Item
#State private var isView3Presented: Bool = false
var body: some View {
NavigationLink("View 3",
destination: View3(item: item,
isView3Presented: $isView3Presented),
isActive: $isView3Presented)
.isDetailLink(false)
}
}
struct View3: View {
#Environment(\.managedObjectContext) var moc
#ObservedObject var item: Item
#State var isAddViewPresented: Bool = false
#Binding var isView3Presented: Bool
var body: some View {
Group {
List {
Text("Item name: \(item.name ?? "item name unknown")")
Button("DELETE this Item") {
moc.delete(self.item)
try! moc.save()
/*adding the next line does not matter:*/
/*NotificationCenter.default.post(Notification.init(name: Notification.Name(rawValue: "Reset")))*/
}.foregroundColor(.red)
Button(action: {
NotificationCenter.default.post(Notification.init(name: Notification.Name(rawValue: "Reset")))
}, label: {Text("Reset")}).foregroundColor(.green)
Button(action: {isView3Presented = false }, label: {Text("Dismiss")})
}
}
.onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextDidSave, object: self.moc),
perform: dismissIfObjectIsDeleted(_:))
.onReceive(NotificationCenter.default.publisher(for: Notification.Name(rawValue: "Reset"))) { _ in
print("\(Self.self) inside reset notification closure")
self.isView3Presented = false
}
.listStyle(InsetGroupedListStyle())
.navigationTitle("View 3")
.toolbar {
ToolbarItem {
Button(action: {isAddViewPresented.toggle()}, label: {
Label("Add", systemImage: "plus.circle.fill")
})
}
}
.sheet(isPresented: $isAddViewPresented, content: {
Text("DestinationDummyView")
})
}
private func dismissIfObjectIsDeleted(_ notification: Notification) {
if notification.isDeletion(of: self.item) {
print("\(Self.self) dismissIfObjectIsDeleted Dismiss view after deletion of Item")
isView3Presented = false
}
}
}
This is the code of my Notification extension -- used for checking if the NSManagedObject is deleted:
import CoreData
extension Notification {
/*Returns whether this notification is about the deletion of the given `NSManagedObject` instance*/
func isDeletion(of managedObject: NSManagedObject) -> Bool {
guard let deletedObjectIDs = self.deletedObjectIDs
else {
return false
}
return deletedObjectIDs.contains(managedObject.objectID)
}
private var deletedObjectIDs: [NSManagedObjectID]? {
guard let deletedObjects =
self.userInfo?[NSManagedObjectContext.NotificationKey.deletedObjects.rawValue]
as? Set<NSManagedObject>,
deletedObjects.count > 0
else {
return .none
}
return deletedObjects.map(\.objectID)
}
}
This is the code of my app #main entry point. It generates example data on app start and my app has 2 Tabs.:
import SwiftUI
import CoreData
#main
struct SwiftUI_CoreData_ExApp: App {
let persistenceController = PersistenceController.shared
var body: some Scene {
WindowGroup {
TabView {
View1().tabItem {
Image(systemName: "1.square.fill")
Text("Tab 1")
}
View1().tabItem {
Image(systemName: "2.square.fill")
Text("Tab 2")
}
}
.environment(\.managedObjectContext, persistenceController.container.viewContext)
.onAppear(perform: {
let moc = persistenceController.container.viewContext
/*Create persistence instances in Core Data database for test and reproduction purpose*/
print("Preparing test data")
let fetchRequest: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest(entityName: Item.entity().name!)
let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
try! moc.execute(deleteRequest)
for i in 1..<4 {
let item = Item(context: moc)
item.name = "Item \(i)"
}
try! moc.save()
})
}
}
}
I have a very simple schoolbook example of a SwiftUI List view that renders items from data in an array. Data in the array is Identifiable. But, when I change the the data in the array, add or remove a item then all rows in the list view are recreated. Is that correct? My understanding was that Identifiable should make sure that only the view in the list that are changed are recreated.
My list is inside a navigation view and each row links to a detail view. The problem is that since all items in the list are removed and recreated every time the data is changed then if that that happens when Im in a detail view (it's triggered by a notification) then Im thrown out back to the list.
What am I missing?
Edit: Added code example
This is my data struct:
struct Item: Identifiable {
let id: UUID
let name: String
init(name: String) {
self.id = UUID()
self.name = name
}
}
This is my ItemView
struct ItemView: View {
var item: Item
init(item: Item) {
self.item = item
print("ItemView created \(self.item.id)")
}
var body: some View {
Text(self.item.name)
}
}
An finally my list view:
struct KeyList: View {
#State var items = [Item(name: "123"), Item(name: "456"), Item(name: "789")]
var body: some View {
VStack {
List(self.items) { item in
ItemView(item: item)
}
Button(action: {
self.items.append(Item(name: "New"))
}) {
Text("Add")
}
}
}
}
When I press add it will print "ItemView created" 4 times. My understanding is that it should only do it 1 time?
Here is an example of how this could work. Tested and working on iOS 13.5
The List doesn't get recreated again when only one item is being removed. So this was accomplished.
About the poping of the View this has already been answered here:
SwiftUI ForEach refresh makes view pop
I have here a small workaround for this problem. Add the items you want to remove to an array. Then when going back, remove these items (Which will make the view pop) or go back programmatically and nothing gets removed
struct ContentView: View {
#State var text:Array<String> = ["a", "b", "c"]
var body: some View {
NavigationView() {
VStack() {
List() {
ForEach(self.text, id: \.self){ item in
NavigationLink(destination: SecondView(textItem: item, text: self.$text)) {
Text(item)
}
}
}
Button(action: {
self.text.remove(at: 0)
}){
Text("Remove \(self.text[0])")
}
}
}
}
}
struct SecondView: View {
#State var textItem: String
#Binding var text: Array<String>
#State var tmpArray: Array<String> = []
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var body: some View {
VStack() {
Text(self.textItem)
Button(action: {
//Append to a tmp array which will later be used to determine what to remove
self.tmpArray.append(self.text[0])
}){
Text("Remove \(self.text[0])")
}
Button(action: {
if self.tmpArray.count > 0 {
//remove your stuff which will automatically pop the view
self.text.remove(at: 0)
} else {
// programmatically go back as nothing has been deleted
self.presentationMode.wrappedValue.dismiss()
}
}){
Text("Go Back")
}
}
}
}
Im trying to pass data of some object from list of objects to modal sheet, fetching data from CoreData.
The problem is that no matter what object I click on in the list, only the data form last added object appears in the details view.
The same goes for deleting the object - no matter what object I'm trying to delete, the last one is deleted all the time.
Problem disappears using NavigationLink, but it is not suitable for me.
Here is my code:
import SwiftUI
struct CarScrollView: View {
#Environment(\.managedObjectContext) var moc
#FetchRequest(entity: Cars.entity(), sortDescriptors: []) var cars: FetchedResults<Cars>
#State var showDetails = false
var body: some View {
VStack {
ScrollView (.vertical, showsIndicators: false) {
ForEach (cars, id: \.self) { car in
Text("\(car.text!)")
.onTapGesture {
self.showDetails.toggle()
}
.sheet(isPresented: self.$showDetails) { CarDetail(id: car.id, text: car.text)
}
}
}
}
}
}
There should be only one sheet in view stack, so just move it out of ForEach, like below
struct CarScrollView: View {
#Environment(\.managedObjectContext) var moc
#FetchRequest(entity: Cars.entity(), sortDescriptors: []) var cars: FetchedResults<Cars>
#State private var selectedCar: Car? = nil
var body: some View {
VStack {
ScrollView (.vertical, showsIndicators: false) {
ForEach (cars, id: \.self) { car in
Text("\(car.text!)")
.onTapGesture {
self.selectedCar = car
}
}
}
}
.sheet(item: self.$selectedCar) { car in
CarDetail(id: car.id, text: car.text)
}
}
}