Expanding Animations in SwiftUI Lists
Ever tried to add a simple expand/collapse animation to a SwiftUI List and watched in horror as your beautiful animation turned into a janky mess? You’re not alone. I faced this issue a couple of months ago and thanks to Donny Wals, I decided to publish my findings.
Let me walk you through the journey of solving this problem, complete with all the failed attempts and eventual success.
The Goal
We want a simple expandable view: a tappable title that reveals additional content when clicked. For our example, let’s use these simple components:
struct TitleView: View {
var body: some View {
Text("Tap to expand/collapse")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.contentShape(Rectangle())
}
}
struct DetailView: View {
var body: some View {
Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.")
.font(.body)
.fixedSize(horizontal: false, vertical: true)
.padding()
}
}
Now let’s build a typical expandable view:
struct TypicalExpandableView: View {
@State private var isExpanded = false
var body: some View {
VStack(alignment: .leading, spacing: 8) {
TitleView()
.onTapGesture {
isExpanded.toggle()
}
if isExpanded {
Divider()
DetailView()
.transition(.opacity)
}
}
.background(AppColor.backgroundSecondary)
.cornerRadius(8)
.animation(.default, value: isExpanded)
}
}
Simple, right? And it works beautifully in a regular VStack or LazyVStack. The content smoothly animates in and out, everything looks great.
Let’s put our TypicalExpandableView into a List and see what happens.
Oof. That’s… not great. The content just jumps into existence. The List cell snaps to its new height without any smooth transition. It works perfectly in a VStack or LazyVStack, but in a List? Not so much.
Second Attempt: Tweaking the Animation
Maybe we just need to be more explicit with our animations? Let’s try wrapping the toggle in withAnimation and adding an .id() to help SwiftUI track the view:
struct TweakedExpandableView: View {
@State private var isExpanded = false
var body: some View {
VStack(alignment: .leading, spacing: 8) {
TitleView()
.onTapGesture {
withAnimation {
isExpanded.toggle()
}
}
if isExpanded {
Divider()
DetailView()
.transition(.opacity)
}
}
.background(AppColor.backgroundSecondary)
.cornerRadius(8)
.id("static")
}
}
Better! But still not smooth. The animation is less jarring, but we’re still getting that awkward jump. List cells just don’t like conditional content appearing and disappearing.
Third Attempt: The Built-In Solution
“Wait,” you might think, “doesn’t SwiftUI have a built-in component for this?”
Yes! Meet DisclosureGroup:
struct DisclosureExpandableView: View {
@State private var isExpanded = false
var body: some View {
DisclosureGroup(
isExpanded: $isExpanded,
content: {
DetailView()
},
label: {
TitleView()
}
)
.background(AppColor.backgroundSecondary)
.cornerRadius(8)
}
}
Finally, a smooth animation! DisclosureGroup works perfectly in Lists. So… problem solved?
Not quite. DisclosureGroup comes with some limitations, mainly the lack of customization options. If you’re OK with the default disclosure indicator animation and animation timing, like on your rarely visited settings screen, you’re fine. But for a custom-designed UI, we need more control.
Fourth Attempt: Animating cell height
After some experimentation, I found that using an Animatable view that can animate its height works nicely even in a List. Here is the proof-of-concept implementation:
struct AnimatableRedBox: View, Animatable {
var animatableData: CGFloat
var body: some View {
Color.red
.frame(height: animatableData)
}
}
struct AnimatableExpandableView: View {
@State private var isExpanded = false
var body: some View {
VStack(alignment: .leading, spacing: 8) {
TitleView()
.onTapGesture {
withAnimation {
isExpanded.toggle()
}
}
AnimatableRedBox(animatableData: isExpanded ? 100 : 0)
}
.background(AppColor.backgroundSecondary)
.cornerRadius(8)
.id("static")
}
}
Aha! The red box animates smoothly in the List. By implementing Animatable and animating a single CGFloat value, SwiftUI smoothly interpolates between states. The List knows exactly what height the cell should be at every frame of the animation.
The Final Solution
Now we just need to generalize this approach to work with any content - not just a fixed-height red box, but dynamic text and self-sizing views. Here is my implementation of a simple expandable component I’m using in my codebase. Having the detail view in an overlay ensures that the detail text doesn’t change during expansion/collapsing. It allows you to tweak the detail animation even more with offsets/scales/distortions or more ;)
struct ExpandableBaseView<Header: View, Content: View>: View, Animatable {
var animatableData: CGFloat
let header: Header
let content: Content
@State var baseFrame: CGRect = .zero
@State var expansionFrame: CGRect = .zero
init(animatableData: CGFloat,
@ViewBuilder header: () -> Header,
@ViewBuilder content: () -> Content) {
self.animatableData = animatableData
self.header = header()
self.content = content()
}
var expandedContent: some View {
content
.opacity(animatableData)
.getFrame($expansionFrame, space: .local)
.offset(y: baseFrame.height)
}
var body: some View {
ZStack(alignment: .bottom) {
header
.frame(maxWidth: .infinity)
.getFrame($baseFrame, space: .local)
.overlay(expandedContent, alignment: .top)
}
.frame(height: baseFrame.height + expansionFrame.height * animatableData,
alignment: .top)
}
}
struct WorkingExpandableView: View {
@State private var isExpanded = false
var body: some View {
ExpandableBaseView(
animatableData: isExpanded ? 1 : 0,
header: {
TitleView()
.onTapGesture {
withAnimation() {
isExpanded.toggle()
}
}
},
content: {
DetailView()
}
)
.background(AppColor.backgroundSecondary)
.cornerRadius(8)
}
}
Perfect! Smooth animation, works in Lists, and supports any content you throw at it.
How It Works
The magic happens by:
- Implementing
Animatable- Animates a single value from 0 to 1 when toggling isExpanded flag - Measuring content sizes - Uses SwiftUI’s
GeometryReaderto measure both header and expanded content - Calculating exact heights - Sets frame height to
headerHeight + contentHeight * progress - Positioning content - Uses overlays and offsets to position expanded content below the header
The content exists during the entire animation - it’s just scaled from 0 to full height smoothly.
The Frame Measurement Helper
The getFrame() helper is a custom view modifier that uses GeometryReader and preference keys to measure view dimensions without affecting layout. Here’s how it works:
struct FrameMeasurePreferenceKey: PreferenceKey {
typealias Value = [String: CGRect]
static var defaultValue: Value = [:]
static func reduce(value: inout Value, nextValue: () -> Value) {
value.merge(nextValue()) { $1 }
}
}
struct MeasureFrameViewModifier: ViewModifier {
@Binding var frame: CGRect
let space: CoordinateSpace
@State private var frameId = UUID()
func body(content: Content) -> some View {
content
.background(
GeometryReader { geometry in
Color.clear
.preference(
key: FrameMeasurePreferenceKey.self,
value: ["frame_\(frameId)": geometry.frame(in: space)]
)
}
)
.onPreferenceChange(FrameMeasurePreferenceKey.self) { preferences in
if let frame = preferences["frame_\(frameId)"] {
self.frame = frame
}
}
}
}
extension View {
func getFrame(_ frame: Binding<CGRect>, space: CoordinateSpace) -> some View {
modifier(MeasureFrameViewModifier(frame: frame, space: space))
}
}
This utility:
- Uses a hidden
GeometryReaderin the background to measure view size - Reports the measurement through SwiftUI’s preference system
- Doesn’t interfere with the view’s layout or appearance
- Updates automatically when the view’s size changes
With this helper, ExpandableBaseView can dynamically measure both the header and content sizes, then calculate the exact height needed at each point in the animation.
Wrapping Up
I hope you find this useful! Let me know if you run into any troubles, or even a happy smiley face in DMs is rewarding. Get in touch on Twitter!
Aaand Happy animating! 🎨