Skip to content

Interactive previews with GIF recordings for the animation quick guide - #1215

Open
riggaroo wants to merge 9 commits into
mainfrom
interactive-previews-animations
Open

riggaroo wants to merge 9 commits into
mainfrom
interactive-previews-animations

Conversation

@riggaroo

@riggaroo riggaroo commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

Adds interactive previews for the animation quick guide, with animated MP4 recordings as the static preview instead of PNG screenshots. Based on main, so it doesn't depend on the Material component PRs (#1191, #1193).

MP4 recordings

  • New PreviewRecordings.kt in the generator tests. For each animation snippet, SnippetRecordings holds a short script of PreviewSteps (Wait, Click, Press) that triggers its animation and returns to the starting state, so the video loops.
  • PreviewScreenshotTest.record() plays the script with Roborazzi's recordRoboVideo at 25 fps and full resolution. Roborazzi can only write animated images, so a new encodePreviewVideos task (run after recordRoborazziDebug) converts each recording with ffmpeg to an H.264 screenshots/<region_tag>.mp4 (crf 23, faststart, bit-exact) and deletes the intermediate GIF. The MP4 is deployed to GitHub Pages with the screenshots, and DAC syncs it as the static preview, with the PNG screenshot as its poster.
  • MP4s are about 25–40x smaller than GIFs at the same resolution. A full-resolution GIF was 5.7 MB at 8 fps, and scaling it down to fit made it blurry.
  • Recording now needs ffmpeg on the PATH. The deploy-preview-pages workflow installs it with apt-get, and the README tells contributors to install it locally.
  • Recorded snippets use a 768x432 dp AnimationPreviewFrame, because they're written for a phone-sized screen (for example a box that grows from 200dp to 400dp). Videos are 1536x864 px (the frame at 2x density).
  • The generator test gets 4 GB of heap, because recordRoboVideo keeps every frame in memory until it's encoded.

Snippets enabled (10)

cookbook_visibility, cookbook_visibility_alpha, animation_size_change, animation_offset_change, animation_layout_offset, animation_padding, cookbook_elevation, cookbook_text, cookbook_text_color and cookbook_animated_content.

Not enabled: the snippets that don't draw anything on their own (background color, infinitely repeating, launch, sequential and concurrent animations), and the navigation example (network images). Their pages keep the existing figures.

Making AnimationQuickGuide.kt compile for wasm

  • Resources: generateSnippetResources copies the R.drawable.*/R.string.* resources the pulled snippets reference into Compose Multiplatform resources and generates a matching R. ResStubs.kt backs painterResource/stringResource with them. This is the same mechanism as in [2/3] Interactive Compose previews: enable component snippets #1191, so the two PRs merge cleanly in either order.
  • Coil 3: the Compose snippets already use Coil 3 on main (Migrate the Compose snippets from Coil 2 to Coil 3 #1216). This PR only adds the web side of the network stack: coil-network-ktor3 with the Ktor JS engine.
  • The navigation example (android_compose_animate_destinations) is migrated to Navigation 3. It uses NavDisplay with a mutableStateListOf<Any> back stack, and Landing/Detail(photoIndex) keys. Detail overrides the transitions with NavDisplay.TransitionKey/PopTransitionKey metadata, which replaces enterTransition/exitTransition on a NavHost composable. Passing the photo index in the key also removes the java.net URL encoding. The DAC prose for that section is updated in the CL.
  • The back arrow in the navigation example's details screen (outside the DAC region) comes from a new ic_arrow_back.xml vector drawable instead of Icons.Default.ArrowBack, so the wasm build doesn't need the material-icons library.
  • The wasm build adds Compose Multiplatform ui-tooling-preview (for @Preview) and components-resources, plus JetBrains' multiplatform navigation3-ui (1.2.0; Android uses androidx.navigation3 1.2.0-beta01).

Testing

  • ./gradlew :preview:wasm:compileKotlinWasmJs :preview:generator:compileDebugUnitTestKotlin :compose:snippets:compileDebugKotlin all pass.
  • recordRoborazziDebug passes for all 15 snippets (the 5 buttons and the 10 animation snippets). and logs Encoded 10 preview recording(s). The 10 MP4s total about 455 KB (1536x864, 25 fps); the largest is animated content at 327 KB. I checked frames from the videos and they are sharp.
  • Navigation 3 snippet, loaded temporarily in the web runner: the photo grid loads through Coil 3, tapping a photo slides and fades the details screen in, and back slides and fades it out.
  • DAC template, tested in headless Chrome with the staged frame HTML: the MP4 autoplays and loops (1536x864, no errors), and with prefers-reduced-motion: reduce it stays paused on the first frame.
  • Live preview in Chrome: tapping Show/Hide hides the box, tapping animated content shows the dog photo (loaded through the generated resources), text color animates, and the button previews still render. No console errors.

DAC change: cl/1005450674 (staged at https://android.devsite.corp.google.com/develop/ui/compose/animation/quick-guide).

- Register 10 AnimationQuickGuide.kt snippets in SnippetRegistry (with a
  768x432 AnimationPreviewFrame) and pull that file, plus
  util/ImageLoadingSampleUtils.kt, into the wasm build.
- Add PreviewRecordings (generator tests): per-snippet PreviewStep scripts
  (Wait, Click, Press). PreviewScreenshotTest.record() plays them with
  recordRoboVideo and writes screenshots/<region_tag>.gif, the static DAC
  preview for animation snippets.
- Support R.drawable/R.string in the wasm build: generateSnippetResources
  copies the referenced resources into Compose Multiplatform resources and
  generates a matching R (ResStubs.kt maps painterResource/stringResource).
- Make AnimationQuickGuide.kt multiplatform: Coil 3 instead of Coil 2, route
  the navigation example by photo index instead of java.net URL encoding,
  and draw the back arrow from a vector drawable instead of material-icons.
- Give the generator test 4 GB of heap for GIF encoding.
@riggaroo
riggaroo force-pushed the interactive-previews-animations branch from 97ed22f to e39d77d Compare October 23, 2026 10:34
@riggaroo
riggaroo changed the base branch from interactive-previews-2-all-snippets to main October 23, 2026 10:36
@riggaroo
riggaroo marked this pull request as ready for review October 23, 2026 10:39
@riggaroo
riggaroo requested a review from a team as a code owner October 23, 2026 10:39
@riggaroo
riggaroo requested a review from AliceYuan October 23, 2026 10:39
@compose-devrel-github-bot

Copy link
Copy Markdown
Contributor

⚡ Jetpack Compose Performance Review

1. Missing key and contentType in LazyVerticalGrid itemsIndexed

  • File: compose/snippets/src/main/java/com/example/compose/snippets/animations/AnimationQuickGuide.kt
  • Line Numbers: 870–882
  • Rule Category: Category 3: Lazy layouts optimization (references/lazy-layouts.md)
  • Why it impacts Compose performance:
    • Missing key: By omitting key, Compose defaults to using item positions (indices) as keys. If items are reordered, inserted, or updated, Compose cannot maintain item state or identity across passes without recreating compositions from scratch.
    • Missing contentType: Omitting contentType prevents Compose from reusing composition slots and node hierarchies when scrolling through items of the same visual structure, increasing layout and composition inflation overhead during scrolling.
  • Recommended Fix:
    Provide an explicit primitive/stable key (such as the unique photo URL string) and a contentType lambda to allow slot table node reuse during scrolling:
<<<<
            itemsIndexed(randomSizedPhotos) { index, photo ->
====
            itemsIndexed(
                items = randomSizedPhotos,
                key = { _, photo -> photo },
                contentType = { _, _ -> "photo" }
            ) { index, photo ->
>>>>
                AsyncImage(
                    model = photo,
                    contentScale = ContentScale.Crop,
                    contentDescription = null,
                    modifier = Modifier
                        .fillMaxWidth()
                        .height(200.dp)
                        .clickable {
                            onItemClicked(index)
                        }
                )
            }

Non-Issue Files in Diff Scope

The remaining files touched in this PR diff were reviewed and comply with performance best practices:

  • preview/generator/src/test/java/com/example/compose/preview/generator/PreviewRecordings.kt: Test data structures and interaction scripts for GIF generation; contains no @Composable functions.
  • preview/generator/src/test/java/com/example/compose/preview/generator/PreviewScreenshotTest.kt: Test harness capturing Roborazzi screenshots and video recordings.
  • preview/wasm/src/wasmJsMain/kotlin/androidx/compose/ui/res/ResStubs.kt: Multiplatform resource stubs delegating directly to CMP painterResource and stringResource.
  • preview/wasm/src/wasmJsMain/kotlin/com/example/compose/preview/wasm/registry/SnippetRegistry.kt: Snippet preview frame configurations and invocation lambdas in a registry map.

@riggaroo
riggaroo marked this pull request as draft October 23, 2026 10:44
Use NavDisplay with per-entry NavDisplay.TransitionKey/PopTransitionKey
metadata instead of NavHost enterTransition/exitTransition. The wasm build
now depends on JetBrains' multiplatform navigation3-ui instead of
navigation-compose.
@riggaroo
riggaroo removed the request for review from AliceYuan October 23, 2026 11:03
…animations

# Conflicts:
#	compose/snippets/build.gradle.kts
#	compose/snippets/src/main/java/com/example/compose/snippets/animations/AnimationQuickGuide.kt
#	gradle/libs.versions.toml
- Fail the screenshot test setup if SnippetRecordings has IDs that aren't in
  SnippetRegistry, or recorded snippets without a SnippetPreviewFrames entry.
- Navigation 3 snippet: add predictive back transitions
  (predictivePopTransitionSpec and NavDisplay.PredictivePopTransitionKey),
  ignore repeated taps during transitions so the back stack can't empty, and
  note rememberNavBackStack for surviving configuration changes.
- Use named RoboVideoOptions arguments and explain settleTimeoutMillis = 0.
- Click the snippet's only clickable node (onNode) so a second clickable
  node fails instead of recording a still GIF.
- Record the animated content GIF at 0.35x (537x302, 2.0 MB, was 3.8 MB).
- Declare the screenshots directory as a test output for the build cache.
- Look for drawables in every drawable-* directory.
- Fix the SnippetPreviewFrames KDoc placement.
@riggaroo
riggaroo marked this pull request as ready for review October 27, 2026 01:13
@snippet-bot

snippet-bot Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Here is the summary of changes.

You are about to add 1 region tag.
You are about to delete 1 region tag.

This comment is generated by snippet-bot.
If you find problems with this result, please file an issue at:
https://github.com/googleapis/repo-automation-bots/issues.
To update this comment, add snippet-bot:force-run label or use the checkbox below:

  • Refresh this comment

@compose-devrel-github-bot

Copy link
Copy Markdown
Contributor

⚡ Jetpack Compose Performance Review

Finding: Missing key and contentType in LazyVerticalGrid

  • File Path: compose/snippets/src/main/java/com/example/compose/snippets/animations/AnimationQuickGuide.kt
  • Line Numbers: 889–902
  • Rule Category: Category 3: Lazy layouts optimization — Mandatory contentType and Primitive key
  • Impact on Compose Performance:
    In ScreenLanding, LazyVerticalGrid invokes itemsIndexed(randomSizedPhotos) without specifying key or contentType:
    1. Slot table reuse (contentType): Omitting contentType prevents Compose from reusing composition slots and node structures when recycling items of the same visual type during fast grid scrolling, incurring unnecessary layout inflation and slot recreation costs.
    2. Item state preservation (key): Omitting a stable, primitive key forces Compose to default to item index positions as keys. If the list changes, reorders, or animates, Compose cannot preserve item state and will needlessly recompose and recreate item compositions from scratch.
  • Recommended Fix:
    Provide a stable primitive/string key (such as the photo URL string) and a contentType lambda in itemsIndexed:
        content = {
            itemsIndexed(
                items = randomSizedPhotos,
                key = { _, photo -> photo },
                contentType = { _, _ -> "photo" },
            ) { index, photo ->
                AsyncImage(
                    model = photo,
                    contentScale = ContentScale.Crop,
                    contentDescription = null,
                    modifier = Modifier
                        .fillMaxWidth()
                        .height(200.dp)
                        .clickable {
                            onItemClicked(index)
                        }
                )
            }
        },

GIFs of the animation snippets were either large (5.7 MB for animated
content at full size) or blurry when scaled down. Record every preview at
full size and 25 fps, then convert the GIFs from recordRoboVideo to H.264
MP4s with a new encodePreviewVideos task (ffmpeg, preinstalled on GitHub's
Ubuntu runners), which runs after recordRoborazziDebug and deletes the GIFs.

All 10 recordings are 1536x864 at 25 fps and total about 455 KB (animated
content: 327 KB). DAC shows them as looping, muted videos with the PNG
screenshot as the poster.
@compose-devrel-github-bot

Copy link
Copy Markdown
Contributor

⚡ Jetpack Compose Performance Review

Finding 1: Missing contentType and Primitive key in LazyVerticalGrid

  • File: compose/snippets/src/main/java/com/example/compose/snippets/animations/AnimationQuickGuide.kt
  • Lines: 891–903
  • Rule Category: Category 3: Lazy layouts optimization (references/lazy-layouts.md)

Why it impacts Compose performance

When invoking itemsIndexed on a virtualized container such as LazyVerticalGrid:

  1. Omitted contentType: Defaults to null, which prevents Compose from identifying reusable composition slots and recycling node layouts across item boundaries during scrolling. This results in unnecessary layout inflation overhead and can cause frame drops and scroll jank.
  2. Omitted key: Falls back to using the item's positional index as the key. If items are reordered, inserted, or filtered, Compose cannot reliably match items to their existing composition state and may needlessly recompose item nodes. Providing a unique primitive string key (the photo URL) ensures stable identity across composition passes.

Recommended Fix

Specify both a primitive key and a contentType parameter in itemsIndexed:

        content = {
            itemsIndexed(
                items = randomSizedPhotos,
                key = { _, photo -> photo },
                contentType = { _, _ -> "photo" },
            ) { index, photo ->
                AsyncImage(
                    model = photo,
                    contentScale = ContentScale.Crop,
                    contentDescription = null,
                    modifier = Modifier
                        .fillMaxWidth()
                        .height(200.dp)
                        .clickable {
                            onItemClicked(index)
                        }
                )
            }
        },

@compose-devrel-github-bot

Copy link
Copy Markdown
Contributor

⚡ Jetpack Compose Performance Review

Finding: Missing key and contentType in LazyVerticalGrid itemsIndexed

  • File: compose/snippets/src/main/java/com/example/compose/snippets/animations/AnimationQuickGuide.kt
  • Lines: 889–901
  • Category: Category 3: Lazy layouts optimization (references/lazy-layouts.md)

Why it impacts Compose performance:

In ScreenLanding, LazyVerticalGrid's itemsIndexed is called without specifying key and contentType.

  1. Omission of key: Compose defaults to using item positions as keys. If items shift, reorder, or update, Compose cannot preserve item composition and state across passes, leading to unnecessary item recompositions and recreation.
  2. Omission of contentType: When contentType is omitted (defaulting to null), Compose cannot reuse composition slots and item nodes between recycled items of the same visual type during scrolling. Providing an explicit contentType enables Compose to reuse composition and layout structures during fast scrolling, significantly reducing layout inflation overhead.

Recommended Fix:

Provide a stable primitive/string key (each image URL in randomSizedPhotos is a unique String) and a contentType (e.g., "photo"):

            itemsIndexed(
                items = randomSizedPhotos,
                key = { _, photo -> photo },
                contentType = { _, _ -> "photo" },
            ) { index, photo ->
                AsyncImage(
                    model = photo,
                    contentScale = ContentScale.Crop,
                    contentDescription = null,
                    modifier = Modifier
                        .fillMaxWidth()
                        .height(200.dp)
                        .clickable {
                            onItemClicked(index)
                        }
                )
            }

@compose-devrel-github-bot

Copy link
Copy Markdown
Contributor

⚡ Jetpack Compose Performance Review

Finding 1: Missing key and contentType in LazyVerticalGrid

  • File: compose/snippets/src/main/java/com/example/compose/snippets/animations/AnimationQuickGuide.kt
  • Lines: 891
  • Rule Category: Category 3: Lazy layouts optimization (references/lazy-layouts.md)

Why It Impacts Compose Performance

In ScreenLanding, LazyVerticalGrid invokes itemsIndexed(randomSizedPhotos) without specifying key and contentType:

  1. Missing key: Without an explicit, stable key, Compose defaults to using item positions as keys. When items shift, reorder, or update, Compose cannot maintain item composition state and may cancel ongoing image loads or unnecessarily recreate item compositions.
  2. Missing contentType: Without contentType, Compose cannot identify that items share the same visual structure. Specifying contentType allows Compose to reuse composition slots and node hierarchy layouts when recycling items during scrolling, significantly reducing layout inflation overhead and frame drops.

Recommended Fix

Provide a stable primitive key (such as the photo URL string) and define a contentType lambda:

            itemsIndexed(
                items = randomSizedPhotos,
                key = { _, photo -> photo },
                contentType = { _, _ -> "photo" }
            ) { index, photo ->
                AsyncImage(
                    model = photo,
                    contentScale = ContentScale.Crop,
                    contentDescription = null,
                    modifier = Modifier
                        .fillMaxWidth()
                        .height(200.dp)
                        .clickable {
                            onItemClicked(index)
                        }
                )
            }

@compose-devrel-github-bot

Copy link
Copy Markdown
Contributor

⚡ Jetpack Compose Performance Review

No Jetpack Compose performance issues were found in the changed code.

Summary of Reviewed Changes

  1. compose/snippets/src/main/java/com/example/compose/snippets/animations/AnimationQuickGuide.kt:

    • Lazy Layout Optimization: The migration from items(randomSizedPhotos) to itemsIndexed properly specifies both a stable, unique String key (key = { _, photo -> photo }) and contentType (contentType = { _, _ -> "photo" }), ensuring composition slot reuse and proper recycling during scrolling.
    • Index Passing: Utilizing itemsIndexed provides index directly to onItemClicked(index) without requiring expensive O(N) index searches inside item compositions.
    • Unique Key Seeds: The randomSizedPhotos list was updated with indexed seeds (randomSampleImageUrl(seed = index, ...)), preventing duplicate keys in the lazy grid.
    • Destination Models: Destination classes data object Landing and data class Detail(val photoIndex: Int) are flat, immutable/stable models adhering to Strong Skipping Mode conventions.
    • Resource Usage: Replacing Icon(Icons.Default.ArrowBack) with Icon(painterResource(R.drawable.ic_arrow_back)) uses cached drawable painter resources.
  2. Preview and Test Files (PreviewRecordings.kt, PreviewScreenshotTest.kt, ResStubs.kt, SnippetRegistry.kt):

    • These additions contain test runners, preview registries, and Multiplatform resource stubs that do not introduce runtime Compose performance bottlenecks.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants