Taming Row Height and Spacing Jumps in SwiftUI List with a Custom Layout

The declarative expression of animation is one of SwiftUI’s core strengths. But in some scenarios the result isn’t always as smooth as we’d hope. A typical example: when the content height inside a List row changes dynamically — a subtitle going from empty to non-empty, or text changing its line count after an update — the system’s built-in layout engine often fails to produce a continuous transition. Instead we get visible height jumps, flicker, or even clipping anomalies. This article starts from that phenomenon, peels back the causes layer by layer, and gives a solution built entirely on SwiftUI’s native primitives. Along the way it revisits a few key constraints in SwiftUI’s layout machinery.

This article doesn’t walk through every line of the implementation; it focuses on the thinking behind it. The complete code is available here.

The Animation Glitch in List Rows

List is one of SwiftUI’s most-used containers. But the moment we try to change the height of content inside a row, we quickly run into a familiar problem: we’ve configured an animation, yet the row height still snaps from one value to another.

For example, a row contains an optional description. It might appear because of an iCloud sync, or it might get updated when the user submits a form in a sheet. The natural thing to write is something like this:

Swift
VStack(alignment: .leading) {
    Text(note.name)

    if !note.description.isEmpty {
        Text(note.description)
            .font(.caption)
            .transition(.opacity)
    }
}
.animation(.smooth, value: note.description)

Inside a VStack or LazyVStack, this generally works fine. But inside a List, the result is often less ideal: the text itself fades in and out, while the cell height still switches abruptly.

As the video above shows, the glitch isn’t limited to insertion/removal — the same kind of issue appears when the row’s height changes because the content’s own length changed.

Why Height Changes in List Rows Are Especially Prone to Jumps

List is not just a fancy VStack. Its implementation is still tightly coupled to the host platform’s scrolling list machinery. To preserve scrolling performance and view reuse, the list container has to compute the size of each row at the right moment. When we use an if inside a row to insert or remove a chunk of content, the row’s size doesn’t change continuously — it switches from one layout result to another.

transition only describes how a child view itself enters or leaves: fade, move, scale. It does not guarantee that the parent List will interpolate frame by frame between the old and the new height.

That leads to a common illusion: we think we’re animating “the row’s change,” when in fact we’re only animating one of the child views inside the row.

The core problem can be reduced to one sentence: List does not automatically interpolate height changes for a dynamically-sized row.

Since List won’t do the interpolation for us, the direction is clear: take over height interpolation manually, and make height an explicit animation state.

SwiftUI itself actually offers a few tools that point this way: the Animatable protocol, and geometryGroup. Both can lift or explicitize the animation interpolation step.

For the mechanics of these two approaches, see Animatable Protocol: Taming Unruly SwiftUI Animations and SwiftUI geometryGroup() Guide: From Theory to Practice.

The Difficulties We Face

The plan sounds clean. But as soon as we sit down to implement it, several hurdles show up first.

The Tight Coupling Between Data and View Lifecycles

In a traditional imperative framework like UIKit, the developer plays the role of “Layout Scheduler”, with precise control over the order of events:

  1. Data changes: trigger the logic.
  2. Pre-measurement: before the current RunLoop’s layout pass, measure the new content’s height ahead of time (for example via systemLayoutSizeFitting).
  3. Batch update: call UITableView.performBatchUpdates, explicitly telling the system, “I’ve measured; please animate to this new set of heights.”

This “measure first, commit later” buffer keeps the animation entirely under the developer’s control.

In SwiftUI’s reactive system, that buffer is gone:

  • State change is a first-class citizen. The moment data changes, SwiftUI’s data flow propagates immediately and unconditionally through the render tree and triggers a re-layout.
  • List asks the underlying machinery for fresh cell sizes the instant a state change arrives, and rearranges hard.
  • The developer simply has no chance to slip a measurement-plus-animation-scheduling step into the vanishingly narrow window between “state changed” and “List responded”.

We need a way to decouple the data lifecycle from the view lifecycle, so we can take precise control over the animation.

Where Does the Target Height Come From?

In SwiftUI, measuring a target size via a hidden background or overlay is a well-known technique. But if we want something more general — wrap the “show/hide” content into a standalone component that doesn’t depend on a sibling that’s guaranteed to exist — we hit a wall: the measurement loses its anchor.

The API shape we’d like to write is roughly this. AnimatedPresence handles measurement and animation on its own, and the content doesn’t have to lean on some neighboring view (like name below) that happens to be guaranteed-present:

Swift
VStack(alignment: .leading) {
    Text(note.name)

    AnimatedPresence(
        value: description,
        animation: animation,
        contentTransition: .opacity
    ) { detail in
        Text(verbatim: detail)
            .font(.caption)
    }
}

The Visual Discomfort of “Squeeze”

A common collapse-animation approach is .frame(height: isExpanded ? nil : 0). With this form, SwiftUI hands the child a shrinking proposed height frame after frame.

But the child’s intrinsic height doesn’t gracefully shrink in response: the text gets truncated, or its baseline is pushed out of the visible area. The whole block looks like it’s being smashed flat rather than collapsing naturally. Beyond the bad optics, the wildly fluctuating “intrinsic height” during the animation also confuses the layout engine’s own measurements.

The takeaway is clear: even if we can interpolate height explicitly, we cannot do it by feeding the child smaller and smaller height proposals.

Solution Approach and Architecture

State Decoupling: Letting a State Machine Take Over the Data Lifecycle

Height changes come in three flavors:

  • nil -> value
  • value -> nil
  • value -> newValue

Because the data lifecycle is tightly bound to the view lifecycle, the view tree mutates the instant data changes, and the room for height interpolation collapses to zero. To break that, we introduce displayValue — a copy owned by the component itself, dedicated to “what gets rendered in the current animation frame” — decoupling data state from render state.

  • Expand: when value becomes a real value, sync it to displayValue immediately and start the expansion.
  • Collapse: when value becomes nil, displayValue is not cleared right away. It keeps holding the old data until the outer visibleHeight animation smoothly reaches 0 (via the animation’s completion callback), and only then is displayValue set to nil.

value decides the target state; displayValue decides whether there’s anything to draw in the current frame. Without this layer of decoupling, the content inside if let value would be destroyed the moment value becomes nil, and the disappearance animation would lose its material.

A Custom Layout: Preventing the Content from Squeezing or Reflowing

As discussed above, the “squeeze” path doesn’t work — the child’s height proposal cannot ride along with the animation. The shape we want is: the size reported to the parent varies continuously with the animation, while the place proposal handed to the child keeps its natural intrinsic size.

In other words: the parent row sees visibleHeight, going from 0 to the real intrinsic value (or the other way around). But the child itself is never squeezed — it always renders at its intrinsic size and stays anchored to the top of the layout.

To make that happen, we build a custom layout container, VisibleHeightLayout, that separates “size reported to the parent” from “size used to place the child”:

Swift
sizeThatFits  -> (intrinsic.width, visibleHeight)
placeSubviews -> place subview using intrinsic.width / intrinsic.height

The behavior this gives us:

  • When expanding: the parent row’s height grows frame by frame, and the content gradually emerges from the top.
  • When collapsing: the parent row’s height shrinks frame by frame, and the content gets clipped from the bottom.
  • The content itself never reflows, compresses, or rearranges because the outer height shrank.

So at the core, this is a deliberate asymmetry: the size we report to the parent participates in the animation, while the place proposal we give the child stays natural.

A side benefit: this custom container also gives us a stable anchor for measuring the target height. The child renders at its intrinsic size, so a GeometryReader attached to it (in a background) reads the real intrinsic — it isn’t polluted by the animating visible height.

Pair this with .clipped(), and we get the visual effect of “clipping, not squeezing.”

With the pieces above we can now write the call site like:

Swift
VStack(alignment: .leading) {
    Text(verbatim: title)
        .lineLimit(1)
        .font(.headline)

    AnimatedPresence(
        value: detail,
        animation: animation,
        contentTransition: .opacity
    ) { detail in
        Text(verbatim: detail)
            .font(.caption)
            .foregroundStyle(.secondary)
            .lineLimit(3)
    }
}

Going Further: Spacing Anomalies

A careful viewer of the previous video will spot another problem: at the very last frame of the collapse, the animation makes a visible two-step jump. And if we give the outer VStack an explicit spacing, it gets worse — the content is fully collapsed, yet VStack still reserves a strip of spacing for it.

