Balloon 2.0.0: Tooltips That Run on Compose Multiplatform
Balloon 2.0.0: Tooltips That Run on Compose Multiplatform
![]()
Back in 2018, Balloon started as an Android tooltip library built on PopupWindow, a FrameLayout, and a stack of XML animations. That foundation is what made it work well on Android and what made it impossible to run anywhere else, because every one of those pieces is android. something. Version 2.0.0 replaces the whole thing with a Compose Multiplatform implementation that ships as a single artifact for Android, iOS, Desktop, and Web. The public API kept the names it had, so a builder block from 1.x mostly still reads the same, but nothing underneath survived.
In this article, you'll dive deep into what that rewrite actually required: why a tooltip is mostly a windowing problem, why the overlay scrim cannot live in the same window as the balloon, the box model that keeps the arrow landing on the same pixels it used to, how placement flips instead of sliding, and how a rewrite gets verified against the implementation it replaces.
The fundamental problem: A tooltip is mostly a window
A tooltip looks like a small drawing task. Draw a rounded rectangle, add a triangle on one edge, put some content inside. The drawing is the easy part. What makes a tooltip hard is that it has to escape its parent.
Consider what happens if you render a balloon as an ordinary sibling of its anchor:
Column(modifier = Modifier.height(44.dp)) {
Button(onClick = { }) { Text("Edit profile") }
if (visible) {
TooltipBody()
}
}
The tooltip is now clipped by the Column, sized by the Column's constraints, and it pushes the button around when it appears. A 44dp tall parent gives you a 44dp tall tooltip. It also cannot draw over anything outside its parent, which is the one thing a tooltip exists to do.
Every real tooltip therefore renders in a separate window, and once you are in a separate window, four problems arrive at once. You have to compute where that window goes relative to an anchor that lives in a different window. You have to decide what happens when it does not fit. You have to route touches between two windows. And if you want to dim the screen behind it, you need yet another surface that covers everything, including the parts of the screen your window is not allowed to reach.
The 1.x implementation solved all four with Android APIs. PopupWindow.showAsDropDown did placement, a second PopupWindow did the overlay, FLAG_NOT_TOUCH_MODAL did touch routing, and ViewOutlineProvider did the shape. Compose Multiplatform gives you Popup, a PopupPositionProvider, and a Shape, and the rest has to be built.
What the API looks like now
The entry point is a style, a state, and a composable that wraps the anchor:
val style = rememberBalloonBuilder {
setArrowSize(10.dp)
setPadding(12.dp)
setCornerRadius(8.dp)
setBackgroundColor(Color(0xFF785EF0))
setBalloonAnimation(BalloonAnimation.ELASTIC)
}
val balloonState = rememberBalloonState(style)
Balloon(
state = balloonState,
balloonContent = { Text(text = "Now you can edit your profile", color = Color.White) },
) {
Button(onClick = { balloonState.showAlignTop() }) {
Text(text = "Edit profile")
}
}
The builder is the same fluent object 1.x had, with 57 setters carrying the names and defaults they carried before. What changed is the content. There is no setText, no TextForm, no IconForm, and no setLayout taking a layout resource, because none of those mean anything outside Android. The body is a composable slot, so a balloon holding a row of icons and a dismiss button is just a Row and a Button.
BalloonState owns visibility and placement. It knows its own anchor, so showAlignTop() takes no arguments, where 1.x needed the View handed back to it on every call. There is a show call for each side, plus showAsDropDown for leading edge alignment and showAtCenter for pointing at the anchor's centre.
showAlignTop | showAlignBottom | showAlignStart | showAlignEnd |
|---|---|---|---|
![]() | ![]() | ![]() | ![]() |
Two ways to attach a balloon
Wrapping an anchor changes its layout position in some containers, so there is a second form that decorates in place:
Icon(
imageVector = Icons.Default.Info,
contentDescription = null,
modifier = Modifier.balloon(
state = balloonState,
balloonContent = { Text("This is what the icon means") },
),
)
Modifier.balloon registers the anchor with the nearest host rather than adding a layout node around it. Both forms produce the same balloon.
Why a balloon needs a host
The one piece of structure 2.0.0 asks for that 1.x did not is BalloonHost:
BalloonHost {
// your screen
}
The reason is the overlay. setIsVisibleOverlay(true) dims the screen and cuts the anchor out of the dimmed area, which is how you build a spotlight tour. The cut out follows a shape you choose, so the highlight can be a rectangle, an oval, a circle, or a rounded rectangle with per corner radii.
| Rectangle | Oval | Circle | Rounded rect |
|---|---|---|---|
![]() | ![]() | ![]() | ![]() |
That scrim has to cover the entire window, including the status bar and the navigation bar, and a Popup cannot do that. A popup window is sized to its content and positioned inside the area the platform gives it.
So the scrim is drawn by the host, in the application's own window, underneath the balloon's popup. Every balloon that wants an overlay registers a request with the nearest host, and the host draws the scrims for all of them:
val registry = LocalBalloonRegistry.current
if (style.isVisibleOverlay) {
val request = remember(state) { BalloonOverlayRequest(state) }
request.anchorBounds = anchorBounds
DisposableEffect(registry, request) {
registry.registerOverlay(request)
onDispose { registry.unregisterOverlay(request) }
}
}
The cut out is a blend mode operation rather than a second shape. The host fills its whole area with the overlay colour, then clears the anchor's shape out of it with BlendMode.Clear inside a layer using CompositingStrategy.Offscreen. Clearing rather than masking is what lets setOverlayPaddingColor paint a ring in the gap the padding opens up: fill the padded shape, then clear the unpadded one, and what remains is the band between them.
The box model that keeps the geometry honest
The part of the rewrite that took the most care is not the drawing, it is the arithmetic around it. A balloon in 1.x was a PopupWindow whose width and height included margins and a shadow inset, and every size setter measured against that outer box. setWidthRatio(0.5f) meant the window was half the screen, not the card.
2.0.0 keeps that model exactly, because changing it would silently resize every ported balloon. The popup box is the margin, plus a reserve, plus the card, and the card is the padding plus your content.
reserve is the space around the visible card that is inside the popup but not part of the card. It has three parts: the arrow protrusion on the side the arrow sits on, the same amount or the elevation inset on the opposite side, and the elevation inset on both cross sides.
The protrusion is where the port gets specific:
internal fun arrowProtrusionPx(arrowHeightPx: Float): Float =
(arrowHeightPx - ARROW_BOUNDARY_PX).coerceAtLeast(0f)
ARROW_BOUNDARY_PX is 1f. The View implementation sank the arrow one pixel into the card so no seam showed between the triangle and the body, which means a 12dp arrow protrudes 12dp minus one pixel, not 12dp. That single pixel is the difference between a ported layout matching and a ported layout drifting by a pixel on every balloon.
The card itself is one Shape producing an Outline.Generic, so the rounded rectangle and the arrow are a single path rather than a background plus an overlaid triangle:
path.lineTo(rectRight - radius, rectTop)
path.quadraticTo(rectRight, rectTop, rectRight, rectTop + radius)
path.lineTo(arrowCenterX + halfArrow, rectTop)
path.lineTo(arrowCenterX, tipY)
path.lineTo(arrowCenterX - halfArrow, rectTop)
Building one path matters for two reasons. Modifier.border strokes the outline, so the border follows the arrow instead of stopping at the body. And Modifier.clip clips content to it, so a full bleed body cannot paint square corners over a rounded background. That last behavior was a real 1.x bug, where a custom layout needed setIsClipArrowEnabled(true) to get its corners back.
There is one place the port deliberately does not match. elevation reserves its space and drives the width math, but no shadow is drawn. Compose can only cast a shadow from a convex outline, and a rectangle with a triangle stuck to one edge is not convex. If you want one, Modifier.shadow inside the slot draws it on the body.
Placement: flipping instead of sliding
Placement runs in a PopupPositionProvider, which Compose calls with the anchor bounds, the window size, and the measured popup size. The interesting part is not the happy path, it is what happens when the balloon does not fit.
1.x inherited PopupWindow behavior here. Vertically it flipped, because showAsDropDown flips. Horizontally it slid the balloon along the window edge until it stopped overflowing, which regularly left the balloon sitting on top of the anchor it was pointing at.
2.0.0 flips on both axes. If the requested side has no room and the opposite side does, the balloon moves there and the arrow moves with it, unless ArrowOrientationRules.ALIGN_FIXED pins the arrow where you put it. The room test includes the caller's offset, so a balloon pushed down by yOffset flips based on the space it will actually need rather than the space it would have needed unshifted.
After the flip, and after the final clamp that keeps the popup on screen, the arrow is re-anchored against where the balloon actually landed. This is the part that makes ArrowPositionRules.ALIGN_ANCHOR work: the arrow keeps pointing at the anchor even though the body has been pushed sideways.
That resolved position has to travel from the layout pass, where the provider runs, back to composition, where the shape is built. It lives in a small holder owned by the popup layer:
@Stable
internal class BalloonArrowPlacement {
var orientation: ArrowOrientation? by mutableStateOf(null)
var centerPx: Float? by mutableStateOf(null)
}
Owning it per layer rather than per state is not a detail. When it lived on BalloonState, two anchors sharing one state wrote conflicting values into the same holder and invalidated each other on every frame. Composition never went idle and the app hung. One holder per popup makes that structurally impossible.
The style is a value, the listeners are not
BalloonStyle is an immutable data class with 43 properties, and it is value equal on purpose. Two identical styles compare equal, so a restyle is cheap to detect and rememberBalloonState can re-apply the style on every recomposition without hiding and reshowing the balloon. That is what makes an animated style work:
val color by animateColorAsState(if (selected) Color(0xFF785EF0) else Color(0xFF444444))
val balloonState = rememberBalloonState(style.derive { setBackgroundColor(color) })
Value equality is also why the listeners are not on the style. A lambda breaks structural equality, so onBalloonClick, onDismiss, and onOverlayClick are properties on BalloonState instead of setters on the builder.
derive is worth a note. A data class gives you copy for free, and copy on a class with 43 properties writes all 43 of them into the published binary interface. Adding a 44th option in any later 2.x release would then break every caller compiled against 2.0.0. So the constructor and copy are internal, and derive takes the same builder block rememberBalloonBuilder takes, starting from an existing style rather than the defaults. It has one lambda parameter and never has to change shape.
Verifying a rewrite against the thing it replaces
Rewriting a library that people already depend on has an obvious failure mode: it compiles, it looks right in the demo, and it moves everyone's tooltip by four pixels.
So the two implementations were rendered side by side and diffed. Both demo apps got a screen that renders exactly one configuration at a time, driven by an intent extra, with the anchor and the balloon body painted in sentinel colours rather than text. Text would have dragged font metrics into every comparison, and the AndroidX and JetBrains Compose runtimes measure text slightly differently. With flat colours, the card rectangle, the arrow triangle, the content rectangle, the border, and the overlay cut out can each be recovered from a screenshot by classifying every pixel to its nearest sentinel.
89 configurations covering alignment, corner radius, arrow size and position under both position rules, padding, margin, every width and height spec, offsets, window edges, borders, alpha, and the overlay shapes were captured on the same emulator from both stacks and compared numerically. The remaining differences are the deliberate ones described above, each one written down with a reason.
That harness answers "does this match 1.7.6", which stops being the question once 2.0.0 ships. So a second suite answers the one that replaces it. 212 golden screenshots render through runSkikoComposeUiTest at a fixed scene size and density, and compare against stored PNGs. Anything that changes what the library draws fails there with a pixel count, a bounding box, and a written diff image.
Writing those cases taught something worth repeating. A golden that varies only a setter, with no thought about geometry, can be a test that cannot fail. The ALIGN_ANCHOR padding band only takes effect when the arrow wants to sit near one end of the card, so at the default arrow position of 0.5f every value of setArrowAlignAnchorPadding rendered an identical image. Nine cases, all green, all proving nothing. The fix was to choose an arrow position inside the band the clamp actually governs.
Supported targets
One artifact, and Gradle picks the variant:
kotlin {
sourceSets {
commonMain.dependencies {
implementation("com.github.skydoves:balloon:2.0.0")
}
}
}
The published set is balloon-android, balloon-desktop, balloon-iosarm64, balloon-iossimulatorarm64, balloon-iosx64, and balloon-wasm-js. On Android it is also just a dependency, with no Compose Multiplatform setup required.
Almost all of the implementation is common code. The only platform specific file is the one that builds PopupProperties, and it exists because the two families disagree about coordinates. On Android, clipping is disabled so the framework reports full window bounds rather than the display frame that excludes system bars, which is the space boundsInWindow measures in. On the Skia targets, usePlatformInsets is disabled for the same reason: the provider positions in raw window coordinates, and skiko would otherwise re-add the insets.
Migrating from 1.x
The View implementation is not going anywhere. It stays published at 1.7.6, it still works, and 2.0.0 does not replace it in place. They are different APIs under the same coordinates, so pin the version you want.
For anyone moving over, the migration guide maps every 1.x setter to its counterpart, including the ones that were dropped and what replaces them. The short version is that builder blocks port with small edits, content becomes a composable, listeners move from the builder to the state, and the show calls stop taking a
View.
Conclusion
If you are picking this up, the habit worth forming early is to reach for the composable slot instead of looking for the setter that used to configure text or icons. Most of the friction in porting a 1.x balloon comes from hunting for setTextForm and finding nothing, when the answer is that the body is now yours to build and the library only owns the box around it. Wrap your screen in a host once, keep one state per balloon, and treat the style as a value you can derive variants from rather than an object you mutate.
What I took away from the rewrite itself is how much of a tooltip turns out to be arithmetic rather than drawing. The path is thirty lines. The parts that took real care were the one pixel the arrow sinks into the card, deciding whether to flip or clamp when nothing fits, and getting a number computed during layout back into a shape built during composition without two balloons deadlocking over it. Rendering a rounded rectangle with a triangle on it is a graphics exercise. Making it land on the same pixels as the implementation it replaces, on four platforms, is a measurement exercise.
As always, happy coding!
— Jaewoong (skydoves)









