swiftuiswiftui-transition

SwiftUI animate the appearance of sub views when expanding parent


I've created this expanding view and it has child views in it that animated in an unexpected way.

In the animation below...

enter image description here

In the "Atlas" section...

The names and image circles stay in place as the parent collapses. The title text moves down but the names and avatars fade in while remaining in place. (The names and Title text move relative to each other)

In the "Luna" section...

The names and avatars are hidden by the view collapsing. (The names and Title text are always in the same relative place)

Ideally I'd like them to to act like the "Luna" section for all views.

My view body is like this at the moment...

VStack(alignment: .leading, spacing: 16) {
    HStack {
        VStack(alignment: .leading, spacing: 8) {
            (
                Text(session.startDate, style: .time)
                + Text(" - ")
                + Text(session.endDate, style: .time)
            )
            .font(.caption1)

            Text(session.title)
                .font(.headline6)
        }
        Spacer()
        if session.canExpand {
            if expanded {
                Image(systemName: "chevron.up")
            } else {
                Image(systemName: "chevron.down")
            }
        }
    }

    if expanded {
        ForEach(session.speakers) { speaker in
            HStack {
                Avatar(name: speaker.name, size: 24, imagePath: speaker.image)
                    Text("Test name \(Int.random(in: 1...30))")
                    .font(.body1)
            }
        }
    }
}

I've tried changing the transition of the Avatar and name section but that didn't seem to have an effect.


Solution

  • The best way that I'm aware of to fix this problem is to get rid of if expanded and unconditionally include the speaker rows, but put the speaker rows in a container with a frame height of 0 when the session is collapsed. Add a clipping modifier to the outer session view (the view that draws the rounded rect and shadow) to hide the speaker rows when the card is collapsed. Here's the result:

    demo of sessions expanding and collapsing properly

    Here's my SessionView code:

    struct SessionView: View {
        var session: Session
        @Binding var expanded: Bool
    
        var body: some View {
            VStack(alignment: .leading, spacing: 0) {
                VStack(alignment: .leading, spacing: 16) {
                    HStack {
                        VStack(alignment: .leading, spacing: 8) {
                            (
                                Text(session.startDate, style: .time)
                                + Text(" - ")
                                + Text(session.endDate, style: .time)
                            )
                            .font(.caption)
    
                            Text(session.title)
                                .font(.headline)
                        }
                        Spacer()
                        if session.canExpand {
                            Image(systemName: "chevron.down")
                                .rotationEffect(.degrees(expanded ? 180 : 360))
                        }
                    }
                }
    
                VStack(alignment: .leading, spacing: 16) {
                    Spacer().frame(height: 0)
    
                    ForEach(session.speakers) { speaker in
                        SpeakerRow(speaker: speaker)
                    }
                }
                .frame(height: expanded ? nil : 0, alignment: .top)
            }
            .padding()
            .contentShape(shape)
            .clipShape(shape)
            .onTapGesture {
                if session.canExpand {
                    expanded.toggle()
                }
            }
            .background {
                shape
                    .fill(.white)
                    .padding(3)
                    .shadow(radius: 2, x: 0, y: 1)
            }
        }
    
        private var shape: some Shape {
            RoundedRectangle(cornerRadius: 10, style: .continuous)
        }
    }
    

    These are the main things to note in my code:

    1. I unconditionally include the speaker rows.

    2. I wrap the speaker rows in their own VStack. That VStack has a frame modifier with height zero if the session is not expanded.

    3. I apply a clipShape modifier to the outer VStack so that the speaker rows are clipped when the session is collapsed.

    Here's the rest of the code, for experimentation:

    struct Speaker: Identifiable {
        var name: String
        var image: String
    
        var id: String { name }
    }
    
    struct Session: Identifiable {
        var startDate: Date
        var endDate: Date
        var title: String
        var speakers: [Speaker]
    
        var canExpand: Bool { !speakers.isEmpty }
    
        var id: String { title }
    }
    
    struct Avatar: View {
        var name: String
        var size: CGFloat
        var imagePath: String
    
        var body: some View {
            Image(systemName: "person.circle.fill")
                .resizable()
                .frame(width: size, height: size)
        }
    }
    
    struct SpeakerRow: View {
        var speaker: Speaker
    
        var body: some View {
            HStack {
                Avatar(name: speaker.name, size: 24, imagePath: speaker.image)
                Text(speaker.name)
            }
        }
    }
    
    struct AgendaView: View {
        var sessions: [Session]
        @State var expandedSessionId: String? = nil
    
        var body: some View {
            ScrollView {
                VStack {
                    ForEach(sessions) { session in
                        SessionView(
                            session: session,
                            expanded: .init(
                                get: { expandedSessionId == session.id },
                                set: { expand in
                                    if expand {
                                        expandedSessionId = session.id
                                    } else if expandedSessionId == session.id {
                                        expandedSessionId = nil
                                    }
                                }
                            )
                        )
                    }
                }
                .animation(.easeInOut(duration: 1), value: expandedSessionId)
            }
            .padding()
        }
    }
    
    #Preview {
        AgendaView(sessions: [
            .init(
                startDate: .init(timeIntervalSinceReferenceDate: 9000),
                endDate: .init(timeIntervalSinceReferenceDate: 9900),
                title: "Keynote",
                speakers: [
                        .init(name: "Tim Cook", image: "tim.jpg"),
                        .init(name: "Johnny Appleseed", image: "apple.jpg"),
                    ]
            ),
            .init(
                startDate: .init(timeIntervalSince1970: 10000),
                endDate: .init(timeIntervalSince1970: 10900),
                title: "Gettysburg Address",
                speakers: [
                    .init(name: "Abraham Lincoln", image: "abe.jpg"),
                    .init(name: "Abe's Beard", image: "beard.jpg"),
                ]
            ),
            .init(
                startDate: .init(timeIntervalSince1970: 11000),
                endDate: .init(timeIntervalSince1970: 11900),
                title: "Ted Talk",
                speakers: [
                    .init(name: "Ted Lasso", image: "lasso.jpg"),
                    .init(name: "Ted ‘Theodore’ Logan", image: "ted.jpg"),
                ]
            )
        ])
    }