I'm working on a SwitUI app that shows a list of items that I want to create/edit/delete. I've been working with Core Data, so I have a #FetchRequest in the view that retrieves the objects. Then a '+' button which creates a new object using the Core Data context, with a placeholder title, and adds it to the list. The user can then tap to edit an object in the list to add information to it using the editor view.
My question is, is there a way to programmatically open the editor view when a new object is created, passing in that same object?
I'm probably missing something simple, but I'm struggling to achieve this. I can't create the editor view in the parent view hierarchy, and show it by toggling a #State variable, as I don't have the newly created object at the time the View is instantiated.
Here's a bit of pseudocode to help illustrate more clearly:
#FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Object.title, ascending: true)],
animation: .default) private var objects: FetchedResults<Object>
#State var objectEditorIsActive: Bool = false
var body: some View {
List {
Button(action: {
addObject()
}, label: { Image(systemName: "plus") })
// List of existing objects, with a button to open ObjectEditor
// and pass in the corresponding object for editing.
NavigationLink(
destination: ObjectEditor(object: existingObject),
isActive: $objectEditorIsActive,
label: { ObjectView() })
}
}
func addObject() {
withAnimation {
let newObject = Object(context: viewContext)
newObject.title = "New Object"
try? viewContext.save()
// if this is successful, open ObjectEditor with newObject passed in.
// Via NavigationLink ideally, but in a sheet or any other method is fine.
}
}
So basically I want to open ObjectEditor and pass in newObject in one button press, rather than requiring the user to have to specifically select the newly created object every time.
Any help would be greatly appreciated!
Here is possible approach - the idea is to use dynamic binding to state property of newly created object and activate hidden navigation link programmatically.
Tested with Xcode 12.1 / iOS 14.1
#State private var newObject: Object?
private var isNewObject: Binding<Bool> {
Binding(get: { self.newObject != nil }, // activate when state is set
set: { _ in self.newObject = nil }) // reset back
}
...
List {
Button(action: {
addObject()
}, label: { Image(systemName: "plus") })
// List of existing objects, with a button to open ObjectEditor
// and pass in the corresponding object for editing.
}
.background(
NavigationLink(
destination: ObjectEditor(object: newObject),
isActive: isNewObject, // << activated programmatically !!
label: { EmptyView() })
)
...
func addObject() {
withAnimation {
let newObject = Object(context: viewContext)
newObject.title = "New Object"
if let _ = try? viewContext.save() {
self.newObject = newObject // << here !!
}
}
}
Related
I have the following code:
struct ContentView: View {
#Environment(\.managedObjectContext) private var viewContext
#State var selectedItemMOID:NSManagedObjectID?
var body: some View {
NavigationView {
VStack {
if let selectedItemMOID = selectedItemMOID, let item = viewContext.object(with: selectedItemMOID) as? Item {
ItemView(item: item)
}
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
if let item = addItem() {
selectedItemMOID = item.objectID
}
} label: {
Image(systemName: "plus")
.font(.system(size: 14, weight: .bold))
}
}
}
}
}
private func addItem() -> Item? {
withAnimation {
let newItem = Item(context: viewContext)
newItem.timestamp = Date()
do {
try viewContext.save()
return newItem
} catch {
return nil
}
}
}
}
struct ItemView: View {
#ObservedObject var item:Item
#State var itemIDString:String = ""
var body: some View {
List {
VStack {
Text(itemIDString)
Text("the item: \(item.objectID)")
}
.onChange(of: item, perform: { newValue in
updateIdString()
})
.onAppear {
updateIdString()
}
}
}
func updateIdString() {
itemIDString = "\(item.objectID)"
}
}
Problem:
when I press the plus button (top right), ItemView partially updates. The bottom Text view updates immediately, the top one lags behind.
See this behavior:
You can see the item id at top is always one behind (except first render).
So, for example, hit plus (after first time) and top is p45, bottom, p46. And so on.
Why is the top Text lagging one behind?
I added the onChange as well, thinking that would sync things up, but it didn't. Without it, the top Text never updates after first item is set.
.onChange(of: item, perform: { newValue in
updateIdString()
})
I need to understand why this is happening so I can understand how to properly build views in general that use #ObservedObjects, so things update immediately when the ObservedObject is set to a new one.
So, to be clear my question is how to fix this code so that the entire ItemView updates immediately when item is set on it and why this is happening at a high level so I can avoid this same type of mistake going forward.
To address Asperi's approach (thanks for your answer!):
His answers fixes this specific case, but, I am not sure how I would go about using that pattern. For example, if ItemView observes multiple objects, lets say a Car and a User entity, then .id(car.id) would only react to setting a new Car on ItemView and not react to setting a new User necessarely. So, then, what is the point of #ObservedObject in this context. With the suggested .id(car.id) it would only react to one and not the other (or many more if ItemView had like 6 #ObservedObjects...).
Does this mean a view should only have one #ObservedObject? I wouldn't think so. Am I wrong?
Just trying to understand how to scale the id(item.id) pattern if it's the agreeable answer. I don't understand yet.
I think it is due to reuse/caching, in short rendering optimisation, try
List {
// content is here
}
.id(item) // << this one !!
It's because you didn't use the newValue, change it to:
.onChange(of: item, perform: { newValue in
itemIDString = "\(newValue.objectID)"
})
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 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
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.
Using Xcode 11.0, Beta 5.
I have a List driven from an array of model objects in an observed view model. Each item in the List has a NavigationLink with a destination detail view/view model that accepts the model as an argument.
The user can tap a bar button above the list to add a new item which is added to the view model's array and the List is therefore reloaded with the new item displayed.
The issue I cannot solve is how to select that new item in the list and therefore display the detail view without needing the user to select it manually. (This is an iPad app with split screen view, hence the reason to want to select it)
I've tried using a NavigationLink programatically, but can't seem to get anything to work. I looked at the selection argument for the List but that also requires the list to be in edit mode, so that's no good.
Any suggestions are very welcome!
The following solution uses the selection attribute of NavigationLink. One problem here is that only items currently visible get rendered, so selecting a row further down does nothing.
import SwiftUI
struct Item: Identifiable {
let id = UUID()
var title: String
var content: String
}
struct ItemOverview: View {
let item: Item
var body: some View {
Text(item.title)
}
}
struct ItemDetailsView: View {
let item: Item
var body: some View {
VStack{
Text(item.title).font(.headline)
Divider()
Text(item.content)
Spacer()
}
}
}
func randomString(length: Int) -> String {
let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
return String((0..<length).map{ _ in letters.randomElement()! })
}
struct ListSelectionView: View {
#State var items: [Item] = [
Item(title: "Hello", content: "Content World"),
Item(title: "Hey", content: "Content Du"),
]
#State var selection: UUID? = nil
func createItem() {
let newItem = Item(title: randomString(length: 3), content: randomString(length: 10))
self.selection = newItem.id
self.items.append(newItem)
}
var body: some View {
VStack{
NavigationView{
List(items) { item in
NavigationLink(destination: ItemDetailsView(item: item), tag: item.id, selection: self.$selection, label: {
Text(item.title)
})
}
}
Button(action: {
self.createItem()
}) { Text("Add and select new item") }
Divider()
Text("Current selection2: \(String(selection?.uuidString ?? "not set"))")
}
}
}
A second problem is that changing the $selection makes Modifying state during view update appear.
Third problem is that after manual selection the shading stays on the same item until again changed by hand.
Result
Programmatic selection is not really usable for now if you want to select a link not initialized yet (not visible?).
Further ideas
One might look into tags a little bit more.
Another option could be paging where all items of the current page are visible.
One could also use list selection and show details based on that.