animationswiftuiios-animations

Control the frame size's animation to create a collapsable that always expands from top to bottom view in SwiftUI


I'm trying to replicate an accordion component that should work like the one in the Custom Control Label from this documentation. To do this I created a Collapsable view as follows:

struct Collapsable<Content>: View where Content: View {
    internal var content: () -> Content
    @Binding private var isCollapsed: Bool
    @State private var isCollapsedForAnimation: Bool 
    
    init(isCollapsed: Binding<Bool>, @ViewBuilder content: @escaping () -> Content) {
        self._isCollapsed = isCollapsed
        self.content = content
        self.isCollapsedForAnimation = isCollapsed.wrappedValue
    }

    var body: some View {
        content()
            .background(.ultraThinMaterial)
            .frame(maxHeight: isCollapsedForAnimation ? 0 : nil, alignment: .top)
            .contentShape(Rectangle())
            .clipped()
            .onChange(of: self.isCollapsed) { isCollapsed in
                withAnimation {
                    self.isCollapsedForAnimation = isCollapsed
                }    
            }
        }
}

And it works as in it allows me to show and hide the content of the content parameter, but the animation is terribly inconsistent, since they way it expands/contracts depends on the container. I would like the view to always expand and contract from bottom to top, or even better, allow the user to specify whether he wants it to animate from top to bottom or the other way around.

Unfortunately I couldn't find a way to get this behavior, so I'm looking for hints in the right direction.


Solution

  • The first thing to understand is that you must test on a real device, not on the simulator. The iOS simulator often has display bugs that real devices don't.

    The second thing to understand is that SwiftUI animations can be flaky and difficult even on devices. 🤷🏼

    Anyway, let's reproduce the accordion component you linked to, in SwiftUI. Here's my result:

    An animated GIF showing a stack of three name cards. The top card is for Bender. The middle card is for Mom. The bottom card is for Homer. I tap each card in turn to expand it so that it shows more info. When a card expands, any other expanded card collapses at the same time.

    I recorded this on my iPhone 12 Mini running iOS 16.4. On the device it looks much smoother, but I generated the animated GIF at 15 fps to get it down to an almost reasonable file size.

    Anyway, here's my implementation of CustomControlLabel:

    struct CustomControlLabel<Header: View, Content: View>: View {
        @Binding
        var isExpanded: Bool
        let header: Header
        let content: Content
    
        init(
            isExpanded: Binding<Bool>,
            @ViewBuilder header: () -> Header,
            @ViewBuilder content: () -> Content
        ) {
            _isExpanded = isExpanded
            self.header = header()
            self.content = content()
        }
    
        var body: some View {
            VStack(spacing: 0) {
                HStack {
                    header
                    Spacer()
                    Image(systemName: "chevron.down")
                        .font(.caption2)
                        .rotationEffect(.degrees(isExpanded ? 180 : 0))
                }
                .contentShape(Rectangle())
                .onTapGesture { isExpanded.toggle() }
                content
                    .opacity(isExpanded ? 1 : 0)
                    .fixedSize(horizontal: false, vertical: true)
                    .padding(.top, 16)
                    .frame(height: isExpanded ? nil : 0, alignment: .top)
                    .clipped()
            }
            .padding()
            .padding(1)
            .border(Color.gray, width: 1)
            .background {
                if isExpanded {
                    Color.black.opacity(0.05)
                }
            }
            .padding(-0.5)
        }
    }
    

    Here are two key differences between my code and yours:

    1. I use the fixedSize(horizontal: false, vertical: true) modifier on the collapsing content, inside the frame modifier that sets its height to zero when it's collapsed. This fixedSize modifier hides the zero height from the content, so it doesn't try to change its own layout to fit in a zero height.

    2. I don't perform the animation at this level. Expanding one CustomControlLabel should simultaneously collapse any other expanded CustomControlLabel. To get the overall layout to animate correctly, I apply an animation modifier to the container that contains all of the CustomControlLabels.

    It's also important that the VStack in there has spacing: 0. The default is that SwiftUI picks a spacing it thinks is appropriate, but that spacing changes when the content is entirely collapsed! So without the spacing: 0, the collapsible content moves slightly as its visibility changes. To prevent that, I set spacing: 0 and added padding(.top, 16) to the collapsible content.

    Here is the rest of my code:

    struct ToonHeader: View {
        let pic: UIImage
        let name: String
        let blurb: String
    
        var body: some View {
            HStack {
                Image(uiImage: pic)
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .frame(width: 64, height: 64)
                VStack(alignment: .leading) {
                    Text(name)
                    Text(blurb)
                        .font(.caption)
                        .foregroundColor(.gray)
                }
            }
        }
    }
    
    enum Selection: Equatable {
        case bender
        case mom
        case homer
    }
    
    struct CustomControlLabelTestView: View {
        @State
        var selection: Selection? = nil
    
        func binding(for target: Selection) -> Binding<Bool> {
            return .init(
                get: { selection == target },
                set: {
                    if $0 {
                        selection = target
                    } else if selection == target {
                        selection = nil
                    }
                }
            )
        }
    
        var body: some View {
            VStack(spacing: 0) {
                CustomControlLabel(isExpanded: binding(for: .bender)) {
                    ToonHeader(
                        pic: #imageLiteral(resourceName: "futurama-bender.png"),
                        name: "Bender Bending Rodríguez",
                        blurb: "Fascinated with cooking, though has no sense of taste"
                    )
                } content: {
                    Text("Bender Bending Rodríguez (born September 4, 2996), designated Bending Unit 22, and commonly known as Bender, is a bending unit created by a division of MomCorp in Tijuana, Mexico, and his serial number is 2716057. His mugshot id number is 01473. He is Fry's best friend.")
                        .font(.caption)
                }
    
                CustomControlLabel(isExpanded: binding(for: .mom)) {
                    ToonHeader(
                        pic: #imageLiteral(resourceName: "futurama-mom.png"),
                        name: "Carol Miller",
                        blurb: "One of the richest people on Earth"
                    )
                } content: {
                    Text("Carol Miller (born January 30, 2880), better known as Mom, is the evil chief executive officer and shareholder of 99.7% of Momcorp, one of the largest industrial conglomerates in the universe and the source of most of Earth's robots. She is also one of the main antagonists of the Futurama series.")
                        .font(.caption)
                }
    
                CustomControlLabel(isExpanded: binding(for: .homer)) {
                    ToonHeader(
                        pic: #imageLiteral(resourceName: "homer-simpson.png"),
                        name: "Homer Simpson",
                        blurb: "Overweight, lazy, and often ignorant"
                    )
                } content: {
                    Text("Homer Jay Simpson (born May 12) is the main protagonist and one of the five main characters of The Simpsons series(or show). He is the spouse of Marge Simpson and father of Bart, Lisa and Maggie Simpson.")
                        .font(.caption)
                }
    
                Spacer()
            }
            .animation(.easeInOut(duration: 1/6), value: selection)
            .padding()
        }
    }
    

    You'll need to scroll down but at the bottom you'll find the single animation modifier that animates everything.