Skip to content

Repository files navigation

Underlay

Maven Central CI

Русская версия

Blurred backdrop for dialogs and overlays that live in their own window, down to API 23.

Snapshot behind a Dialog Fallback in a translucent activity
A dialog over a grid of tiles, its backdrop blurred from a snapshot of the activity window A translucent activity drawing the solid fallback color, with no host window to capture

Both from :sample on one device.

Dialog(onDismissRequest = ::dismiss) {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .blurredUnderlay(
                blurRadius = 16.dp,
                tint = Color.Black.copy(alpha = 0.3f),
                fallback = MaterialTheme.colorScheme.surface,
            ),
    ) {
        // dialog content
    }
}

Why this exists

Compose blur libraries — haze, Cloudy, imla — blur a composable subtree: you mark it with a modifier, they capture it into a GraphicsLayer and blur that layer.

A Dialog renders in a separate window, and the content behind it belongs to the activity's window — a different render node tree. A modifier inside the dialog cannot reach it. The platform gap is tracked upstream as Support window blur in compose dialogs.

Underlay covers that one case. For blur inside your own window use haze or Cloudy; the two combine, with Underlay drawing the backdrop and haze the live effect on top.

How it degrades

The backdrop comes from the best source the device offers:

Source Requires What happens
SystemBlur API 31+, own window FLAG_BLUR_BEHIND + blurBehindRadius. Live, on the GPU, free. Only the tint is drawn on top.
Snapshot API 23+, reachable host window One PixelCopy of the host window (decorView.draw() below API 26), downscaled ×4, then blurred: RenderEffect on the GPU from API 31, box passes on Dispatchers.Default below it.
Fallback Solid fallback color.

A capture takes a frame boundary, a PixelCopy and a blur. Until it lands the modifier reports a fourth value, Pending, and draws only the tint, so the overlay settles from clear host content into the blurred snapshot instead of flashing an opaque fallback.

The system switches cross-window blur off in battery saver. Underlay listens for that through WindowManager.addCrossWindowBlurEnabledListener, drops the window flag and falls through to the snapshot.

The snapshot uses PixelCopy wherever it exists. decorView.draw() into a software canvas throws Software rendering doesn't support hardware bitmaps as soon as an image loader has decoded anything on screen into one, which is the Coil and Glide default from API 26.

Windows

Underlay resolves two windows: the one the caller lives in, and the host window underneath.

Called from Own window Host window Best source
Dialog dialog window activity window SystemBlur
Popup, PopupWindow root view added through WindowManager activity window SystemBlur
Translucent or floating activity activity window none — it belongs to another task SystemBlur
Plain overlay inside the activity none none Fallback

Without an own window the host window is the caller's own window, so capturing it would fold the overlay into its own backdrop. That row draws fallback.

A translucent activity has no host window either, so it drops from SystemBlur straight to Fallback where a Dialog or a Popup would drop to Snapshot.

Reporting the active source

Modifier.blurredUnderlay(
    blurRadius = 16.dp,
    tint = Color.Black.copy(alpha = 0.3f),
    fallback = MaterialTheme.colorScheme.surface,
    onSourceChange = { source -> Log.d("underlay", "source: $source") },
)

UnderlaySource is SystemBlur, Snapshot, Pending or Fallback. The callback fires on every change, including the fall-through when the system turns cross-window blur off.

Refreshing the snapshot

Snapshot freezes the host window. To capture it again while the overlay is open, hand the modifier an UnderlayState:

val underlay = rememberUnderlayState()

Box(
    modifier = Modifier.blurredUnderlay(
        blurRadius = 16.dp,
        tint = Color.Black.copy(alpha = 0.3f),
        fallback = MaterialTheme.colorScheme.surface,
        state = underlay,
    ),
) {
    Button(onClick = { underlay.refresh() }) { Text("Refresh") }
}

The snapshot on screen stays until the new one is ready, so a refresh does not flash. SystemBlur blurs live and ignores the state.

Install

dependencies {
    implementation("com.timkrest:underlay:0.2.0")
}

minSdk 23, the floor Compose itself declares, and Java 11 bytecode. Dependencies: Compose runtime and UI, exposed as api, plus activity-compose, core-ktx, annotation and kotlinx-coroutines-core.

Compatibility

The public surface is recorded in underlay/api/underlay.api and checked on every build.

  • A patch release never breaks anything, in source or in binary.
  • Until 1.0.0 a minor release may, and says so in the changelog. CI refuses a release that drops a declaration without leaving the series it was published in.
  • Kotlin mangles every signature taking a Dp or a ColorblurredUnderlay-6Ivg_Sk. Adding a parameter renames it, so a release can keep your call sites compiling and still need a rebuild. A release that needs the call site itself edited says so in the changelog.

Trade-offs

  • The snapshot is frozen. It captures the host window once, again when that window changes size or configuration, and on UnderlayState.refresh(). Content moving underneath is not picked up on its own; for a popup over a scrolling list use an in-window library. A failed re-capture keeps the snapshot on screen, unless the host resized and the old frame no longer lines up — then the snapshot is dropped and only the tint is drawn until a capture succeeds, and fallback only if none does.
  • FLAG_BLUR_BEHIND is a window flag. SystemBlur blurs everything behind the overlay window; Snapshot blurs only what is behind the composable. Apply the modifier to a composable that fills the overlay window to get the same picture from both.
  • blurRadius is a Gaussian sigma, the meaning Figma and RenderEffect.createBlurEffect give it, taken on the ×4 downscale in Snapshot. On the CPU it becomes the box radius whose three passes land nearest, radius = (sqrt(4*sigma^2 + 1) - 1) / 2 rounded to the closer neighbour.
  • The host is always the activity window. Under a dialog opened over another dialog, Snapshot captures the activity, not the dialog in between. SystemBlur has no such seam.

Sample

:sample opens one overlay per window kind — Dialog, Popup, translucent activity, plain in-window overlay — over a grid of hardware bitmaps, reports the active source and lets you drag the radius. Under SystemBlur it points at Battery Saver, which triggers the fall-through to Snapshot on a real device.

./gradlew :sample:installDebug

Scope

Kotlin Multiplatform is not planned. The gap this library covers is Android's: on iOS Compose draws dialogs into the same view hierarchy, so there is no window boundary and UIVisualEffectView already covers blur. Desktop has the boundary but nothing in common with FLAG_BLUR_BEHIND or PixelCopy — a second implementation, not shared code. Only the box blur is portable.

License

Apache 2.0 — see LICENSE.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages