1

I am building an app where summary information and historical detail relating to a range of parameters is displayed in a List.

The user can view historical metrics by clicking on a disclosure button and DisclosureGroup handles this very neatly but I would like to add a button which toggles display of a control which enables the user to enter new data without using a modal display or navigating away from the summary screen.

Although you can nest DisclosureGroups this wouldn't be good UX IMO (user would need to disclose twice potentially to access the information they wanted) so I wanted to replicate DisclosureGroup animation behaviour by conditionally adding a view to the hierarchy.

Outside Lists or Forms, this is easy enough to do - ensure the action to toggle your flag for display is wrapped in withAnimation and SwiftUI takes care of the rest. Unfortunately the way these container views are laid out causes some rather odd behaviour which results pre-existing views (other than Text views) "bouncing" when a new view is added to a cell. This doesn't happen with DisclosureGroup (regardless of whether you are using Text or another View subtype) which is completely unaffected when added to a List or Form.

Slowing the animation down in the simulator makes it apparent that the parent view (i.e. the cell) changes its size before the new child is added but this results in the position of the existing view to change as well (attempting to anchor it to the top of the cell view by using alignment modifiers on an enclosing HStack or directly on the frame of the view doesn't stop this happening).

In addition, when the view is removed from the cell's hierarchy, the cell shrinks before the view is removed leaving the departing view hovering over the adjacent cell(s) for a short time which looks messy. Needless to say, this doesn't happen with DisclosureGroup so I think I can reasonably assume that what I'm after is totally possible.

I've tried (and failed) to sort the issue out by throwing alignments, HStack and VStack containers and .matchedGeometryEffect modifiers to each of the views (the latter seemed the most promising as this seems to be ultimately poor coordination of the view transitions). This either didn't work or caused some very strange behaviours.

Slowing down the animation makes it apparent that DisclosureGroup might be using a ZStack and/or a custom Transition which is offsetting child views in a progressive manner. However, my attempts to replicate this have been...interesting.

As a picture is worth a thousand words (so a video is worth 10,000?) see the attached for illustrations of the behaviour along with the relevant code.

Simulator recording

Using Text Views:

struct DisclosureWithTextView: View {
@Namespace private var nspace

@State private var willDisplayControl = false

var body: some View {
        DisclosureGroup(
            content: {
                ForEach(0..<2) { index in
                    Text("Item")
                        .padding(6)
                        .background {
                            Rectangle()
                                .foregroundColor(.yellow)
                        }
                }
            },
            label: {
                VStack {
                    HStack {
                        Button(action: {
                            willDisplayControl.toggle()
                        })
                        {
                            Image(systemName: "plus.circle")
                        }
                        .buttonStyle(PlainButtonStyle())

                        Text("SUMMARY")
                            .padding(8)
                            .background {
                                Rectangle()
                                    .foregroundColor(.red)
                            }
                    }

                    if willDisplayControl {
                        Text("CONTROL")
                            .padding(8)
                            .background {
                                Rectangle()
                                    .foregroundColor(.green)
                            }
                    }
                }

            })

}

}

Using Rectangle Views:

struct DisclosureWithGraphics: View {
@Namespace private var nspace

@State private var willDisplayControl = false

var body: some View {
    DisclosureGroup(
        content: {
            ForEach(0..<2) { index in
                Rectangle()
                    .frame(width:80, height: 30)
                    .foregroundColor(.yellow)
            }
        },
        label: {
            VStack {
                HStack {
                    Button(action: {
                        withAnimation { willDisplayControl.toggle() }
                    })
                    {
                        Image(systemName: "plus.circle")
                    }
                    .buttonStyle(PlainButtonStyle())

                    Rectangle()
                        .frame(width: 110, height: 40)
                        .foregroundColor(.red)

                }

                if willDisplayControl {
                    Rectangle()
                        .frame(width: 100, height: 40)
                        .foregroundColor(.green)
                }
            }

        })
}

}

rustproofFish
  • 931
  • 10
  • 32

1 Answers1

2

Ironically I realise that Asperi has referred me to his solution for an almost identical issue I raised 18mth ago! Now feeling a little foolish but it explains the vague feeling I had that I'd come across this problem before!

However, I have discovered that there is an important difference. If you're working with a 'vanilla' stack container view inside a List or Form, adjusting alignment is all you need to do to achieve a smooth animation of one view being disclosed by another. Animating the frame of the container view works absolutely fine but it's not necessary. However, as soon as you try the same thing inside a DisclosureGroup, the views that form your label start jumping around regardless of whether you animate alignment guides, frames, etc, etc and you have to animate the parent view height explicitly.

So, slide out views within a simple List or Form cell are easy (using AlignmentGuide) but you need to add in AnimatableValue for the cell height when you try the same thing in a more specialised view container. If I was to guess I suspect this is because there is some conflict with Apple's own disclosure implementation.

So, thanks to Asperi for reminding me how to do this :-)

Code examples of two ways to achieve the effect below (I prefer the appearance of the alignment guide animation personally)

Adjusting the frame of the view to be disclosed:

struct DisclosureWithFrameAnimationReveal: View {
@Namespace private var nspace
@State private var willDisplayControl = false

var body: some View {

        DisclosureGroup(
            content: {
                ForEach(0..<2) { index in
                    Text("Item")
                        .padding(6)
                        .background {
                            Rectangle()
                                .foregroundColor(.yellow)
                        }
                }
            },
            label: {
                VStack {
                    HStack {
                        Button(action: {
                            withAnimation(.easeInOut) { willDisplayControl.toggle() }
                        })
                        {
                            Image(systemName: "plus.circle")
                        }
                        .buttonStyle(PlainButtonStyle())

                        Color(.red)
                            .frame(width: 100, height: 40)
                        Spacer()
                    }
                    .padding(.top, 4)
                    .background()

                    HStack {
                        Color(.blue)
                            .frame(width: 100, height: willDisplayControl ? 40 : 0)
                        Spacer()
                    }
                    .opacity(willDisplayControl ? 1 : 0)
                }
                .modifier(AnimatableCellHeight(height: willDisplayControl ? 88 : 44))

            }
        )
}
}

Alignment guide animation:

struct DisclosureWithAlignmentReveal: View {
@Namespace private var nspace
@State private var willDisplayControl = false

var body: some View {
        DisclosureGroup(
            content: {
                ForEach(0..<2) { index in
                    Text("Item")
                        .padding(6)
                        .background {
                            Rectangle()
                                .foregroundColor(.yellow)
                        }
                }
            },
            label: {
                ZStack(alignment: .top) {
                    HStack {
                        Button(action: {
                            withAnimation(.easeInOut) { willDisplayControl.toggle() }
                        })
                        {
                            Image(systemName: "plus.circle")
                        }
                        .buttonStyle(PlainButtonStyle())

                        Color(.red)
                            .frame(width: 100, height: 40)
                        Spacer()
                    }
                    .zIndex(1)
                    .padding(.top, 4)
                    .background()

                    HStack {
                        Color(.blue)
                            .frame(width: 100, height: 40)
                        Spacer()
                    }
                    .alignmentGuide(.top, computeValue: { d in d[.top] - (willDisplayControl ? 46 : 0) })
                    .opacity(willDisplayControl ? 1 : 0)
                    .zIndex(0)
                }
                .modifier(AnimatableCellHeight(height: willDisplayControl ? 88 : 44))
            }
        )
}
}

And, finally, the AnimatableValue implementation:

struct AnimatableCellHeight: AnimatableModifier {
var height: CGFloat = 0

var animatableData: CGFloat {
    get { height }
    set { height = newValue }
}

func body(content: Content) -> some View {
    content.frame(height: height)
}
}

Final solution

rustproofFish
  • 931
  • 10
  • 32