11

I want to implement a swipe-to-delete functionality on a SwiftUI Section by using the .onDelete modifier. The problem is that it always deletes the first item in the list.

My view has a list with dynamic sections created with a ForEach.

struct SetListView : View {

    var setlist: Setlist
    var body : some View {

        List { 
            ForEach(setlist.sets) { 
                 SetSection(number: $0.id, songs: $0.songs) 
            }
        }
        .listStyle(.grouped)
    }
}

In each section, there is another ForEach to create the dynamic rows:

private struct SetSection : View {

    var number: Int
    @State var songs: [Song]

    var body : some View {
        Section (header: Text("Set \(number)"), footer: Spacer()) {
            ForEach(songs) { song in
                SongRow(song: song)
            }
            .onDelete { index in
                self.songs.remove(at: index.first!)
            }
        }
    }
}

While debugging, I found out that the IndexSet is referring to the current section instead of the row. So when deleting items from the first section, always the first item gets deleted (as the index for the first section is 0).

Is this a bug in SwiftUI?

If not, then how could I get the index for the row?

KrohnicDev
  • 355
  • 2
  • 12
  • This is definitely a problem with the structure of your data. Make sure that you update sets when you remove a specific song – E-Riddie Jun 17 '19 at 09:32
  • 3
    I respectfully disagree. The problem is not happening after a song is removed, but before. I don’t think that my data structure has anything to do with the IndexSet passed to the closure. – KrohnicDev Jun 19 '19 at 20:16
  • 1
    I think it's a bug, or an unexpected behavior at least. If you have multiple ForEach () {}.onDelete () you get the position of the ForEach within all ForEach defined – jla Jul 07 '19 at 18:33

3 Answers3

12

In simple terms, a solution to this problem is to pass the section to your deletion method by:

  1. Adopting RandomAccessCollection on your source data.
  2. Binding the section in your outer ForEach and then using it in your inner ForEach, passing it to your deletion method:
List {
  ForEach(someGroups.indices) { section in
    bind(self.someGroups[section]) { someGroup in
      Section(header: Text(someGroup.displayName)) {
        ForEach(someGroup.numbers) { number in
          Text("\(number)")
        }
        .onDelete { self.delete(at: $0, in: section) }
      }
    }
  }
}

func delete(at offsets: IndexSet, in section: Int) {
  print("\(section), \(offsets.first!)")
}

A complete, contrived working example

(Also available in Gist form for convenience):

import SwiftUI

func bind<Value, Answer>(_ value: Value, to answer: (Value) -> Answer) -> Answer { answer(value) }

struct Example: View {

  struct SomeGroup: Identifiable, RandomAccessCollection {
    typealias Indices = CountableRange<Int>
    public typealias Index = Int;
    var id: Int
    var displayName: String
    var numbers: [Int]

    public var endIndex: Index {
      return numbers.count - 1
    }

    public var startIndex: Index {
      return 0
    }

    public subscript(position: Int) -> Int {
      get { return numbers[position] }
      set { numbers[position] = newValue }
    }

  }

  var someGroups: [SomeGroup] = {
    return [
      SomeGroup(id: 0, displayName: "First", numbers: [1, 2, 3, 4]),
      SomeGroup(id: 1, displayName: "Second", numbers: [1, 3, 5, 7])
    ]
  }()

  var body: some View {
    List {
      ForEach(someGroups.indices) { section in
        bind(self.someGroups[section]) { someGroup in
          Section(header: Text(someGroup.displayName)) {
            ForEach(someGroup.numbers) { number in
              Text("\(number)")
            }
            .onDelete { self.delete(at: $0, in: section) }
          }
        }
      }
    }
    .listStyle(.grouped)
  }

  func delete(at offsets: IndexSet, in section: Int) {
    print("\(section), \(offsets.first!)")
  }

}

Many thanks to @rob-mayoff who pointed me in the right direction for this solution via Twitter!

garrettmurray
  • 3,338
  • 1
  • 25
  • 23
  • Thanks for the insight on how to pass the specific section to the delete method. My sections are already an array of NSFetchedResultsSectionInfo's from a NSFetchedResultsController using a sectionNameKeyPath. Very elegant. – Chuck H Aug 20 '19 at 23:07
10

I had exactly the same problem. Turns out, SwiftUI's (current?) implementation does not recognize nested lists. This means that each SetSection in your List is interpreted as a single row even though you have a ForEach in it with the actual SongRows. Hence, it the IndexSet (index.first!) always returns zero.

What I've also noticed is that even with a flat hierarchy such as..

List {
   Section {
       ForEach(...) {
          ...
       }
   }
   Section {
       ForEach(...) {
          ...
       }
   }
}

..individual rows cannot be moved between sections. This is also true when directly using two ForEach, i.e. without the Section wrappers.

We should probably file a report for each phenomenon.

Nik Sauer
  • 125
  • 1
  • 6
  • Based on testing on my end, I would agree this is a bug. Sadly, it was not fixed in beta 3. – garrettmurray Jul 15 '19 at 01:03
  • This wasn't fixed in beta 4 either, which makes me feel like I'm missing something. I saw some [references to `remove(atOffsets:)` being added in beta 4](https://developer.apple.com/documentation/swift/rangereplaceablecollection/3349502-remove), but I don't see how that resolves the issue considering the IndexSet still has the wrong values. – garrettmurray Jul 18 '19 at 02:55
  • 1
    Thankfully, I've found a working solution (posted as a new answer). – garrettmurray Jul 18 '19 at 21:16
  • i have the same problem anyone find a solution? – Misael Landeros Mar 05 '20 at 22:45
  • @garrettmurray Your solution works well for deleting, but I also want to be able to move rows between the different sections. I'm curious if anyone has a solution for that, but I'm thinking it's a limitation of SwiftUI right now. – Jeremy May 05 '20 at 20:03
  • Sadly no, last I checked it was still a limitation. – garrettmurray May 06 '20 at 21:05
3

It seems to work fine on Xcode 12.5

I'm using it like this:

struct Sections: View {
    var items: [SomeData]
    private var sections: [Date: [SomeData]] {
        Dictionary(grouping: items, by: { $0.date })
    }
    private var headers: [Date] {
        sections.map({ $0.key }).sorted().reversed()
    }

    var body: some View {
        List {
            ForEach(headers, id: \.self) { date in
                Section(header: Text(date.friendly) {
                    AList(items: sections[date]!)
                }
            }
        }
    }
}

struct AList: View {
    var items: [SomeData]
    var body: some View {
        ForEach(items) { data in
            ...
        }
        .onDelete(perform: delete)
    }

    private func delete(at offsets: IndexSet) {
        // You can use `items.remove(atOffsets: offsets)`
        for offset in offsets {
            let data = items[offset]
            print("\(data)") 
    // You can check here that this is the item that you want to remove and then you need to remove it from your data source. 
    // I'm using Realm and @Published vars that works fine, you should adapt to your logic.
        }
    }
}
Kitos
  • 31
  • 1