Swift
VStack(alignment: .leading, spacing: 8) {
    ...
}

A standard VStack(spacing: 8) maintains an “unconditional spacing promise” toward its children. To the native VStack, children are physical occupants. Even after AnimatedPresence’s internal layout has driven the physical height down to 0, as long as the view node hasn’t been formally destroyed, VStack will still slot 8pt of spacing between it and its neighbors. As a result, at the tail of the collapse animation the user sees the content gone but an awkward strip of blank space lingering on both sides.

In the spacing = nil case, the default spacing vanishes abruptly the frame the view is removed from the hierarchy. The same “height goes to 0 first, then spacing snaps off” two-stage hard jump is just as destructive to animation continuity.

To do better, we build a replica of VStack: CollapsibleSpacingVStack.

CollapsibleSpacingVStack is essentially “a VStack that knows about its children’s presence state”. It doesn’t try to tackle ordinary vertical stacking; it targets the List-Row-style situation where some row content can collapse to 0, by pulling spacing onto the same animation timeline as height.

First, let the child explicitly declare “I might collapse.”

Swift
.ignoredWhenCollapsed()

When CollapsibleSpacingVStack processes a child carrying this marker, and the child’s height has shrunk to 0, it treats the child as “absent from the spacing calculation” — even though the child is still alive in the view tree.

Second, read the child’s collapse progress.

AnimatedPresence exposes a 0...1 progress to the parent layout via .collapsibleSpacingProgress(spacingProgress). There’s a non-obvious detail here: LayoutValueKey itself is a static property read at layout time, and cannot be interpolated directly by the animation system. But a ViewModifier can conform to Animatable — inside a withAnimation transaction, SwiftUI calls the modifier’s body frame by frame and rewrites the layoutValue — so a normally static layoutValue indirectly gains animatable capability.

In every layout pass, CollapsibleSpacingVStack reads the progress of adjacent children and computes the effective spacing as min(prevScale, nextScale) * baseSpacing. When a row starts to collapse, the spacing on either side of it shrinks toward 0 along the very same animation curve, in the same proportion as its height. The moment its height hits 0, the spacing seamlessly hits 0 too.

Third, bridge the spacing between two visible siblings separated by a collapsed child.

min(prevScale, nextScale) alone isn’t enough. When a collapsible child sits between two visible siblings, both of its adjacent spacings get squeezed to 0, and the two visible siblings end up glued together once the collapse finishes. But after the collapse, those two siblings are the real adjacent pair — they should still enjoy the normal base spacing.

To handle that, CollapsibleSpacingVStack scans for runs of consecutively-collapsed children and adds an extra baseSpacingDistance(prevVisible, nextVisible) * (1 - maxScale) between the visible siblings on either side, splitting it evenly across both ends of the collapsed run. This keeps the spacing distribution symmetric during the collapse, and leaves the new adjacent pair with the correct spacing once the collapse completes.

The core idea behind the whole mechanism: lock the spacing’s change onto the same timeline as the height’s change, eliminating the lag and the jump that come from spacing mutating out of sync.

Now, combining CollapsibleSpacingVStack with AnimatedPresence, we finally get the result we wanted:

SwiftUI Isn’t Perfect, but That Doesn’t Mean We’re Powerless

SwiftUI is imperfect — but that’s not the same as being powerless. The usual retreats are familiar: swap List for ScrollView + LazyVStack, or give up on animation entirely and let the transition hard-cut. Both routes sidestep the problem rather than solve it.

The solution presented here introduces no UIKit. Everything stays within the combination of Animatable, the Layout protocol, LayoutValueKey, and ViewModifier. Which perhaps says something worth remembering: SwiftUI’s real capability boundary is usually farther out than where you first hit a wall.

There’s a deeper question worth sitting with. The reason List row spacing is so stubborn comes down to how layout information flows in SwiftUI — one direction only. Parent containers can’t observe a child’s animation state; children have no way to notify a parent that they’re collapsing. The reason the LayoutValueKey + Animatable combination works here is precisely because it opens a small window in that one-way channel. If you run into parent-child layout synchronization problems elsewhere, this pattern is likely worth reaching for.

Full source code is available here.

Subscribe to Fatbobman

Weekly Swift & SwiftUI highlights. Join developers.

Subscribe Now