SwiftUI animate the appearance of sub views when expanding parent

317 Views Asked by At

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.

1

There are 1 best solutions below

1
On BEST ANSWER

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"),
            ]
        )
    ])
}