IndexSet referring to index of the section instead of the row

Viewed 5210

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?

3 Answers

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.

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!

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.
        }
    }
}
Related