Skip to content

Read List's item-animation gating state once per pass instead of inside every item - #506

Closed
Aecasorg wants to merge 1 commit into
skiptools:mainfrom
Aecasorg:list-animation-gating
Closed

Read List's item-animation gating state once per pass instead of inside every item#506
Aecasorg wants to merge 1 commit into
skiptools:mainfrom
Aecasorg:list-animation-gating

Conversation

@Aecasorg

@Aecasorg Aecasorg commented Aug 7, 2026

Copy link
Copy Markdown

Small self-contained fix from the investigation written up in #486.

Problem

RenderList builds the item-animation predicate as a @Composable closure and calls it from inside each of the four item-production lambdas:

let shouldAnimateItems: @Composable () -> Bool = {
    let animate = !forceUnanimatedItems.value && EnvironmentValues.shared._searchableState?.isSearching.value != true
    return animate
}

items(count: count, key: key) { index in
    
    let itemModifier: Modifier = shouldAnimateItems() ? Modifier.animateItem() : Modifier   // L308 / L318 / L328 / L338

Because the reads happen inside the item lambda, every visible item's own composition scope subscribes to forceUnanimatedItems and to _searchableState.isSearching. A single flip of either then invalidates every visible row individually and re-runs its content. forceUnanimatedItems is not rare: it is set true on any unanimated recomposition and flipped back 300 ms later by the LaunchedEffect at L267-276.

The predicate's value is identical for every item within a pass, so nothing is gained by evaluating it per item.

Change

Compute it once in the LazyColumn content scope (next to the existing moveTrigger read) and capture the Bool in the item lambdas. Strictly fewer subscribing scopes; same value everywhere it is used; no behavior change.

Verification

swift build clean (transpile + macOS compile) on top of main.

The user-visible cost of the current behavior is much larger in a Fuse app than a transpiled one — re-running a row there rebuilds its view tree across the bridge (two JNI hops plus a fresh peer allocation per bridged view per level), so a flip that re-runs ten visible rows is meaningfully expensive. Details in #486.

Not included

The LaunchedEffect(System.currentTimeMillis()) at L267-276 restarts on every RenderList pass because its key is wall-clock time, and its delayed write flips forceUnanimatedItems back — which recomposes the list, which restarts the effect. That looks like a self-sustaining ~300 ms cycle for any list that is not animating, and this PR does not address it: every data-version signal we considered as a replacement key (item count, first/last identifier) changes animation behavior for same-count data replacements, which is a visible regression we did not want to introduce blind. Happy to take direction on the intended semantics there and follow up — it seems the more valuable of the two fixes.

…ry item

shouldAnimateItems() was invoked inside each of the four item-production
lambdas, so every visible item's own composition scope subscribed to
forceUnanimatedItems and _searchableState.isSearching. A single flip of
either — and forceUnanimatedItems flips on a 300ms timer after any
unanimated recomposition — therefore invalidated every visible row
individually and re-ran its content. That is wasted work in a transpiled
app and much more costly in a Fuse app, where re-running a row rebuilds
its view tree across the bridge.

The predicate's value is identical for all items within a pass, so it is
now computed once in the LazyColumn content scope and captured. No
behavior change.

Part of skiptools#486.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit b54538b)
@cla-bot cla-bot Bot added the cla-signed label Aug 7, 2026
@Aecasorg

Aecasorg commented Aug 7, 2026

Copy link
Copy Markdown
Author

Closing this — the change does not compile, and I should have caught that before opening the PR. Apologies for the noise.

Hoisting the predicate out of the item lambdas moves EnvironmentValues.shared._searchableState into LazyColumn's content lambda, which is LazyListScope.() -> Unit and not @Composable:

List.kt:295:88 error: @Composable invocations can only happen from the context of a @Composable function

It passed swift build because that transpiles to Kotlin without compiling it; the error only appears when the generated Kotlin is compiled. That is now part of my pre-PR checklist.

Worth recording why I don't think there is a simple corrected version: the only genuinely @Composable scope outside the item lambdas is RenderList's own body, and a state read there subscribes the whole list — a forceUnanimatedItems flip would then re-run RenderList, re-Evaluate its content, re-initialize the item collector and re-register every item, which is no cheaper than the per-item invalidations I was trying to remove. So the current placement inside the item lambdas looks deliberate, and the per-row cost is better addressed by the LaunchedEffect(System.currentTimeMillis()) key discussed in the PR description (that effect restarting every pass, and its delayed write flipping the flag back, is what makes the flips frequent in the first place). Happy to take direction on the intended semantics there if you'd like that fixed.

@Aecasorg Aecasorg closed this Aug 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant