I've created this expanding view and it has child views in it that animated in an unexpected way.
In the animation below...
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.
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:
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:
I unconditionally include the speaker rows.
I wrap the speaker rows in their own VStack
. That VStack
has a frame
modifier with height zero if the session is not expanded.
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"),
]
)
])
}