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

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

Found the solution after hours of googling various different phrases of the issue: https://stackoverflow.com/a/58381982/10688806
You must use a "lazy view".
Code:
struct LazyView<Content: View>: View {
let build: () -> Content
init(_ build: #autoclosure #escaping () -> Content) {
self.build = build
}
var body: Content {
build()
}
}
Usage:
NavigationLink(destination: LazyView(TodoItemView(todoList: todoList)), label: {
Text(todoList.title ?? "")
})

Related

Swiftui view doesn't refresh when navigated to from a different view

I have, what is probably, a beginner question here. I'm hoping there is something simple I'm missing or I have done wrong.
I essentially have a view which holds a struct containing an array of id strings. I then have a #FirestoreQuery which accesses a collection which holds objects with these id's. My view then displays a list with two sections. One for the id's in the original struct, and one for the remaining ones in the collection which don't appear in the array.
Each listitem is a separate view which displays the details of that item and also includes a button. When this button is pressed it adds/removes that object from the parent list and the view should update to show that object in the opposite section of the list from before.
My issue is that this works fine in the 'preview' in xcode when I look at this view on it's own. However if I run the app in the simulator, or even preview a parent view and navigate to this one, the refreshing of the view doesn't seem to work. I can press the buttons, and nothing happens. If i leave the view and come back, everything appears where it should.
I'll include all the files below. Is there something I'm missing here?
Thanks
Main view displaying the list with two sections
import SwiftUI
import FirebaseFirestoreSwift
struct SessionInvitesView: View {
#Environment(\.presentationMode) private var presentationMode
#FirestoreQuery(collectionPath: "clients") var clients : [Client]
#Binding var sessionViewModel : TrainingSessionViewModel
#State private var searchText: String = ""
#State var refresh : Bool = false
var enrolledClients : [Client] {
return clients.filter { sessionViewModel.session.invites.contains($0.id!) }
}
var availableClients : [Client] {
return clients.filter { !sessionViewModel.session.invites.contains($0.id!) }
}
var searchFilteredClients : [Client] {
if searchText.isEmpty {
return availableClients
} else {
return availableClients.filter {
$0.dogName.localizedCaseInsensitiveContains(searchText) ||
$0.name.localizedCaseInsensitiveContains(searchText) ||
$0.dogBreed.localizedCaseInsensitiveContains(searchText) }
}
}
var backButton: some View {
Button(action: { self.onCancel() }) {
Text("Back")
}
}
var body: some View {
NavigationView {
List {
Section(header: Text("Enrolled")) {
ForEach(enrolledClients) { client in
SessionInviteListItem(client: client, isEnrolled: true, onTap: removeClient)
}
}
Section(header: Text("Others")) {
ForEach(searchFilteredClients) { client in
SessionInviteListItem(client: client, isEnrolled: false, onTap: addClient)
}
}
}
.listStyle(.insetGrouped)
.searchable(text: $searchText)
.navigationTitle("Invites")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(leading: backButton)
}
}
func removeClient(clientId: String) {
self.sessionViewModel.session.invites.removeAll(where: { $0 == clientId })
refresh.toggle()
}
func addClient(clientId: String) {
self.sessionViewModel.session.invites.append(clientId)
refresh.toggle()
}
func dismiss() {
self.presentationMode.wrappedValue.dismiss()
}
func onCancel() {
self.dismiss()
}
}
struct SessionInvitesView_Previews: PreviewProvider {
#State static var model = TrainingSessionViewModel()
static var previews: some View {
SessionInvitesView(sessionViewModel: $model)
}
}
List item view
import SwiftUI
struct SessionInviteListItem: View {
var client : Client
#State var isEnrolled : Bool
var onTap : (String) -> ()
var body: some View {
HStack {
VStack(alignment: .leading) {
HStack {
Text(client.dogName.uppercased())
.bold()
Text("(\(client.dogBreed))")
}
Text(client.name)
.font(.subheadline)
}
Spacer()
Button(action: { onTap(client.id!) }) {
Image(systemName: self.isEnrolled ? "xmark.circle.fill" : "plus.circle.fill")
}
.buttonStyle(.borderless)
.foregroundColor(self.isEnrolled ? .red : .green)
}
}
}
struct SessionInviteListItem_Previews: PreviewProvider {
static func doNothing(_ : String) {}
static var previews: some View {
SessionInviteListItem(client: buildSampleClient(), isEnrolled: false, onTap: doNothing)
}
}
Higher level view used to navigate to this list view
import SwiftUI
import FirebaseFirestoreSwift
struct TrainingSessionEditView: View {
// MARK: - Member Variables
#Environment(\.presentationMode) private var presentationMode
#FirestoreQuery(collectionPath: "clients") var clients : [Client]
#StateObject var sheetManager = SheetManager()
var mode: Mode = .new
var dateManager = DateManager()
#State var viewModel = TrainingSessionViewModel()
#State var sessionDate = Date.now
#State var startTime = Date.now
#State var endTime = Date.now.addingTimeInterval(3600)
var completionHandler: ((Result<Action, Error>) -> Void)?
// MARK: - Local Views
var cancelButton: some View {
Button(action: { self.onCancel() }) {
Text("Cancel")
}
}
var saveButton: some View {
Button(action: { self.onSave() }) {
Text("Save")
}
}
var addInviteButton : some View {
Button(action: { sheetManager.showInvitesSheet.toggle() }) {
HStack {
Text("Add")
Image(systemName: "plus")
}
}
}
// MARK: - Main View
var body: some View {
NavigationView {
List {
Section(header: Text("Details")) {
TextField("Session Name", text: $viewModel.session.title)
TextField("Location", text: $viewModel.session.location)
}
Section {
DatePicker(selection: $sessionDate, displayedComponents: .date) {
Text("Date")
}
.onChange(of: sessionDate, perform: { _ in
viewModel.session.date = dateManager.dateToStr(date: sessionDate)
})
DatePicker(selection: $startTime, displayedComponents: .hourAndMinute) {
Text("Start Time")
}
.onAppear() { UIDatePicker.appearance().minuteInterval = 15 }
.onChange(of: startTime, perform: { _ in
viewModel.session.startTime = dateManager.timeToStr(date: startTime)
})
DatePicker(selection: $endTime, displayedComponents: .hourAndMinute) {
Text("End Time")
}
.onAppear() { UIDatePicker.appearance().minuteInterval = 15 }
.onChange(of: endTime, perform: { _ in
viewModel.session.endTime = dateManager.timeToStr(date: endTime)
})
}
Section {
HStack {
Text("Clients")
Spacer()
Button(action: { self.sheetManager.showInvitesSheet.toggle() }) {
Text("Edit").foregroundColor(.blue)
}
}
ForEach(viewModel.session.invites, id: \.self) { clientID in
self.createClientListElement(id: clientID)
}
.onDelete(perform: deleteInvite)
}
Section(header: Text("Notes")) {
TextField("Add notes here...", text: $viewModel.session.notes)
}
if mode == .edit {
Section {
HStack {
Spacer()
Button("Delete Session") {
sheetManager.showActionSheet.toggle()
}
.foregroundColor(.red)
Spacer()
}
}
}
}
.navigationTitle(mode == .new ? "New Training Session" : "Edit Training Session")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(
leading: cancelButton,
trailing: saveButton)
.actionSheet(isPresented: $sheetManager.showActionSheet) {
ActionSheet(title: Text("Are you sure?"),
buttons: [
.destructive(Text("Delete Session"), action: { self.onDelete() }),
.cancel()
])
}
.sheet(isPresented: $sheetManager.showInvitesSheet) {
SessionInvitesView(sessionViewModel: $viewModel)
}
}
}
func createClientListElement(id: String) -> some View {
let client = clients.first(where: { $0.id == id })
if let client = client {
return AnyView(ClientListItem(client: client))
}
else {
return AnyView(Text("Invalid Client ID: \(id)"))
}
}
func deleteInvite(indexSet: IndexSet) {
viewModel.session.invites.remove(atOffsets: indexSet)
}
// MARK: - Local Event Handlers
func dismiss() {
self.presentationMode.wrappedValue.dismiss()
}
func onCancel() {
self.dismiss()
}
func onSave() {
self.viewModel.onDone()
self.dismiss()
}
func onDelete() {
self.viewModel.onDelete()
self.dismiss()
self.completionHandler?(.success(.delete))
}
// MARK: - Sheet Management
class SheetManager : ObservableObject {
#Published var showActionSheet = false
#Published var showInvitesSheet = false
}
}
struct TrainingSessionEditView_Previews: PreviewProvider {
static var previews: some View {
TrainingSessionEditView(viewModel: TrainingSessionViewModel(session: buildSampleTrainingSession()))
}
}
I'm happy to include any of the other files if you think it would help. Thanks in advance!

