Broken animation when using custom DisclosureGroup in a SwiftUI List

58 Views Asked by At

Due to constraints of using the default DisclosureGroup in a list, I'm trying to make a custom implementation, but I'm running into an issue with the expansion animation, which only occurs when the custom component is in a List. Here is the code:

struct CustomDisclosureGroup<Label: View, Content: View>: View {
    @State private var isExpanded: Bool = false

    private let label: Label
    private let content: Content

    init(@ViewBuilder label: () -> Label, @ViewBuilder content: () -> Content) {
        self.label = label()
        self.content = content()
    }

    var body: some View {
        VStack(spacing: 0) {
            Button {
                withAnimation {
                    isExpanded.toggle()
                }
            } label: {
                HStack {
                    label
                    Spacer()
                }
            }

            if isExpanded {
                content
            }
        }
    }
}

However, when I put it in a List, as so:

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

    var body: some View {
        List {
            ForEach(items, id: \.self) { item in
                CustomDisclosureGroup {
                    Text(item.formatted())
                } content: {
                    Text("Test text")
                    Text("Test text")
                    Text("Test text")
                }
            }
        }
    }
}

The animation breaks. Compare it to the default disclosure group animation:

With Custom Component Default Disclosure Group
First Image Second Image

Assume that I cannot use DisclosureGroup (and need to use this custom component) and that I must use List. How can I fix this broken animation?

1

There are 1 best solutions below

0
Benzy Neez On

Animations inside a List are very restricted and difficult to control, as you have discovered. The same goes for animations inside a Form.

Here is an updated version of your CustomDisclosureGroup that performs slightly better than what you originaly had, but not by much.

  • The content is shown in an overlay, which allows a bit of animation.
  • The flag is not toggled withAnimation, because this was causing some uncontrollable movement (including movement of the label). Instead, an .animation modifier is applied when revealing the content.
  • The list insets are set explicitly, to prevent a small amount of label movement when the flag is toggled. This is probably being caused by the difference between the minimum row height (as defined by defaultMinListRowHeight) and the height of the label by itself when the view is collapsed.
  • Unfortunately, I couldn't find a way to have it expand and collapse gradually, so it happens in jumps.
struct CustomDisclosureGroup<Label: View, Content: View>: View {
    @State private var isExpanded = false
    @State private var isContentVisible = false

    private let label: Label
    private let content: Content

    init(@ViewBuilder label: () -> Label, @ViewBuilder content: () -> Content) {
        self.label = label()
        self.content = content()
    }

    var body: some View {
        VStack(spacing: 0) {
            Button {
                if isExpanded {
                    withAnimation {
                        isContentVisible = false
                    } completion: {
                        isExpanded = false
                    }
                } else {
                    isExpanded = true
                }
            } label: {
                HStack {
                    label
                    Spacer()
                }
            }
            if isExpanded {
                VStack(spacing: 0) {
                    content
                }
                .hidden()
                .overlay(alignment: .top) {
                    VStack(spacing: 0) {
                        content
                    }
                    .opacity(isContentVisible ? 1 : 0)
                    .fixedSize(horizontal: false, vertical: true)
                    .frame(height: isContentVisible ? nil : 0, alignment: .top)
                    .clipped()
                    .animation(.easeInOut, value: isContentVisible)
                }
                .onAppear { isContentVisible = true }
                .onDisappear { isContentVisible = false }
            }
        }
        .listRowInsets(EdgeInsets(top: 12, leading: 20, bottom: 12, trailing: 20))
    }
}

Animation

If you want to have more control over the animation, you might be better off building a custom List instead. This would probably be easier than finding workarounds for all the limitations of a native List.