Overlay¶
An overlay dims the whole window and cuts the anchor out of it. That is how you build a spotlight style onboarding tour.
Basic usage¶
val style = rememberBalloonBuilder {
setIsVisibleOverlay(true)
setOverlayColor(Color(0x99000000))
setOverlayPadding(6.dp)
setOverlayShape(BalloonOverlayShape.Oval)
}
An overlay needs a BalloonHost
The scrim is drawn by BalloonHost, not by a popup. A Compose Popup cannot do the job
on Android: its layout params are wrap content and the composition root clamps the
reported size to a measure spec that excludes the navigation bar, so a popup scrim would
stop short of the bottom of the screen.
BalloonHost(modifier = Modifier.fillMaxSize()) {
// Balloon(...) and Modifier.balloon(...) anchors go here
}
A balloon with setIsVisibleOverlay(true) outside a host throws an
IllegalStateException that says exactly this.
The scrim covers the host, not the window
The scrim fills the host's Box and is composited offscreen so the cut-out can erase
from it, which means it cannot paint outside those bounds. Balloon 1.x always dimmed the
whole display, because its scrim had a MATCH_PARENT window of its own.
To get the same result, put BalloonHost at the root of an edge-to-edge window with
Modifier.fillMaxSize(). Wrapping only a subtree dims only that subtree, which is
sometimes what you want and is worth knowing either way.
Shapes¶
setOverlayShape(BalloonOverlayShape.Empty) // no cut-out, dim everything
setOverlayShape(BalloonOverlayShape.Rect) // the anchor's bounds
setOverlayShape(BalloonOverlayShape.Oval) // inscribed in the bounds, default
setOverlayShape(BalloonOverlayShape.Circle(radius = 40.dp))
setOverlayShape(BalloonOverlayShape.RoundRect(radiusX = 12.dp, radiusY = 12.dp))
setOverlayShape(
BalloonOverlayShape.RoundRectPerCorner(
topStart = 16.dp,
topEnd = 4.dp,
bottomEnd = 16.dp,
bottomStart = 4.dp,
),
)
Circle with no radius uses half of the anchor's longer side. Balloon 1.x had no default
here at all: its radius was mandatory, and a circle built without one drew nothing.
RoundRectPerCorner is the counterpart of 1.x's four-argument BalloonOverlayRoundRect. Its
corners are start relative, so they mirror under a right to left layout.
Radii are Dp, not pixels
Circle and RoundRect took raw pixels in 1.x and take Dp here. Passing the same
number gives a different result, so convert deliberately when porting.
Padding¶
Extra space added around the anchor before the shape is cut:
setOverlayPadding(6.dp)
setOverlayPadding(start = 4.dp, top = 10.dp, end = 20.dp, bottom = 30.dp)
That band is transparent by default, like the rest of the cut-out. Fill it to draw a highlight ring around the anchor:
setOverlayPadding(6.dp)
setOverlayPaddingColor(Color(0xFFFFC107))
Animation¶
setBalloonOverlayAnimation(BalloonOverlayAnimation.FADE) // default, 200ms linear
setBalloonOverlayAnimation(BalloonOverlayAnimation.NONE)
Interaction¶
The scrim is modal. A tap on it never falls through to the content underneath.
setDismissWhenOverlayClicked(true) // default
balloonState.onOverlayClick = { analytics.log("tour_scrim_tapped") }
onOverlayClick fires before any dismissal, so it runs whether or not
dismissWhenOverlayClicked is on.
Building a tour¶
Combine an overlay with the suspend show calls and a tour is a handful of lines:
BalloonHost {
val tourStyle = rememberBalloonBuilder {
setIsVisibleOverlay(true)
setOverlayColor(Color(0xCC000000))
setOverlayPadding(8.dp)
setOverlayShape(BalloonOverlayShape.RoundRect(12.dp))
setBackgroundColor(Color.White)
setPadding(12.dp)
setCornerRadius(8.dp)
}
val step1 = rememberBalloonState(tourStyle)
val step2 = rememberBalloonState(tourStyle)
LaunchedEffect(Unit) {
step1.awaitAlignBottom()
step2.awaitAlignTop()
}
Column {
Button(modifier = Modifier.balloon(step1) { Text("Start here") }, onClick = {}) {
Text("Home")
}
Button(modifier = Modifier.balloon(step2) { Text("Then here") }, onClick = {}) {
Text("Profile")
}
}
}