I am using coredata to save information. This information populates a picker, but at the moment there is no information so the picker is empty. The array is set using FetchedRequest.
#FetchRequest(sortDescriptors: [])
var sources: FetchedResults<Source>
#State private var selectedSource = 0
This is how the picker is setup.
Picker(selection: $selectedSource, label: Text("Source")) {
ForEach(0 ..< sources.count) {
Text(sources[$0].name!)
}
}
There is also a button that displays another sheet and allows the user to add a source.
Button(action: { addSource.toggle() }, label: {
Text("Add Source")
})
.sheet(isPresented: $addSource, content: {
AddSource(showSheet: $addSource)
})
If the user presses Add Source, the sheet is displayed with a textfield and a button to add the source. There is also a button to dismiss the sheet.
struct AddSource: View {
#Environment(\.managedObjectContext) var viewContext
#Binding var showSheet: Bool
#State var name = ""
var body: some View {
NavigationView {
Form {
Section(header: Text("Source")) {
TextField("Source Name", text: $name)
Button("Add Source") {
let source = Source(context: viewContext)
source.name = name
do {
try viewContext.save()
name = ""
} catch {
let error = error as NSError
fatalError("Unable to save context: \(error)")
}
}
}
}
.navigationBarTitle("Add Source")
.navigationBarItems(trailing: Button(action:{
self.showSheet = false
}) {
Text("Done").bold()
.accessibilityLabel("Add your source.")
})
}
}
}
Once the sheet is dismissed, it goes back to the first view. The picker in the first view is not updated with the newly added source. You have to close it and reopen. How can I update the picker once the source is added by the user? Thanks!
The issue is with the ForEach signature you're using. It works only for constant data. If you want to use with changing data, you have to use something like:
ForEach(sources, id: \Source.name.hashValue) {
Text(verbatim: $0.name!)
}
Note that hashValue will not be unique for two entity objects with the same name. This is just an example
Related
Description
I've got simple Combat model which stores name and list of actors. When I delete the Combat from List using onDelete it looks like it's working. It removes the Combat from Realm (checked with RealmStudio) and updates the view. However, if view gets redrawn (for instance, when switching Apps), the "old" data is loaded again (the very first loaded on app initialization), so all deleted rows are back again. Of course, removing them again crashes the app, because they are not present in #ObservedResults combats anymore. Restarting the app fixes the issue, because new data is loaded to #ObservedResults combats and to List, but then again, when I removed something it will be back on review draw...
What I discovered is that removing .sheet() fixes the issue! (EDIT: clarification; it doesn't matter what's inside of the sheet, it may be even empty) The view is updated correctly on redraw! The Sheet is used to display form to add new Combat (nether to say that adding new combats or editing them does not update the view as well, but let's focus on deletion). I have no idea what adding sheet() changes in behaviour of the List and "listening" to #ObservedResults combats.
As a test I used simple array of Combat classes and everything worked. So it points me to issue with #ObservedResults.
I was using the Alert before and all changes to #ObservedResults combats were seen at glance. Now I wanted to replace Alert with Sheet and… That happened.
Also, I have subview where I have almost identical code for actor and there everything works, however I use #ObservedRealmObject var combat: Combat there, and I pass the combat #ObservedResults combats, like so:
NavigationLink(destination: CombatView(combat: combat)) { Text(combat.name) }
I removed unecessary code from below examples to keep it at minimum.
Model
The Combat model:
class Combat: Object, ObjectKeyIdentifiable {
#objc dynamic var id: String = UUID().uuidString
#objc dynamic var name: String = ""
var actors = List<Actor>()
}
Actual View Code (broken using Sheet)
#ObservedResults(
Combat.self,
sortDescriptor: SortDescriptor( keyPath: "name", ascending: true)
) var combats
struct CombatsListView: View {
#ObservedResults(
Combat.self,
sortDescriptor: SortDescriptor( keyPath: "name", ascending: true)
) var combats
var body: some View {
List {
ForEach(combats) { combat in
Text(combat.name)
}.onDelete(perform: $combats.remove)
}
.sheet(isPresented: $showAddCombat) {
AddCombatView( showAddCombat: $showAddCombat)
}
}
}
Old View Code (works using Alert)
struct CombatsListView: View {
#ObservedResults(
Combat.self,
sortDescriptor: SortDescriptor( keyPath: "name", ascending: true)
) var combats
#State private var showAddCombat = false
#State private var addCombatNewName = ""
var body: some View {
List(combats) { combat in
Text(combat.name)
.onDelete(perform: $combats.remove)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
showAlert = true
}) {
Image(systemName: "plus" )
.font(.title)
Text("New Combat")
}.alert("New Combat", isPresented: $showAlert) {
TextField("write name", text: $addCombatNewName)
Button("Close", role: .cancel) {
addCombatNewName = ""
}
Button("Add") {
addNewCombat(name: addCombatNewName)
addCombatNewName = ""
}
}
}
}
}
private func addNewCombat(name: String) {
let newCombat = Combat()
newCombat.name = name
do {
try self.realm.write {
realm.add(newCombat)
}
} catch {
fatalError("Error: \(error)")
}
}
}
EDITED
I just found some new behaviour. I made a new simple view which lists elements of Collection list and you can delete or add new Collection. It works just fine, but if I include this CollectionsView under the TabView, then the effect is exactly the same as in the example above. The view stops working properly: deleted items are added back on view redraw and adding new objects doesn't refresh the View.
This makes me think more of a bug in #ObservedResults().
Below is the source code.
class Collection: Object, ObjectKeyIdentifiable {
#objc dynamic var id: String = UUID().uuidString
#objc dynamic var name: String = ""
var actors = List<Actor>()
}
#main
struct CombatTrackerApp: App {
var body: some Scene {
WindowGroup {
Tabber() // will not work
// CollectionsView() // will work
}
}
}
struct CollectionsView: View {
#ObservedResults( Collection.self ) var collections
#State private var showNewCollectionForm = false
var body: some View {
NavigationStack {
List {
ForEach(collections) { collection in
Text(collection.name)
}.onDelete(perform: $collections.remove)
}
.listStyle(.inset)
.padding()
.navigationTitle("Collections")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button() {
self.showNewCollectionForm.toggle()
} label: {
Image(systemName: "plus")
Text("Add New Collection")
}
}
}
.sheet(isPresented: $showNewCollectionForm) {
NewCollectionView( showNewCollectionForm: $showNewCollectionForm )
}
}
}
}
struct NewCollectionView: View {
let realm = try! Realm()
#Binding var showNewCollectionForm: Bool
#State private var newCollectioName: String = ""
var body: some View {
NavigationStack {
VStack {
Text("Create new Collection").font(.title).padding()
Form {
TextField("Name", text: $newCollectioName)
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Close", role: .cancel) {
showNewCollectionForm.toggle()
}
}
ToolbarItem {
Button("Create") {
addCollection()
} .disabled(newCollectioName.isEmpty)
}
}
}
}
private func addCollection() {
let newCollection = Collection()
newCollection.name = newCollectioName
do {
try realm.write {
realm.add(newCollection)
}
} catch {
print("Cannot add new Collection", error)
}
showNewCollectionForm.toggle()
}
}
struct Tabber: View {
var body: some View {
TabView() {
NavigationStack {
CombatsListView()
}
.tabItem {
Text("Combats")
}
NavigationStack {
CollectionsView()
}
.tabItem {
Text("Collections")
}
SettingsView()
.tabItem {
Text("Settings")
}
}
}
}
I found out the solution (but I still don't understand why it's working).
The solution was to move NavigationStack from my TabView to the subviews. So instead of:
struct Tabber: View {
var body: some View {
TabView() {
NavigationStack {
CombatsListView()
}
.tabItem {
Text("Combats")
}
//...
I should do:
struct Tabber: View {
var body: some View {
TabView() {
CombatsListView()
.tabItem {
Text("Combats")
}
//...
struct CombatsListView: View {
var body: some View {
NavigationStack {
Confusing part was that all online tutorials and Apple Documentation suggests to wrap subviews with NavigationStack in TabView directly instead of adding NavigationStack in subviews. Maybe it's a bug, maybe it's a feature.
I have a view, where i can add a new entry to CoreData. The name for that entry cannot be null, which can be seen in the ViewModel. If someone tries to add a new entry without a name, they are presented with an error. Now, every time the error pops up, it dismisses itself.
The View:
struct AddProductPopover: View {
#Environment(\.presentationMode) var presentationMode
#StateObject var prodPopViewModel = AddProductPopoverViewModel()
var body: some View {
NavigationView {
List {
HStack {
Label("", systemImage: K.ProductIcons.name)
.foregroundColor(.black)
Spacer().frame(maxWidth: .infinity)
TextField("Add Name", text: $prodPopViewModel.newProductName)
.keyboardType(.default)
}
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
prodPopViewModel.saveProduct()
// if saving fails due to an empty name, the dismissal is still called before the error is displayed
presentationMode.wrappedValue.dismiss()
}
.alert(isPresented: $prodPopViewModel.showAlert) {
Alert(
title: Text("Product Name cannot be empty!"),
message: Text("Please specify a name for your new Product.")
)
}
}
}
}
}
The ViewModel:
class AddProductPopoverViewModel: ObservableObject {
var managedObjectContext = PersistenceController.shared.container.viewContext
#Published var newProductName: String = ""
#Published var newProductVendor: String = ""
#Published var newProductCategory: String = ""
#Published var newProductStoredQuantity: Int = 0
#Published var showAlert = false
func saveProduct() {
// if name is not nil saves the new product to CoreData
if !newProductName.isEmpty {
let newProduct = ProductEntity(context: managedObjectContext)
newProduct.productName = newProductName
newProduct.id = UUID()
newProduct.productVendor = newProductVendor
newProduct.productCategory = newProductCategory
newProduct.productStoredQuantity = Int32(newProductStoredQuantity)
PersistenceController.shared.save()
} else {
showAlert = true
}
}
I have figured out, that issue lies in the View in the Button Save action. Whenever the check in the ViewModel fails, it sets the boolean required for the alert to true. However, after setting that boolean to true, it returns to the view first and completes the next step in the Button Action, which is dismissing the current view before it then finally triggers the Alert. This execution order results in the Alert to be dismissed. However, the alert should not be dismissed. Dismissing should only happen if saving to CoreData has been successfull.
Button("Save") {
prodPopViewModel.saveProduct()
presentationMode.wrappedValue.dismiss()
}
What changes would I need to make to skip the dismissing line in case the boolean is set to true? I thought of including the dismissal in the ViewModel. However, that would violate the MVVM concept I'm trying to follow.
Replace save button with:
Button("Save") {
prodPopViewModel.saveProduct() // save the product
if (!prodPopViewModel.showAlert) { // don't dismiss if need to show alert
presentationMode.wrappedValue.dismiss()
}
}
.alert("Product Name cannot be empty!", // alert title
// decide to show alert i.e. save failed
isPresented: $prodPopViewModel.showAlert) {
Button("Ok"){
// hide alert on button press
prodPopViewModel.showAlert = false
}
} message: {
Text("Please specify a name for your new Product.")
}
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()
})
}
}
}
From a settings page, I want to :
Navigate to a child view
Let the user input update some value in a textfield
Save this value in the user defaults
Navigate back to the settings
If the user opens the child view again, pre-fill the textfield with the previously saved value
Given the following (simple) code :
// User defaults wrapper
class SettingsProvider: ObservableObject {
static let shared = SettingsProvider()
var savedValue: String {
get { UserDefaults.standard.string(forKey: "userdefaultskey") ?? "Default value" }
set {
UserDefaults.standard.setValue(newValue, forKey: "userdefaultskey")
objectWillChange.send()
}
}
}
struct SettingsView: View {
var body: some View {
NavigationView {
NavigationLink("Open child", destination: ChildView())
}
}
}
struct ChildView: View {
#ObservedObject var settingsProvider = SettingsProvider.shared
#State var text: String = SettingsProvider.shared.savedValue
var body: some View {
Text("Value is \(settingsProvider.savedValue)")
TextField("Enter value", text: $text).background(Color.gray)
Button("Save value") {
settingsProvider.savedValue = text
}
}
}
I'm having the following behaviour : video
Can somebody explain to me why the TextField contains Default value the second time I open it ?
Is it a bug in SwiftUI that I should report, or am I missing something ?
If I kill & re-open the app, the textfield will contain (as expected) Other value.
You can just add an onAppear { text = SettingsProvider.shared.savedValue } under the Button like this:
var body: some View {
Text("Value is \(settingsProvider.savedValue)")
TextField("Enter value", text: $text).background(Color.gray)
Button("Save value") {
settingsProvider.savedValue = text
}
.onAppear {
text = SettingsProvider.shared.savedValue // <= add this
}
}
I have a sheet view that is presented when a user clicks a button as shown in the parent view below:
struct ViewWithSheet: View {
#State var showingSheetView: Bool = false
#EnvironmetObject var store: DataStore()
var body: some View {
NavigationView() {
ZStack {
Button(action: { self.showingSheetView = true }) {
Text("Show sheet view")
}
}
.navigationBarHidden(true)
.navigationBarTitle("")
.sheet(isPresented: $showingSheetView) {
SheetView(showingSheetView: self.$showingSheetView).environmentObject(self.dataStore)
}
}
}
}
In the sheet view, when a user clicks another button, an action is performed by the store that has a completion handler. The completion handler returns an object value, and if that value exists, should dismiss the SheetView.
struct SheetView: View {
#Binding var showingSheetView: Bool
#EnvironmentObject var store: DataStore()
//#Environment(\.presentationMode) private var presentationMode
func create() {
store.createObject() { object, error in
if let _ = object {
self.showingSheetView = false
// self.presentationMode.wrappedValue.dismiss()
}
}
}
var body: some View {
VStack {
VStack {
HStack {
Button(action: { self.showingSheetView = false }) {
Text("Cancel")
}
Spacer()
Spacer()
Button(action: { self.create() }) {
Text("Add")
}
}
.padding()
}
}
}
}
However, in the create() function, once the store returns values and showingSheetView is set to false, the sheet view doesn't dismiss as expected. I've tried using presentationMode to dismiss the sheet as well, but this also doesn't appear to work.
I found my issue, the sheet wasn't dismissing due to a conditional in my overall App wrapping View, I had an if statement that would show a loading view on app startup, however, in my DataStore I was setting it's fetching variable on every function call it performs. When that value changed, the view stack behind my sheet view would re-render the LoadingView and then my TabView once the fetching variable changed again. This was making the sheet view un-dismissable. Here's an example of what my AppView looked like:
struct AppView: View {
#State private var fetchMessage: String = ""
#EnvironmentObject var store: DataStore()
func initializeApp() {
self.fetchMessage = "Getting App Data"
store.getData() { object, error in
if let error = error {
self.fetchMessage = error.localizedDescription
}
self.fetchMessage = ""
}
}
var body: some View {
Group {
ZStack {
//this is where my issue was occurring
if(!store.fetching) {
TabView {
Tab1().tabItem {
Image(systemName: "tab-1")
Text("Tab1")
}
Tab2().tabItem {
Image(systemName: "tab-2")
Text("Tab2")
}
//Tab 3 contained my ViewWithSheet() and SheetView()
Tab3().tabItem {
Image(systemName: "tab-3")
Text("Tab3")
}
}
} else {
LoadingView(loadingMessage: $fetchMessage)
}
}
}.onAppear(perform: initializeApp)
}
}
To solve my issue, I added another variable to my DataStore called initializing, which I use to render the loading screen or the actual application views on first .onAppear event in my app. Below is an example of what my updated AppView looks like:
struct AppView: View {
#State private var fetchMessage: String = ""
#EnvironmentObject var store: DataStore()
func initializeApp() {
self.fetchMessage = "Getting App Data"
store.getData() { object, error in
if let error = error {
self.fetchMessage = error.localizedDescription
}
self.fetchMessage = ""
//set the value to false once I'm done getting my app's initial data.
self.store.initializing = false
}
}
var body: some View {
Group {
ZStack {
//now using initializing instead
if(!store.initializing) {
TabView {
Tab1().tabItem {
Image(systemName: "tab-1")
Text("Tab1")
}
Tab2().tabItem {
Image(systemName: "tab-2")
Text("Tab2")
}
//Tab 3 contained my ViewWithSheet() and SheetView()
Tab3().tabItem {
Image(systemName: "tab-3")
Text("Tab3")
}
}
} else {
LoadingView(loadingMessage: $fetchMessage)
}
}
}.onAppear(perform: initializeApp)
}
}
Try to do this on main queue explicitly
func create() {
store.createObject() { object, error in
if let _ = object {
DispatchQueue.main.async {
self.showingSheetView = false
}
}
// think also about feedback on else case as well !!
}
}
Want to see something hacky that worked for me? Disclaimer: Might not work for you and I don't necessarily recommend it. But maybe it'll help someone in a pinch.
If you add a NavigationLink AND keep your fullScreenCover, then the fullscreen cover will be able to dismiss itself like you expect.
Why does this happen when you add the NavigationLink to your View? I don't know. My guess is it creates an extra reference somewhere.
Add this to your body, and keep your sheet as it is:
NavigationLink(destination: YOURVIEW().environmentObjects(), isActive: $showingSheetView) {}