SwiftUI Lists treat DisclosureGroup content as list items

74 Views Asked by At

I'm working with Lists in SwiftUI, and the interface seems pretty intuitive. However, if I want more complex views in a list, and I decide to use DisclosureGroup, then the List treats the expanded content of the disclosure group as its own list cell. When implementing onMove and onDelete, this causes undefined behavior.

Consider the following view:

struct ContentView: View {
    @State var items: [Int] = [0, 1, 2, 3]

    var body: some View {
        List {
            ForEach($items, id: \.self) { $item in
                DisclosureGroupView()
            }
            .onDelete { indexSet in
                items.remove(atOffsets: indexSet)
            }
            .onMove { indices, newOffset in
                items.move(fromOffsets: indices, toOffset: newOffset)
            }
        }
    }
}

struct DisclosureGroupView: View {
    var body: some View {
        DisclosureGroup {
            VStack(spacing: 8) {
                Text("Test text")
                Text("Test text")
                Text("Test text")
            }
        } label: {
            HStack {
                Text("Tap to Expand")
                Spacer()
                Image(systemName: "ellipsis")
            }
        }
    }
}

this leads to the following behavior when deleting/reordering:

Demo

You can see that I can attempt to individually delete/reorder only the expanded content of the disclosure group, which in my context does not make sense and leads to undefined behavior; the entirety of the group should be treated as a single element.

Assuming that DisclosureGroup is the only option I have for the complex view I wish to implement, how can I make it so that the entire group is treated as a cell, rather than the label and expanded content being treated as different cells?

2

There are 2 best solutions below

9
J W On

The final proposal:

When using a custom view, use DisclosureGroup and replace List with SrcollView

Attention: The interface is not perfect yet. If you need to improve it, you can refer to it.

https://www.youtube.com/watch?v=K8VnH2eEnK4

I didn’t watch the video carefully, but the effect looks good. The source code costs 3 dollars.(o_o)

custom view

import SwiftUI

struct ContentView: View {
  @State var items: [Item] = (0...5).map { Item(number: $0) }
  
  var body: some View {
    ScrollView {
      VStack(spacing: 0) {
        Divider()
        ForEach(0 ..< items.count, id: \.self) { i in
          ZStack(alignment: .trailing) {
            CollapsibleView(item: $items[i])
              .padding()
              .background(Color.white)
              .offset(x: items[i].offset)
              .gesture(DragGesture()
                .onChanged({ gesture in
                  if gesture.translation.width < 0 {
                    // Swipe left to show delete button
                    items[i].offset = gesture.translation.width
                  }
                })
                  .onEnded({ _ in
                    if items[i].offset < -100 {
                      // Swipe far enough, delete the items[i]
                      items[i].offset = -1000
                      withAnimation {
                        delete(item: items[i])
                      }
                    } else {
                      // Not far enough, reset
                      items[i].offset = 0
                    }
                  })
              )
            
            if items[i].offset <= -100 {
              Button(action: {
                withAnimation {
                  delete(item: items[i])
                }
              }) {
                Image(systemName: "trash.fill")
                  .foregroundColor(.white)
                  .padding()
                  .background(Color.red)
                  .cornerRadius(8)
              }
              .offset(x: -100) // Adjust the position as needed
            }
          }
          Divider()
        }
      }
    }
  }
  
  func delete(item: Item) {
    items.removeAll { $0.id == item.id }
  }
}

struct CollapsibleView: View {
  @Binding var item: Item
  
  var body: some View {
    DisclosureGroup(
      isExpanded: $item.isExpanded,
      content: {
        VStack(spacing: 8) {
          Text("Detail for item \(item.number)")
          Text("Another detail")
        }
        .foregroundColor(.black) // Set the color here
      },
      label: {
        HStack {
          Text("Item \(item.number)").foregroundColor(.black) // Set the color here
          Spacer()
          Image(systemName: "ellipsis").foregroundColor(.black) // Set the color here
        }
      }
    )
    .animation(.easeInOut, value: item.isExpanded)
    .transition(.opacity)
  }
}

struct Item: Identifiable {
  let id = UUID()
  var number: Int
  var isExpanded: Bool = false
  var offset: CGFloat = 0
}

#Preview {
  ContentView()
}
1
Igoretto On

You have treat items in DisclosureGroupView like a List then. onDelete method should be there too.

struct ContentView: View {
    @State var items: [Int] = [0, 1, 2, 3]

    var body: some View {
        List {
            ForEach($items, id: \.self) { $item in
                DisclosureGroupView()
            }

        }
    }
}

struct DisclosureGroupView: View {
    var body: some View {
        DisclosureGroup {
            // List here
                ForEach... { $item in
                    .padding(.vertical, 8)
                }
            .onDelete { indexSet in
                ....
            }
            .onMove { indices, newOffset in
                ...
            }
        } label: {
            HStack {
                Text("Tap to Expand")
                Spacer()
                Image(systemName: "ellipsis")
            }
        }
    }
}