JetpackCompose.app's Dispatch Issue #20

💌 In today’s Dispatch: 🛼 2D Scrolling 🎣 Prefetching Lists 🔎 Better Compose Debugging & 🏗️ Architecting Persistent UI Elements

2025 State of Mobile Release Management

Runway's new report on mobile releases shows that automation alone isn't solving core issues: teams that invest significantly in automation still lose 6–10 hours per release to manual busywork and coordination overhead – about the same as teams with less automation in place

GM fellow Compose wranglers and perpetual gradle prophets. This is JetpackCompose.app’s Dispatch, arriving with that “fresh build succeeded after a major dependency update” energy. Whether you’re perfecting list performance or arguing with your designer about navigation bar persistence for the 42nd time this week, Issue #20 is packed with hard-won insights and spicy new Android/Compose techniques ready for you to try out. So grab your favorite debugging snack, silence those meeting invites, and let’s get rolling!

🤔 Interesting tid-bits

Handfuls of Compose Goodness from the August ’25 Release

Compose just dropped a sleigh’s worth of shiny new APIs in the August ’25 update (BOM 2025.08.00). Here’s your quick tour of stuff that’s extra interesting, specifically the ones we haven’t already covered in the previous issues of this newsletter

2D Scrolling: Scroll Like You Mean It

Ever tried mimicking a spreadsheet in Compose, only to be undone by single-direction scrolls? The new Modifier.scrollable2D() lets you pan around both axes—hello, natural-feeling image viewers and data grids! You can also set up nested 2D scroll scenarios; Compose finally acknowledges that the world isn’t just up/down or left/right.

val offset = remember { mutableStateOf(Offset.Zero) }
Box(
    Modifier.size(150.dp)
        .scrollable2D(
            state = rememberScrollable2DState { delta ->
                offset.value += delta
                delta // indicate consumption of pixels
            }
        )
        .background(Color.LightGray)
)

Prefetch Party: Lazier Lists, Sooner

Smooth list scrolling lives and dies by good prefetching, and Compose’s default cache strategies just got a massive glow-up. Now you can customize how far ahead (and behind) to prefetch items using the new LazyLayoutCacheWindow. Whether you want to go by pixels (ahead = 150.dp), fraction of viewport, or keep things old-school, you’re in control.

val cacheWindow = LazyLayoutCacheWindow(
    ahead = 150.dp, 
    behind = 100.dp
)
val listState = rememberLazyListState(cacheWindow)
LazyColumn(state = listState) { /* ... */ }

Scroll Interop Improvements

There are bug fixes and new features to improve scroll and nested scroll interop with Views, including the following:

  • You can now use ViewTreeObserver onScrollChangeListeners to listen to Compose scroll events.

  • Fixed the dispatching of incorrect velocities during fling animations

  • Compose now correctly invokes the View's nested scroll callbacks in the right order.

  • Nested scrolling is respected in the case of NestedScrollView inside an AndroidView.

Bitdrift’s hands-on guide instruments the Wikipedia Android app end-to-end: SDK setup, auto-OkHttp, custom logs, and a workflow that records sessions you can scrub in 3D Session Replay!

Here’s a piece of code that gets especially impacted by some of the latest features from the August ’25 Release.

The code snippet below wants to log the impressions of each list item that ends up rendered on the screen.

@Composable
fun MyList(items: List<Item>) {
    LazyColumn {
        items(items) { item ->
            LaunchedEffect(item.id) {
                logItemImpression(item)
            }
            ItemRow(item = item)
        }
    }
}

Can you see anything incorrect in this implementation? What subtle bug lurks here?

See the answer in a section below ⬇️

😆 Dev Delight

🐫 Where’s my camel gang at?

Java 11 and 17** for most Android apps

🤿 Deep Dive

Persistent UI Elements: Root, Per-Screen, or… Both?

If you've ever felt existential dread trying to decide where your app’s navigation bars, top/bottom bars, or FABs should truly live—root level, per-screen, or in a "Scaffold soup"—grab your beverage of choice: it's time for a modern reckoning.

The way you architect persistent UI elements impacts app modularity, animation smoothness, testing friction, and (most importantly) your career longevity—I've seen too many brilliant devs become code janitors cleaning up nav-bar-state hacks. Enter a killer article by my extraordinary colleague TJ Dahunsi, who systematically unpacks the trade-offs and ultimate solution patterns for Compose apps in 2025.

