SwiftUI: Drag & Drop between Lists crashes sometimes - swiftui

I'm using Xcode 12 beta and trying to create a view where items from a left list can be dragged onto a right list and dropped there.
This crashes in the following situations:
The list is empty.
The list is not empty, but the item is dragged behind the last list element, after dragging it onto other list elements first. The crash already appears while the item is dragged, not when it is dropped (i.e., the .onInsert is not called yet).
The crash message tells:
SwiftUI`generic specialization <SwiftUI._ViewList_ID.Views> of (extension in Swift):Swift.RandomAccessCollection< where A.Index: Swift.Strideable, A.Indices == Swift.Range<A.Index>, A.Index.Stride == Swift.Int>.index(after: A.Index) -> A.Index:
Are there any ideas why this happens and how it can be avoided?
The left list code:
struct AvailableBuildingBricksView: View {
#StateObject var buildingBricksProvider: BuildingBricksProvider = BuildingBricksProvider()
var body: some View {
List {
ForEach(buildingBricksProvider.availableBuildingBricks) { buildingBrickItem in
Text(buildingBrickItem.title)
.onDrag {
self.provider(buildingBrickItem: buildingBrickItem)
}
}
}
}
private func provider(buildingBrickItem: BuildingBrickItem) -> NSItemProvider {
let image = UIImage(systemName: buildingBrickItem.systemImageName) ?? UIImage()
let provider = NSItemProvider(object: image)
provider.suggestedName = buildingBrickItem.title
return provider
}
}
final class BuildingBricksProvider: ObservableObject {
#Published var availableBuildingBricks: [BuildingBrickItem] = []
init() {
self.availableBuildingBricks = [
TopBrick.personalData,
TopBrick.education,
TopBrick.work,
TopBrick.overviews
].map({ return BuildingBrickItem(title: $0.title,
systemImageName: "stop") })
}
}
struct BuildingBrickItem: Identifiable {
var id: UUID = UUID()
var title: String
var systemImageName: String
}
The right list code:
struct DocumentStructureView: View {
#StateObject var documentStructureProvider: DocumentStructureProvider = DocumentStructureProvider()
var body: some View {
List {
ForEach(documentStructureProvider.documentSections) { section in
Text(section.title)
}
.onInsert(of: ["public.image"]) {
self.insertSection(position: $0,
itemProviders: $1,
top: true)
}
}
}
func insertSection(position: Int, itemProviders: [NSItemProvider], top: Bool) {
for item in itemProviders.reversed() {
item.loadObject(ofClass: UIImage.self) { image, _ in
if let _ = image as? UIImage {
DispatchQueue.main.async {
let section = DocumentSectionItem(title: item.suggestedName ?? "Unknown")
self.documentStructureProvider.insert(section: section, at: position)
}
}
}
}
}
}
final class DocumentStructureProvider: ObservableObject {
#Published var documentSections: [DocumentSectionItem] = []
init() {
documentSections = [
DocumentSectionItem(title: "Dummy")
]
}
func insert(section: DocumentSectionItem, at position: Int) {
if documentSections.count == 0 {
documentSections.append(section)
return
}
documentSections.insert(section, at: position)
}
}
struct DocumentSectionItem: Identifiable {
var id: UUID = UUID()
var title: String
}

Well, I succeeded to make the problem reproducable, code below.
Steps to reproduce:
Drag "A" on "1" as first item on the right.
Drag another "A" on "1", hold it dragged, draw it slowly down after "5" -> crash.
The drop function is not called before the crash.
struct ContentView: View {
var body: some View {
HStack {
LeftList()
Divider()
RightList()
}
}
}
import SwiftUI
import UniformTypeIdentifiers
struct LeftList: View {
var list: [String] = ["A", "B", "C", "D", "E"]
var body: some View {
List(list) { item in
Text(item)
.onDrag {
let stringItemProvider = NSItemProvider(object: item as NSString)
return stringItemProvider
}
}
}
}
import SwiftUI
import UniformTypeIdentifiers
struct RightList: View {
#State var list: [String] = ["1", "2", "3", "4", "5"]
var body: some View {
List {
ForEach(list) { item in
Text(item)
}
.onInsert(
of: [UTType.text],
perform: drop)
}
}
private func drop(at index: Int, _ items: [NSItemProvider]) {
debugPrint(index)
for item in items {
_ = item.loadObject(ofClass: NSString.self) { text, _ in
debugPrint(text)
DispatchQueue.main.async {
debugPrint("dispatch")
text.map { self.list.insert($0 as! String, at: index) }
}
}
}
}
}

Related

Why can't I write to an #ObservedObject?

I have a struct called Activity which has an id (UUID), name (String), description (String) and timesCompleted (Int).
I also have a class called Activities that contains an array of Activity structs called activityList. Activities is marked with ObservableObject.
I have activities declared as a #StateObject in my ContentView and I pass it to my ActivityDetailView where it is declared as an #ObservedObject.
However I can only partially write to activities.activityList in the child view. I can append, but I can't overwrite, update or remove an element from the array. No error is thrown but the view immediately crashes and the app returns to the main ContentView.
How do you update/write to an #ObservedObject? As you can see from the comments in my updateTimesCompleted() function I've tried all kinds of things to update/overwrite an existing element. All crash silently and return to ContentView. Append does not fail, but isn't the behavior I want, I want to update/overwrite an array element, not append a new copy.
Activity Struct:
struct Activity : Codable, Identifiable, Equatable {
var id = UUID()
var name: String
var description: String
var timesCompleted: Int
}
Activities Class:
class Activities: ObservableObject {
#Published var activityList = [Activity]() {
didSet {
if let encoded = try? JSONEncoder().encode(activityList) {
UserDefaults.standard.set(encoded, forKey: "activityList")
}
}
}
init() {
if let savedList = UserDefaults.standard.data(forKey: "activityList") {
if let decodedList = try? JSONDecoder().decode([Activity].self, from: savedList) {
activityList = decodedList
return
}
}
activityList = []
}
init(activityList: [Activity]) {
self.activityList = activityList
}
subscript(index: Int) -> Activity {
get {
assert(index < activityList.count, "Index out of range")
return activityList[index]
}
set {
assert(index < activityList.count, "Index out of range")
activityList[index] = newValue
}
}
}
ContentView:
struct ContentView: View {
#StateObject var activities = Activities()
#State private var showingAddActivity = false
var body: some View {
NavigationView {
List {
ForEach(activities.activityList) { activity in
NavigationLink {
ActivityDetailView(activity: activity, activities: activities)
} label: {
Text(activity.name)
}
}
}
.navigationTitle("Habits")
.toolbar {
Button {
showingAddActivity = true
let _ = print("add activity")
}
label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showingAddActivity) {
AddActivityView(activities: activities)
}
}
}
ActivityDetailView:
struct ActivityDetailView: View {
#State private var timesCompleted = 0
let activity: Activity
#ObservedObject var activities: Activities
var body: some View {
NavigationView {
Form {
Text("Activity: \(activity.name)")
Text("Description: \(activity.description)")
Stepper {
Text("Times Completed: \(timesCompleted)")
} onIncrement: {
timesCompleted += 1
updateTimesCompleted()
} onDecrement: {
if timesCompleted > 0 {
timesCompleted -= 1
updateTimesCompleted()
}
}
}
.navigationTitle("Activity Details")
}
}
func updateTimesCompleted() {
let newActivity = Activity(name: activity.name, description: activity.description, timesCompleted: timesCompleted)
let _ = print("count: \(activities.activityList.count)")
let index = activities.activityList.firstIndex(of: activity)
let _ = print(index ?? -666)
if let index = index {
activities.activityList[index] = Activity(name: activity.name, description: activity.description, timesCompleted: timesCompleted)
//activities.activityList.swapAt(index, activities.activityList.count - 1)
//activities.activityList[index].incrementTimesCompleted()
//activities.activityList.append(newActivity)
//activities.activityList.remove(at: index)
//activities.activityList.removeAll()
//activities.activityList.append(newActivity)
}
}
}
You could try this approach, where the activity is passed to the ActivityDetailView
as a binding.
In addition, #ObservedObject var activities: Activities is used directly in AddActivityView to add an Activity to the list.
struct Activity : Codable, Identifiable, Equatable {
let id = UUID() // <-- here
var name: String
var description: String
var timesCompleted: Int
enum CodingKeys: String, CodingKey { // <-- here
case name,description,timesCompleted
}
}
class Activities: ObservableObject {
#Published var activityList = [Activity]() {
didSet {
if let encoded = try? JSONEncoder().encode(activityList) {
UserDefaults.standard.set(encoded, forKey: "activityList")
}
}
}
init() {
if let savedList = UserDefaults.standard.data(forKey: "activityList") {
if let decodedList = try? JSONDecoder().decode([Activity].self, from: savedList) {
activityList = decodedList
return
}
}
activityList = []
}
init(activityList: [Activity]) {
self.activityList = activityList
}
subscript(index: Int) -> Activity {
get {
assert(index < activityList.count, "Index out of range")
return activityList[index]
}
set {
assert(index < activityList.count, "Index out of range")
activityList[index] = newValue
}
}
}
struct ContentView: View {
#StateObject var activities = Activities()
#State private var showingAddActivity = false
var body: some View {
NavigationView {
List {
ForEach($activities.activityList) { $activity in // <-- here
NavigationLink {
ActivityDetailView(activity: $activity) // <-- here
} label: {
Text(activity.name)
}
}
}
.navigationTitle("Habits")
.toolbar {
Button {
showingAddActivity = true
}
label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showingAddActivity) {
AddActivityView(activities: activities)
}
.onAppear {
// for testing
if activities.activityList.isEmpty {
activities.activityList.append(Activity(name: "activity-1", description: "activity-1", timesCompleted: 1))
activities.activityList.append(Activity(name: "activity-2", description: "activity-2", timesCompleted: 2))
activities.activityList.append(Activity(name: "activity-3", description: "activity-3", timesCompleted: 3))
}
}
}
}
// -- here for testing
struct AddActivityView: View {
#ObservedObject var activities: Activities
var body: some View {
Text("AddActivityView")
Button("add activity") {
activities.activityList.append(Activity(name: "workingDog", description: "workingDog", timesCompleted: 5))
}
}
}
struct ActivityDetailView: View {
#Binding var activity: Activity // <-- here
var body: some View {
Form {
Text("Activity: \(activity.name)")
Text("Description: \(activity.description)")
Stepper {
Text("Times Completed: \(activity.timesCompleted)")
} onIncrement: {
activity.timesCompleted += 1 // <-- here
} onDecrement: {
if activity.timesCompleted > 0 {
activity.timesCompleted -= 1 // <-- here
}
}
}
}
}

What's the best way to achieve parameterized "on tap"/"on click" behavior for a list row?

So let's say I have a list component in SwiftUI:
struct MyListView: View {
var body: some View {
List(...) { rec in
Row(rec)
}
}
}
Now let's say I want to make this reusable, and I want the "caller" of this view to determine what happens when I tap on each row view. What would be the correct way to insert that behavior?
Here is some other Buttons in ListView example that you can run and play with it yourself
import SwiftUI
struct TestTableView: View {
#State private var item: MyItem?
var body: some View {
NavigationView {
List {
// Cell as Button that display Sheet
ForEach(1...3, id:\.self) { i in
Button(action: { item = MyItem(number: i) }) {
TestTableViewCell(number: i)
}
}
// Cell as NavigationLink
ForEach(4...6, id:\.self) { i in
NavigationLink(destination: TestTableViewCell(number: i)) {
TestTableViewCell(number: i)
}
}
// If you want a button inside cell which doesn't trigger the whole cell when being touched
HStack {
TestTableViewCell(number: 7)
Spacer()
Button(action: { item = MyItem(number: 7) }) {
Text("Button").foregroundColor(.accentColor)
}.buttonStyle(PlainButtonStyle())
}
}
}.sheet(item: $item) { myItem in
TestTableViewCell(number: myItem.number)
}
}
struct MyItem: Identifiable {
var number: Int
var id: Int { number }
}
}
struct TestTableViewCell: View {
var number: Int
var body: some View {
Text("View Number \(number)")
}
}
Make it like Button and takes an action param that is a closure.
From my understanding you're looking for a reusable generic List view with tap on delete functionality. If I'm guessing right my approach then would be like this:
struct MyArray: Identifiable {
let id = UUID()
var title = ""
}
struct ContentView: View {
#State private var myArray = [
MyArray(title: "One"),
MyArray(title: "Two"),
MyArray(title: "Three"),
MyArray(title: "Four"),
MyArray(title: "Five"),
]
var body: some View {
MyListView(array: myArray) { item in
Text(item.title) // row view
} onDelete: { item in
myArray.removeAll(where: {$0.id == item.id}) // delete func
}
}
}
struct MyListView<Items, Label>: View
where Items: RandomAccessCollection, Items.Element: Identifiable, Label: View {
var array: Items
var row: (Items.Element) -> Label
var onDelete: (Items.Element) -> ()
var body : some View {
List(array) { item in
Button {
onDelete(item)
} label: {
row(item)
}
}
}
}

Deleting row in Form results in Fatal error: Index out of range

I have the following code, which will compile only in Xcode 13 beta, not in Xcode 12. The project is targeting iOS 13 and up. Note that it uses the new syntax that can pass a binding to an array element, which is what I need in my project (but this demo code could do without).
class MyViewModel: ObservableObject {
#Published var model: MyModel
init() {
self.model = MyModel(components: ["alpha", "bravo", "charlie", "delta", "echo", "foxtrot"])
}
}
struct MyModel {
var components: [String]
}
struct ContentView: View {
#EnvironmentObject var viewModel: MyViewModel
var body: some View {
NavigationView {
Form {
Section(header: Text("Components")) {
ForEach(self.$viewModel.model.components, id: \.self) { $component in
Text(component)
}
.onDelete(perform: delete)
}
}
}.navigationViewStyle(StackNavigationViewStyle())
}
private func delete(_ indices: IndexSet) {
for index in indices {
self.viewModel.model.components.remove(at: index)
}
}
}
Furthermore, the ContentView() must be injected with the correct environment object, i.e.:
struct FormDeleteCrashDemo_iOS13App: App {
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(MyViewModel())
}
}
}
When running this code on iPad or iPhone running iOS 14, the user can swipe-left-to-delete. Deleting the bottom row is fine. Deleting any other row will crash with the following error:
Swift/ContiguousArrayBuffer.swift:580: Fatal error: Index out of range
When I replace the Form with List, then the crash does not occur. Why?
This looks like a bug to me. Crash happens exactly with Form(works fine with List)
I've reported it it to feedback assistant, so let's hope it'll be fixed before release.
Meanwhile you can try this CustomForEach, inspired by my ListIndexed. All animations looks totally fine.
struct CustomForEach<Data: MutableCollection&RandomAccessCollection, RowContent: View, ID: Hashable>: View, DynamicViewContent where Data.Index : Hashable
{
let forEach: ForEach<[(Data.Index, Data.Element)], ID, RowContent>
init(_ data: Binding<Data>,
#ViewBuilder rowContent: #escaping (Binding<Data.Element>) -> RowContent
) where Data.Element: Identifiable, Data.Element.ID == ID {
forEach = ForEach(
Array(zip(data.wrappedValue.indices, data.wrappedValue)),
id: \.1.id
) { i, _ in
rowContent(Binding(get: { data.wrappedValue[i] }, set: { data.wrappedValue[i] = $0 }))
}
}
init(_ data: Binding<Data>,
id: KeyPath<Data.Element, ID>,
#ViewBuilder rowContent: #escaping (Binding<Data.Element>) -> RowContent
) {
forEach = ForEach(
Array(zip(data.wrappedValue.indices, data.wrappedValue)),
id: (\.1 as KeyPath<(Data.Index, Data.Element), Data.Element>).appending(path: id)
) { i, _ in
rowContent(Binding(get: { data.wrappedValue[i] }, set: { data.wrappedValue[i] = $0 }))
}
}
init(_ data: Binding<Data>,
#ViewBuilder rowContent: #escaping (Binding<Data.Element>) -> RowContent
) where ID == Data.Element {
forEach = ForEach(
Array(zip(data.wrappedValue.indices, data.wrappedValue)),
id: \.1
) { i, _ in
rowContent(Binding(get: { data.wrappedValue[i] }, set: { data.wrappedValue[i] = $0 }))
}
}
var body: some View {
forEach
}
var data: [(Data.Index, Data.Element)] {
forEach.data
}
}

Does SwiftUI's ForEach cache #State variables of child views beyond their existence?

So here is a little piece of code that sums up a problem I cannot figure out atm.
In the code below I add and remove entries to a dictionary keyed by an Enum.
What I would expect is that every time I add an item a new random number is being generated in the Element view and displayed.
What happens is that for every same Ident the same random number shows up - event though the ForEach loop has had a state where that Ident key was not in the dictionary any more. It appears as if ForEach does not purge the #State vars of the Element views that are not present any more, but reuses them when a new entry to the dictionary is added with the same Ident.
Is this expected behavior? What am I doing wrong?
Here is the code:
import Foundation
import SwiftUI
enum Ident:Int, Comparable, CaseIterable {
case one=1, two, three, four
static func < (lhs: Ident, rhs: Ident) -> Bool {
lhs.rawValue < rhs.rawValue
}
}
extension Dictionary where Key == Ident,Value== String {
var asSortedArray:Array<(Ident,Value)> {
Array(self).sorted(by: { $0.key < $1.key })
}
var nextKey:Ident? {
if self.isEmpty {
return .one
}
else {
return Array(Set(Ident.allCases).subtracting(Set(self.keys))).sorted().first
}
}
}
struct ContentView: View {
#State var dictionary:[Ident:String] = [:]
var body: some View {
Form {
Section {
ForEach(dictionary.asSortedArray, id: \.0) { (ident, text) in
Element(dictionary: $dictionary, ident: ident, text: text)
}
}
Section {
Button(action: {
if let nextIdent = dictionary.nextKey {
dictionary[nextIdent] = "Something"
}
}, label: {
Text("Add one")
})
}
}
}
}
struct Element:View {
#Binding var dictionary:[Ident:String]
var ident:Ident
var text:String
#State var random:Int = Int.random(in: 0...1000)
var body: some View {
HStack {
Text(String(ident.rawValue))
Text(String(random))
Button(action: {
dictionary.removeValue(forKey: ident)
}, label: {
Text("Delete me.")
})
Spacer()
}
}
}

How to manipulate the ion-list item using an ActionSheet in SwiftUI?

I am trying to use an ActionSheet to manipulate items of a List. How can I call a function (in this example deleteItem) that is part of the data model, using an ActionSheet and manipulte the selected item, similar to what .onDelete does?
My view presents items from a model using the following code:
struct ItemManager: View {
#ObservedObject var model: ItemModel
var body: some View {
List {
ForEach(model.items) { item in
ItemCell(item: item)
}
.onDelete { self.model.deleteItem(at: $0) }
}
}
}
struct ItemCell: View {
var item: Item
#State private var isActionSheetVisible = false
private var actionSheet: ActionSheet {
let button1 = ActionSheet.Button.default(Text("Delete")){
self.isActionSheetVisible = false
}
let button2 = ActionSheet.Button.cancel(){
self.isActionSheetVisible = false
}
let buttons = [button1, button2]
return ActionSheet(title: Text("Actions"), buttons: buttons)
}
var body: some View {
VStack(alignment: .leading) {
Button(action: {
self.isActionSheetVisible = true
}) {
Text(item.title).font(.headline)
}.actionSheet(isPresented: self.$isActionSheetVisible) {
self.actionSheet
}
}
}
}
My model has some simple properties and a function that deletes items from the collection:
struct Item: Identifiable, Equatable {
let title: String
var id: String {
title
}
}
class ItemModel: ObservableObject {
#Published var items: [Item] = [Item(title: "temp.1"), Item(title: "temp.2")]
public func deleteItem(at indices: IndexSet) {
indices.forEach { items.remove(at: $0) }
}
}
extension Item {
static let previewItem = Item(title: "temp.3")
}
Update: Added Equatable in the Item declaration to comform.
You could try passing the ItemModel to the ForEach() like so:
ForEach(model.items) { item in
ItemCell(item: item, model: self.model)
}
Then in your ItemCell you can:
struct ItemCell: View {
var item: Item
var model: ItemModel // Add the model variable
#State private var isActionSheetVisible = false
private var actionSheet: ActionSheet {
let button1 = ActionSheet.Button.default(Text("Delete")) {
// Get the index
if let index = self.model.items.firstIndex(of: self.item) {
// Delete the item based on the index
self.model.items.remove(at: index)
// Dismiss the ActionSheet
self.isActionSheetVisible = false
} else {
print("Could not find item!")
print(self.item)
}
}
}
}