The #ObservedResults loads old data in View on deletion, creation or update

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.

SwiftUI - CoreData - ForEach - Wrong Item gets deleted, Why?

I´ve got a list of Fetched Core Data Items, displayed as a NavigationLink inside a ForEach Loop.
Each of those Elements can be deleted by Swipe or Context Menu.
However when I add an additional confirmationDialog, and move the actual delete action into that one, the wrong item gets deleted (until the actual selected Item is the last one).
Without the confirmationDialog, and the delete Action inside the Button, it works fine.
Does anyone have any idea why?
Thank you!
import Foundation
import SwiftUI
struct IngredientsList: View {
#Environment(\.managedObjectContext) private var viewContext
#FetchRequest(sortDescriptors: []) private var ingredients: FetchedResults<Ingredient>
#State private var DeleteDialogue = false
var body: some View {
VStack{
List{
ForEach(ingredients){ingredients in
NavigationLink{
RecipeIngredientsDetailed(ingredients: ingredients, editMode: true, createmode: true)
} label: {
Text(ingredients.ingredientname ?? "")
}
.swipeActions(){
Button(role: .destructive){
DeleteDialogue = true
} label:{
Text("Delete")
}
}
.contextMenu(){
Button(role: .destructive){
DeleteDialogue = true
} label:{
Text("Delete")
}
}
.confirmationDialog("Are you sure?", isPresented: $DeleteDialogue){
Button("Delete Ingredient"){
viewContext.delete(ingredients)
do{
try viewContext.save()
} catch{
}
}
} message: {
Text("This will remove the Ingredient from all Recipes!")
}
}
}
}
}
}
Its because you are using the same boolean for every item. Try making custom View struct for each row, that has its own boolean, e.g.
struct IngredientsList: View {
#FetchRequest(sortDescriptors: []) private var ingredients: FetchedResults<Ingredient>
var body: some View {
List{
ForEach(ingredients){ ingredient in
IngredientRow(ingredient: ingredient)
}
}
}
}
struct IngredientRow: View {
#Environment(\.managedObjectContext) private var viewContext
#State var confirm = false
#ObservedObject var ingredient: Ingredient
var body: some View {
NavigationLink{
IngredientDetail(ingredient: ingredient)
} label: {
Text(ingredients.ingredientname ?? "")
}
.swipeActions {
Button(role: .destructive){
confirm = true
} label: {
Text("Delete")
}
}
.contextMenu {
Button(role: .destructive){
confirm = true
} label:{
Text("Delete")
}
}
.confirmationDialog("Are you sure?", isPresented: $confirm){
Button("Delete Ingredient"){
viewContext.delete(ingredient)
do {
try viewContext.save()
} catch {
}
}
} message: {
Text("This will remove the Ingredient from all Recipes!")
}
}
}
And btw, without a valid sortDescriptor the list might not behave correctly.

Attaching .popover to a ForEach or Section within a List creates multiple popovers

I have a List with multiple Section and each Section has different type of data. For each section I want clicking on an item to present a popover.
Problem is that if I attach the .popover to the Section or ForEach then the .popover seems to be applied to every entry in the list. So the popover gets created for each item even when just one is clicked.
Example code is below. I cannot attach the .popover to the List because, in my case, there are 2 different styles of .popover and each view can only have a single .popover attached to it.
struct Item: Identifiable {
var id = UUID()
var title: String
}
var items: [Item] = [
Item(title: "Item 1"),
Item(title: "Item 2"),
Item(title: "Item 3"),
]
struct PopoverView: View {
#State var item: Item
var body: some View {
print("new PopoverView")
return Text("View for \(item.title)")
}
}
struct ContentView: View {
#State var currentItem: Item?
var body: some View {
List {
Section(header: Text("Items")) {
ForEach(items) { item in
Button(action: { currentItem = item }) {
Text("\(item.title)")
}
}
}
}
}
}
The current best solution I have come up with is to attach the popover to each Button and then only allow one popover based on currentItem,
Button(action: { currentItem = item }) {
Text("\(item.title)")
}
.popover(isPresented: .init(get: { currentItem == item },
set: { $0 ? (currentItem = item) : (currentItem = nil) })) {
PopoverView(item: item)
}
Any better way to do this?
Bonus points to solve this: When I used my hack, the drag down motion seems to glitch and the view appears from the top again. Not sure what the deal with that is.
You can always create a separate view for your item.
struct MyGreatItemView: View {
#State var isPresented = false
var item: Item
var body: some View {
Button(action: { isPresented = true }) {
Text("\(item.title)")
}
.popover(isPresented: $isPresented) {
PopoverView(item: item)
}
}
}
And implement it to ContentView:
struct ContentView: View {
var body: some View {
List {
Section(header: Text("Items")) {
ForEach(items) { item in
MyGreatItemView(item: item)
}
}
}
}
}
Trying to reach component like sheet or popover in ForEach causes problems.
I've also faced the glitch you mentioned, but below (with sheet) works as expected;
List {
Section(header: Text("Items")) {
ForEach(items) { item in
Button(action: { currentItem = item }) {
Text("\(item.title)")
}
}
}
}
.sheet(item: $currentItem, content: PopoverView.init)
Here's a late suggestion, I used a ViewModifier to hold the show Popover state on each view, the modifier also builds the Popover menu and also handles the presented sheet initiated from the popover menu. (Here's some code...)
struct Item: Identifiable {
var id = UUID()
var title: String
}
var items: [Item] = [
Item(title: "Item 1"),
Item(title: "Item 2"),
Item(title: "Item 3"),
]
struct ContentView: View {
var body: some View {
List {
Section(header: Text("Items")) {
ForEach(items) { item in
Text("\(item.title)").popoverWithSheet(item: item)
}
}
}
}
}
struct SheetFromPopover: View {
#State var item: Item
var body: some View {
print("new Sheet from Popover")
return Text("Sheet for \(item.title)")
}
}
struct PopoverModifierForView : ViewModifier {
#State var showSheet : Bool = false
#State var showPopover : Bool = false
var item : Item
var tap: some Gesture {
TapGesture(count: 1)
.onEnded { _ in self.showPopover = true }
}
func body(content: Content) -> some View {
content
.popover(isPresented: $showPopover,
attachmentAnchor: .point(.bottom),
arrowEdge: .bottom) {
self.createPopover()
}
.sheet(isPresented: self.$showSheet) {
SheetFromPopover(item: item)
}
.gesture(tap)
}
func createPopover() -> some View {
VStack {
Button(action: {
self.showPopover = false
self.showSheet = true
}) {
Text("Show Sheet...")
}.padding()
Button(action: {
print("Something Else..")
}) {
Text("Something Else")
}.padding()
}
}
}
extension View {
func popoverWithSheet(item: Item) -> some View {
modifier(PopoverModifierForView(item: item))
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

SwiftUI TextField not updating in sibling View (with video)

I have a simple todo list demo app I built to understand the relationship between SwiftUI and Core Data. When I modify a Task in the TaskDetail view the changes are not reflected within the TextField residing in the TaskRow view. Both of these views are children of the ContentView.
Sudo Fix: If I change out TextField for Text the view is updated as expected; but, I need to edit the title attribute in Task from the row.
2nd Option: It seems like every tutorial avoids updating data inside a child view using Core Data. I can use #EnvironmentObject to sync data across views easily (with structs). However, keeping the environment data and the Core Data store synced sounds like a nightmare. I'd expect there to be an easier way :D
Video of Issue: https://youtu.be/JV-jQHpXE4Y
Code
ContentView.swift
import SwiftUI
import CoreData
struct ContentView: View {
#Environment(\.managedObjectContext) var context
#FetchRequest(entity: Task.entity(), sortDescriptors: [NSSortDescriptor(keyPath: \Task.position, ascending: true)]) var tasks: FetchedResults<Task>
init() {
print("INIT - Content View")
}
var body: some View {
NavigationView {
VStack {
todoList
newButton
}
}
}
}
extension ContentView {
var todoList: some View {
List {
ForEach(self.tasks, id: \.id) { task in
NavigationLink(destination: TaskDetail(task: task)) {
TaskRow(task: task)
}
}
.onDelete { indices in
for index in indices {
self.context.delete(self.tasks[index])
try? self.context.save()
}
}
.onMove(perform: move)
}
.navigationBarItems(trailing: EditButton())
}
var newButton: some View {
Button(action: {
self.newTask()
}, label: {
Text("Add Random Task")
}).padding([.bottom, .top], 20)
}
}
extension ContentView {
private func newTask() {
let things = ["Cook", "Clean", "Eat", "Workout", "Program"]
let newItem = Task(context: self.context)
newItem.id = UUID()
newItem.title = things.randomElement()!
newItem.position = Int64(self.tasks.count)
newItem.completed = Bool.random()
try? self.context.save()
}
private func move(from source: IndexSet, to destination: Int) {
// Make an array of items from fetched results
var revisedItems: [Task] = self.tasks.map{ $0 }
// change the order of the items in the array
revisedItems.move(fromOffsets: source, toOffset: destination )
// update the userOrder attribute in revisedItems to
// persist the new order. This is done in reverse order
// to minimize changes to the indices.
for reverseIndex in stride(from: revisedItems.count - 1, through: 0, by: -1) {
revisedItems[reverseIndex].position = Int64(reverseIndex)
try? self.context.save()
}
}
}
TaskRow.swift
import SwiftUI
import CoreData
struct TaskRow: View {
#Environment(\.managedObjectContext) var context
#ObservedObject var task: Task
#State private var title: String
init(task: Task) {
self.task = task
self._title = State(initialValue: task.title ?? "")
print("INIT - TaskRow Initialized: title=\(title), completed=\(task.completed)")
}
var body: some View {
HStack {
TextField(self.task.title ?? "", text: self.$title) {
self.task.title = self.title
self.save()
}.foregroundColor(.black)
// Text(self.task.title ?? "")
Spacer()
Text("\(self.task.position)")
Button(action: {
self.task.completed.toggle()
self.save()
}, label: {
Image(systemName: self.task.completed ? "checkmark.square" : "square")
}).buttonStyle(BorderlessButtonStyle())
}
}
}
extension TaskRow {
func save() {
try? self.context.save()
print("SAVE - TaskRow")
}
}
TaskDetail.swift
import SwiftUI
struct TaskDetail: View {
#Environment(\.managedObjectContext) var context
#ObservedObject var task: Task
#State private var title: String
init(task: Task) {
self.task = task
self._title = State(initialValue: task.title ?? "")
print("INIT - TaskDetail Initialized: title=\(title), completed=\(task.completed)")
}
var body: some View {
Form {
Section {
TextField(self.title, text: self.$title) {
self.task.title = self.title
self.save()
}.foregroundColor(.black)
}
Section {
Button(action: {
self.task.completed.toggle()
self.save()
}, label: {
Image(systemName: self.task.completed ? "checkmark.square" : "square")
}).buttonStyle(BorderlessButtonStyle())
}
}
}
}
extension TaskDetail {
func save() {
try? self.context.save()
print("SAVE - TaskDetail")
}
}
Core Data Model of Task
Edit
This has to do with the 'PlaceHolder' text (first argument) within the TextField. If I modify the Task in TaskDetail and then navigate back to ContentView it doesn't appear to update. But, if I remove the text in the row (highlight, backspace) the 'PlaceHolder' text contains the updated value.
What's strange is that exiting the app and restarting it displays the changes made in the TextField with dark font (expected behavior without the restart).
Try the following
var body: some View {
HStack {
TextField(self.task.title ?? "", text: self.$title) {
self.task.title = self.title
self.save()
}.foregroundColor(.black)
.onReceive(task.objectWillChange) { _ in // << here !!
if task.title != self.title {
task.title = self.title
}
}
It's fun to play with SwiftUI (I have no experience with it). But what I can see in most of the questions in different forums about TextField, the binding value can be created by .constant. Therefore, use this:
TextField(self.task.title ?? "", text: .constant(self.task.title!))
This should work now.
Demo in GIF:
Use #Binding instead of #State
It is important to remember that TextField is actually a SwiftUI View (via inheritance). The parent child relationship is actually TaskRow -> TextField.
#State is used for representing the 'state' of a view. While this value can be passed around, it's not meant to be written to by other views (it has a single source of truth).
In the case above, I am actually passing title (via $ prefix) to another view while expecting either the parent or child to modify the title property. #Binding supports 2 way communication between views or a property and view.
#State Apple Docs: https://developer.apple.com/documentation/swiftui/state
#Binding Apple Docs: https://developer.apple.com/documentation/swiftui/binding
Jared Sinclair's Wrapper Rules: https://jaredsinclair.com/2020/05/07/swiftui-cheat-sheet.html
Changing the TaskRow and TaskDetail views fixed the behavior:
TaskRow.swift
import SwiftUI
import CoreData
struct TaskRow: View {
#Environment(\.managedObjectContext) var context
#ObservedObject var task: Task
#Binding private var title: String
init(task: Task) {
self.task = task
self._title = Binding(get: {
return task.title ?? ""
}, set: {
task.title = $0
})
print("INIT - TaskRow Initialized: title=\(task.title ?? ""), completed=\(task.completed)")
}
var body: some View {
HStack {
TextField("Task Name", text: self.$title) {
self.save()
}.foregroundColor(.black)
Spacer()
Text("\(self.task.position)")
Button(action: {
self.task.completed.toggle()
self.save()
}, label: {
Image(systemName: self.task.completed ? "checkmark.square" : "square")
}).buttonStyle(BorderlessButtonStyle())
}
}
}
extension TaskRow {
func save() {
try? self.context.save()
print("SAVE - TaskRow")
}
}
TaskDetail.swift
import SwiftUI
struct TaskDetail: View {
#Environment(\.managedObjectContext) var context
#ObservedObject var task: Task
#Binding private var title: String
init(task: Task) {
self.task = task
self._title = Binding(get: {
return task.title ?? ""
}, set: {
task.title = $0
})
print("INIT - TaskDetail Initialized: title=\(task.title ?? ""), completed=\(task.completed)")
}
var body: some View {
Form {
Section {
TextField("Task Name", text: self.$title) {
self.save()
}.foregroundColor(.black)
}
Section {
Button(action: {
self.task.completed.toggle()
self.save()
}, label: {
Image(systemName: self.task.completed ? "checkmark.square" : "square")
}).buttonStyle(BorderlessButtonStyle())
}
}
}
}
extension TaskDetail {
func save() {
try? self.context.save()
print("SAVE - TaskDetail")
}
}

Resources