skiptools / skiptools/skip-ui

List rows animate their placement on first appearance when any other animation is in flight

Open
#515 0 comments 0 reactions 0 assignees View on GitHub

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

  1. Suppress the item animation for a list's first composition specifically
    (the rows have no prior placement, so nothing is lost).
  2. Narrow the gate to animations whose source is this list's own content, rather
    than any ambient animation.
  3. Failing either, expose a supported opt-out. There is currently none reachable
    from Swift: _animation is internal, and .animation(nil) does not help
    because Animation.current falls back to the withAnimation marker 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.