I’m confident that you will be dealing with this decision either at work or in an interview context/conversation so I STRONGLY RECOMMEND READING THE ENTIRE ARTICLE as a summary won’t do it justice.

The reason I loved this article was because it was opinionated and took a firm stance at how to approach and solve this conundrum. Here’s an excerpt from the intro of the blog post:

Satisfyingly, I believe this to be one of the rare cases where the classic software engineering "It depends" response does not apply. I'm firmly of the opinion that for Jetpack Compose apps, the per-screen UI element approach should always be preferred. Let's take a brief walk down memory lane.

TJ Dahunsi

Takeaway: The amazing animation APIs that Compose exposes allow us to handle this problem in an elegant manner. Read the full article to understand how.

🔦 Code Corner

Extracting Raw HTML from String Resources in Compose

I stumbled on this question on Slack:

If my string resource is HTML, how do I get the RAW string at runtime—without Android stripping tags, turning everything into plain text, and leaving my carefully crafted formatting in the dust?

What exactly was the issue?

  • context.getString(resId) strips/decodes the HTML tags, flattening your content.

  • Even Resources.getValue() won’t help; by build-time, Android (aapt) converts those string resources into styled spans, not verbatim XML.

How to fix it?

Round-trip it! Use context.getText(resId) to get the styled value. Then, if it’s a Spanned, run it back through Html.toHtml(...) to recover the original tags:

@Composable
@ReadOnlyComposable
fun htmlStringResource(@StringRes resId: Int): AnnotatedString {
    // Ensures that string content is updated on config changes (e.g., locale).
    LocalConfiguration.current

    val context = LocalContext.current
    val text = context.getText(resId)
    return if (text is Spanned) {
        AnnotatedString.fromHtml(Html.toHtml(text, Html.TO_HTML_PARAGRAPH_LINES_CONSECUTIVE))
    } else {
        AnnotatedString(text.toString())
    }
}

Pro tips:

  • If you’re managing localization, make sure your pipeline doesn’t do its own stripping before Android sees it.

  • Some recommend using <![CDATA[string content goes here]]> to force verbatim in XML, but modern aapt and resource compilers may still style/convert.

  • If you expect to round-trip HTML and preserve tags (say, for in-app annotation/editing!), test this at build and runtime on all supported locales and resource toolchains.

Composables like LazyColumn have always prefetched items outside of the viewport. So the code snippet was always buggy from the get go as the impressions would get logged as soon as an item was in composition. With the prefetching APIs that are part of the latest August ’25 Release, more items will be COMPOSED (and thus, their effects run!) even when they’re NOT visible.

The correct move is to track visibility specifically, using the new Modifier.onFirstVisible or Modifier.onVisibilityChanged that we’ve covered in a previous edition of the newsletter. Never use side effects inside list item composition as a signal for actual on-screen visibility in apps targeting future Compose releases!

The correct fix:

LazyColumn {
    items(items) { item ->
        Box(
            Modifier.onFirstVisible {
                logItemImpression(item)
            }
        ) {
            ItemRow(item = item)
        }
    }
}

🦄 How you can help?

If you enjoy reading this newsletter and would like to keep Dispatch free, here are two quick ways to help:

👉🏻 Chip in via Github Sponsors. If your company offers an education budget, contributing a “coffee” to keep this newsletter going will certainly qualify. You’ll get an instant receipt for reimbursement, and I’ll keep Dispatch running ☕️

👉🏻Spread the word. Tweet, Bluesky, toot, Slack, or carrier‑pigeon this issue to someone who loves Android. Each new subscriber pushes Dispatch closer to sustainability.

👂 Let me hear it!

What’d you think of this email? Tap your choice below and lemme hear it 👇

🚀🚀🚀🚀🚀 AMAZING-LOVED IT!
🚀🚀🚀 PRETTY GOOD!

On that note, here’s hoping that your bugs are minor and your compilations are error free,

Tech Lead Manager @ Airbnb | Google Developer Expert for Android | ex-Snap, Spotify, Deloitte

Vinay Gaba is a Google Developer Expert for Android and serves as a Tech Lead Manager at Airbnb, where he spearheads the UI Tooling team. His team's mission is to enhance developer productivity through cutting-edge tools leveraging LLMs and Generative AI. Vinay has deep expertise in Android, UI Infrastructure, Developer Tooling, Design Systems and Figma Plugins. Prior to Airbnb, he worked at Snapchat, Spotify, and Deloitte. Vinay holds a Master's degree in Computer Science from Columbia University.

Reply

or to participate.