ForEach not updating when deleting from ActionSheet - swiftui

I have an app that has a view with a for each with some child views. On the child view, I have a delete button that triggers a completion handler that shows an ActionSheet on the parent view that deletes the corresponding child, except the for each is not updating (keeps showing the original items).
The weird thing is that if I delete it directly in the completion handler (no ActionSheet), it works fine, but when I delete it from the ActionSheet, it doesn't work. Also, I have another action sheet that deletes an entire project, and that works fine.
I have an optional state property to store the item to delete (sent back by the completion handler).
The ForEach (on parent):
ForEach(viewModel.project.counters.indices, id: \.self) { i in
AddEditCounterView(
viewModel: AddEditCounterViewModel(
counter: viewModel.project.counters[i],
projectCount: viewModel.project.counters.count,
saveHandler: { counter in viewModel.updateCounter(counter: counter) },
deleteHandler: { counterToDelete in
self.counterToDelete = counterToDelete
activeSheet = .deleteCounter
// viewModel.deleteCounter(counterToDelete) // If I delete directly here, it works
}
),
isNew: false,
isValid: $countersValidationResult[i],
doValidation: $performValidateCounters
)
}
The Button (on child):
Button(action: {
viewModel.deleteHandler(viewModel.counter)
}, label: {
Text(NSLocalizedString("Delete Counter", comment: "button label"))
})
The ActionSheet (on parent):
.actionSheet(item: $activeSheet) { item in
switch item {
case .deleteProject:
return ActionSheet(title: Text(NSLocalizedString(String.localizedStringWithFormat("Delete %#", viewModel.project.name), comment: "alert title")), message: Text(NSLocalizedString("Deleting a project can't be undone", comment: "Deleting alert message")), buttons: [
.destructive(Text(NSLocalizedString("Delete", comment: "Button label"))) {
viewModel.deleteHandler(viewModel.project.id) // This works fine
},
.cancel()
])
case .deleteCounter: // This is where the problem happens
return ActionSheet(title: Text(NSLocalizedString(String.localizedStringWithFormat("Delete %#", counterToDelete?.name ?? NSLocalizedString("Counter", comment: "Generic counter name")), comment: "alert title")), message: Text(NSLocalizedString("Deleting a counter can't be undone", comment: "Deleting alert message")), buttons: [
.destructive(Text(NSLocalizedString("Delete", comment: "Button label"))) {
if let c = counterToDelete {
print(viewModel.project.counters.count) // Prints, for example 3
viewModel.deleteCounter(counter: c)
print(viewModel.project.counters.count) // Prints one less than above, 2
}
},
.cancel()
])
}
}
I tried having just the one action sheet (to see if it was that having two was an issue) but makes no difference.
Any ideas?

Related

Does XCUIElement represent a query rather than a specific element?

This example is pretty contrived, but it illustrates the behavior. I know you can use .accessibilityIdentifier to uniquely identify a control, but I'm just trying to better understand the interplay between XCUIElement and XCUIElementQuery.
Let's say you have an app like this:
import SwiftUI
struct ContentView: View {
#State var showRedButton = true
var body: some View {
VStack {
if showRedButton {
Button("Click me") {
showRedButton = false
}
.background(.red)
}
else {
HStack {
Button("Click me") {
showRedButton = true
}
.background(.blue)
Spacer()
}
}
}
}
}
And you are UI testing like this:
import XCTest
final class MyAppUITests: XCTestCase {
func testExample() throws {
let app = XCUIApplication()
app.launch()
print(app.debugDescription)
// At this point, the Element subtree shows a single Button:
// Button, 0x14e40d290, {{162.3, 418.3}, {65.3, 20.3}}, label: 'Click me'
let btn = app.buttons["Click me"]
btn.tap() // <-- This tap makes the red button disappear and shows the blue button
print(app.debugDescription)
// Now, the Element subtree shows a single Button that has a different ID
// and different x-y coordinates:
// Button, 0x15dc12e50, {{0.0, 418.3}, {65.3, 20.3}}, label: 'Click me'
btn.tap() // <-- This tap now works on the blue button?? Without requerying?
print(app.debugDescription)
// The red button reappears, but with a different ID (which makes sense).
}
}
Why does the second tap work, even though it's a different control? This must mean that SwiftUI is automatically re-running the XCUIElementQuery to find the button that matches "Click me". Apparently the variable btn isn't linked to the control with the ID 0x14e40d290. Does this mean XCUIElement actually represents an XCUIElementQuery? I expected it to require me to explicitly re-run the query like this,
btn = app.buttons["Click me"]
prior to running the 2nd tap, or the tap would've said that btn was no longer available.
The final print of the Element subtree shows that the red button has a different ID now. This makes sense, because when SwiftUI redraws the red button, it's not the same instance as the last time. This is explained well in the WWDC videos. Nevertheless, at the moment I connected the variable "btn" to the control, I thought there was a tighter affiliation. Maybe UI testing has to behave this way because SwiftUI redraws controls so frequently?

Unexpected SwiftUI #State behavior

The setup:
My app has a View ToolBar (all shortened):
struct ToolBar: View {
#State private var ownerShare: CKShare?
#State private var show_ownerModifyShare = false
// …
The toolbar has some buttons that are created by functions. One of them is
func sharingButton(imageName: String, enabled: Bool) -> SharingButton {
return SharingButton(systemImageName: imageName, enabled: enabled) {
Task {
do {
(_, participantShare, ownerShare) = try await dataSource.getSharingInfo()
// …
if ownerShare != nil { show_ownerModifyShare = true }
} catch (let error) {
//...
}
}
}
}
This is the body:
var body: some View {
HStack {
// …
sharingButton(imageName: "square.and.arrow.up", enabled: currentMode == .displayingItems)
.fullScreenCover(isPresented: $show_ownerModifyShare) {
// A CKShare record exists in the iCloud private database. Present a controller that allows to modify it.
CloudSharingView(container: CKContainer(identifier: kICloudContainerID), shareRecord: ownerShare!, dataSource: dataSource)
}
}
}
}
The problem:
When the sharingButton is tapped, ownerShare is set in Task {…}, since the iCloud database is shared as an owner.
Accordingly, show_ownerModifyShare = true is executed, and thus the body of struct ToolBar is newly rendered.
However, CloudSharingView(container: CKContainer(identifier: kICloudContainerID), shareRecord: ownerShare!, dataSource: dataSource) crashes, because ownerShare is nil, although it is only set true after ownerShare has been set != nil.
My question:
What could be the reason, and how to correct the code?
EDIT: (due to the comment of jnpdx):
I replaced .fullScreenCover(isPresented: by
.fullScreenCover(item: $ownerShare) { _ in
CloudSharingView(container: CKContainer(identifier: kICloudContainerID), shareRecord: ownerShare!, dataSource: dataSource)
}
but the code still crashes with Thread 1: Fatal error: Unexpectedly found nil while unwrapping an Optional value when ownerShare! is used.
You should use the item form of fullScreenCover, which allows you to send a dynamically-changed parameter to the inner closure:
.fullScreenCover(item: $ownerShare) { share in
CloudSharingView(container: CKContainer(identifier: kICloudContainerID), shareRecord: share, dataSource: dataSource)
}
This is a common issue with sheet and the related functions in SwiftUI, which calculate their closures when first rendered and not at presentation time. See related:
SwiftUI: Switch .sheet on enum, does not work

Vertical SwiftUIPager breaks when adding content on the start of the list, on demand

I have an app, that shows posts on a vertical SwiftUIPager. Each page fills the whole screen.
So, when the app launches, I fetch the 10 most recent posts and display them.
As soon as those posts are fetched, I start listening for new posts. (see code below) Whenever that callback gets triggered, a new post is created. I take it and place it on the top of my list.
The thing is when I scroll to find the new post, its views get mixed up with the views of the next post.
Here's what I mean:
Before the new post, I have the one below
https://imgur.com/a/ZmMzfvb
And then, a new post is added to the top
https://imgur.com/a/PJ0trSF
As you'll notice the image seems to be the same, but it shouldn’t! If I scroll for a while and then go back up, the new post will be fixed and display the proper image. (I'm using SDWebImageSwiftUI for async images, but I don't think it matters... I also used Apple's AsyncImage, with the same results)
Here's my feed view model:
#Published var feedPage: Page = .first()
#Published var feedItems = Array(0..<2)
var posts = [Post]()
...
private func subscribeToNewPosts() {
postsService.subscribeToNewPosts() { [weak self] post in
self?.posts.insert(post, at: 0)
DispatchQueue.main.async {
self?.feedItems = Array(0..<(self?.posts.count ?? 1))
}
}
}
And here's my feed view:
private struct FeedPageView: View {
#EnvironmentObject private var viewModel: FeedView.ViewModel
var body: some View {
ZStack {
VStack {
Pager(page: viewModel.feedPage,
data: viewModel.feedItems,
id: \.self,
content: { index in
if index == 0 {
HomeCameraView()
.background(.black)
} else {
PostView(post: viewModel.posts[index - 1])
}
})
.vertical()
.sensitivity(.custom(0.1))
.onPageWillChange { index in
viewModel.willChangeVerticalPage(index: index)
}
}
}
}
}
Any idea what I'm doing wrong?

How to completely delete items when using #ObservedResults in Realm and SwiftUI

I'm trying the new Realm 10 wrappers by using the example given in the Realm documentation Integration Guides -> SwiftUI & Combine and I like how simple it is to add and delete records when using the #ObservedResults and the #ObservedRealmObject. The one thing I don't quite understand is why when deleting items from the Group object it only removes the items from the Group but leaves the actual items in the Item Realm object undeleted. See the Realm Browser image below.
Here is what the Realm Browser shows after adding and deleting four (4) items through the app UI, as you can see the four (4) items were deleted from the Group but left all four (4) items in the Item object.
Can someone please explain why the items don't get deleted from the Item object only from the Group object when calling .onDelete(perform: $group.items.remove)? How can I delete them?
I tried deleting them like this...
ItemsView.swift
.onDelete(perform: deleteItems)
func deleteItems(at offsets: IndexSet){
let realm = try? Realm()
try! realm?.write {
// 1. delete items
for item in list.items{
realm?.delete(item)
}
// 2. delete the list
realm?.delete(list)
}
}
but I got the following error:
Thread 1: "Can only delete an object from the Realm it belongs to."
Again, the whole code can be found in the Integration Guides - Without Sync.
EDIT: Added code for, LocalOnlyContentView, ItemsView, Group and Item models.
Item.swift
import Foundation
import RealmSwift
/// Random adjectives for more interesting demo item names
let randomAdjectives = [
"fluffy", "classy", "bumpy", "bizarre", "wiggly", "quick", "sudden",
"acoustic", "smiling", "dispensable", "foreign", "shaky", "purple", "keen",
"aberrant", "disastrous", "vague", "squealing", "ad hoc", "sweet"
]
/// Random noun for more interesting demo item names
let randomNouns = [
"floor", "monitor", "hair tie", "puddle", "hair brush", "bread",
"cinder block", "glass", "ring", "twister", "coasters", "fridge",
"toe ring", "bracelet", "cabinet", "nail file", "plate", "lace",
"cork", "mouse pad"
]
final class Item: Object, ObjectKeyIdentifiable {
#Persisted(primaryKey: true) var _id: ObjectId
#Persisted var name = "\(randomAdjectives.randomElement()!) \(randomNouns.randomElement()!)"
#Persisted var isFavorite = false
#Persisted(originProperty: "items") var group: LinkingObjects<Group>
}
Group.swift
import Foundation
import RealmSwift
final class Group: Object, ObjectKeyIdentifiable {
#Persisted(primaryKey: true) var _id: ObjectId
#Persisted var items = RealmSwift.List<Item>()
}
ItemsView.swift
struct ItemsView: View {
#ObservedRealmObject var group: Group
var leadingBarButton: AnyView?
var body: some View {
NavigationView {
VStack {
// The list shows the items in the realm.
List {
ForEach(group.items) { item in
ItemRow(item: item)
}
.onDelete(perform: $group.items.remove)
.onMove(perform: $group.items.move)
}.listStyle(GroupedListStyle())
.navigationBarTitle("Items", displayMode: .large)
.navigationBarBackButtonHidden(true)
.navigationBarItems(
leading: self.leadingBarButton,
trailing: EditButton())
HStack {
Spacer()
Button(action: {
$group.items.append(Item())
}) { Image(systemName: "plus") }
}.padding()
}
}
}
}
LocalOnlyContentView.swift
struct LocalOnlyContentView: View {
#ObservedResults(Group.self) var groups
var body: some View {
if let group = groups.first {
AnyView(ItemsView(group: group))
} else {
AnyView(ProgressView().onAppear {
$groups.append(Group())
})
}
}
}
SceneDelegate.swift
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
let contentView = LocalOnlyContentView()
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: contentView)
self.window = window
window.makeKeyAndVisible()
}
}
Keep in mind that what's being shown in that tutorial is how to remove an item from a group's List - not how to totally delete the item.
Going very high level with this answer - objects in a List are not the actual objects - they are a "pointer" to the actual item stored on disk.
Suppose you have three items
Item 0
Item 1
Item 2
and a Group with List of items
MyGroup
List of items
Item 0
Item 1
Item 2
What's actually going on is the List "points" to the items on disk
My Group
List of items
pointer to Item 0
pointer to Item 1
pointer to Item 2
So when this is called $group.items.remove it's removing the pointer to the item from the list, not the item itself.
The solution (well, one of the solutions) is to remove the actual item
let myItemToRemove = List of items[0] //get the item at index 0
realm.remove(myItemToRemove) // removes the item itself from realm, along
// with the pointer stored in the list
The code to actually delete an item is this
try? realm.write {
realm.delete(objectToDelete)
}

SwiftUI - Wrong index with removeAll(where: )

I want to let the user remove an image in an array. When the image is pressed, an action sheet is presented so the user can confirm the removal of the image. The problem is that it removes the wrong image. It always removes the first image of the array.
#State var pickerResult: [SImage] = []
...
ScrollView(.horizontal, showsIndicators: true){
HStack{
ForEach(pickerResult) { simage in
Image(uiImage: simage.image)
.onTapGesture() {
imageActionSheetIsPresented = true
// This will work: self.pickerResult.removeAll(where: {$0.image == simage.image})
}
.actionSheet(isPresented: $imageActionSheetIsPresented) {
ActionSheet(title: Text("Do you want to remove the image?"), buttons: [
.default(Text("Remove image")){
self.pickerResult.removeAll(where: {$0.image == simage.image})
print(simage.id)
// Returns id of the first image in the array
},
.cancel()
])
}
}
}
}
As you see in the code, skipping the confirmation and letting the user remove the image with onTap will work just fine.
Some how 'simage' is always the first item in the array when using ActionSheet.
Here is the SImage:
struct SImage: Identifiable{
var id = UUID()
var image: UIImage
}