List rows animate their placement on first appearance when any other animation is in flight
Nobody has claimed this yet.
- Dominant language
- Swift
- Stars
- 330
- Forks
- 76
- Avg merge
- 3h 6m
- Merged PRs (30d)
- 1
Description
List rows animate their placement on first appearance when any other animation is in flight
Repo: skip-ui
Affects: Sources/SkipUI/SkipUI/Containers/List.swift
Summary
Every List row is composed with Modifier.animateItem(), gated on whether any
other animation is currently running. A tab switch or a NavigationStack push
is such an animation, so a List that is appearing for the first time inside
one animates all of its rows into their placement — from a placement they never
had. On screen this reads as the whole list sliding sideways into position over
roughly half a second while the surrounding chrome is already settled.
Where
Containers/List.swift, the item modifier (four call sites, e.g. line 308):
let itemModifier: Modifier = shouldAnimateItems() ? Modifier.animateItem() : Modifier
and the gate that feeds it (line ~267):
let forceUnanimatedItems = remember { mutableStateOf(false) }
if Animation.current(isAnimating: false) == nil {
forceUnanimatedItems.value = true
LaunchedEffect(System.currentTimeMillis()) {
delay(300)
forceUnanimatedItems.value = false
}
} else {
forceUnanimatedItems.value = false
}
Animation.current resolves the ambient .animation(_:) and, when that is nil,
falls back to the marker left by a recent withAnimation. The condition it
actually expresses is "is anything animating anywhere", not "is this list's
content changing in an animated way".
Reproduction
A TabView with two tabs, each a NavigationStack over a List of ~17 rows.
Populate the list before the tab is first shown (so no data arrives late), then
switch to that tab.
Measured on a Samsung SM-A326U, Android 14, with screenrecord at 60fps and
per-frame column geometry:
| t | header | search field | rows | tab bar |
|---|---|---|---|---|
| 2.08s | 30 (final) | 32 (final) | 16 | 50 |
| 2.10s | 30 | 32 | 16 | 32 (final) |
| 2.63s | 30 | 32 | 52 | 32 |
The chrome is at its final x immediately; the rows sit 36px short of theirs for
0.55s and then travel. With the screen idle — 297 consecutive frames, including
two scrolls — the geometry never changes, which matches the gate: with nothing
else animating, forceUnanimatedItems is true and rows snap.
Replacing the List with ScrollView + LazyVStack (same LazyColumn
underneath, but no animateItem) reduces the travel from 36px to 8px, which
isolates the modifier as the cause.
A second, smaller symptom on the same trigger
After moving the screen to ScrollView + VStack (no List, no animateItem),
36px of travel became 9px — but it did not reach zero. Segmenting one row's ink
by column, pre-settle vs settled:
| artwork | title | trailing icons | |
|---|---|---|---|
| pre-settle | 21–87 | 110–265 | 630–660 (one glyph) |
| settled | 30–96 | 119–274 | 564–594, 622–641, 662–688 (three glyphs) |
Every element keeps its exact size; the whole row translates 9px right at the
instant the two remaining glyphs are drawn. A probe inside the row (logging on
every paint) shows the data driving those glyphs is present and unchanged from
the first pass, so they are composed but not drawn on it.
This may be the same ambient-animation coupling reaching layout modifiers rather
than item placement — Animation.swift documents that frame / offset /
padding sites also consult Animation.current. If so, both symptoms share the
trigger: an unrelated animation in flight during first composition.
Why this looks wrong
In Compose, item placement animation is opt-in per item, and it exists to
animate items whose position changed. An item being composed for the first
time has no previous placement, so there is nothing to animate from — Compose
animates it from wherever the first measure put it, which is why the travel is a
constant offset rather than a data-dependent one.
The current gate cannot distinguish "the list's own items moved" from "an
unrelated animation is running". Screen transitions are the common case of the
latter, and they are also exactly when a list is most likely to be composed for
the first time — so the two conditions coincide precisely where the artifact is
most visible.
Suggested directions
- Suppress the item animation for a list's first composition specifically
(the rows have no prior placement, so nothing is lost). - Narrow the gate to animations whose source is this list's own content, rather
than any ambient animation. - Failing either, expose a supported opt-out. There is currently none reachable
from Swift:_animationis internal, and.animation(nil)does not help
becauseAnimation.currentfalls back to thewithAnimationmarker exactly
when the ambient value is nil.
Workaround for others hitting this
Use ScrollView + LazyVStack for lists that do not need swipe actions or
reordering. Lists that need reordering are stuck, since the reorder integration
(org.burnoutcrew.reorderable) is attached to List.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in Sources/SkipUI/SkipUI/Containers/List.swift, especially the item modifier call sites around line 308 and the forceUnanimatedItems gate around line 267. Reproduce with a TabView containing NavigationStack and pre-populated Lists, then trace how Animation.current affects first composition. Done means first appearance does not animate rows from an unestablished placement while item movement animations still work when list content changes.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- swift
- Domain
- mobile
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 52/100