Blog Infos
Author
Published
Topics
, , , ,
Published
Gemini generated

Sometimes our apps need to highlight some UI components, e.g. during the first login (onboarding), or when we added something new (“what’s new”).

In this article I will guide on building a custom solution to show hints / tooltips, pointing to a particular UI element in Compose (Compose Multiplatform and Jetpack Compose).

Layout hierarchy

To highlight a UI element firstly we should get through the main concept of “hints”.

Let’s imagine we have an app with TopBarBottomNavigation, and a primary action button. We want to highlight a TopBar’s action, the primary button, and an item from BottomNavigation.

Layout hierarchy
For our “hints” we need to draw a dimmed background (also to to intercept touch events), calculate position of a highlighted UI elements, clip out (mask) our elements shape not to be dimmed, and finally draw a hint (e.g. text with background).
1. Dimmed background

To draw a dimmed background as overlay or popup on top of all content we might:

  1. Wrap all content of our app (root component) with a custom composable (e.g. Box with Modifier.background)
@Composable
fun AppContent() {
HintOverlay {
MaterialTheme {
Scaffold {
// My app content
}
}
}
}

2. Use Dialog

By using Dialog we can show an overlay on top of all content (e.g. on Android the Dialog is shown in its own Window).

With Dialog the problem comes for scrimColor in Compose Multiplatform. We can’t configure from Compose common target the scrimColor, but each target (except for Android) provides an actual value for the scrimColor. As a possible solution, we can create an excepted class to provide DialogProperties and provide actual implementation for each target.

3. Use Popup

Popup looks more better here because it does not draw a scrimColor by default, and the overlay is shown on top of all content.

I would go with the 3nd approach not to force to use HintOverlay manually.

I also want to add support of Brush for the overlay background, not only Color.

val LocalHintOverlayColor = staticCompositionLocalOf<Color> { Color(0x44000000) }
val LocalHintOverlayBrush = staticCompositionLocalOf<Brush?> { null }
@Composable
fun HintOverlay() {
Popup {
Box(
modifier = Modifier
.fillMaxSize()
.overlayBackground()
) {
Text(
modifier = Modifier.align(Alignment.Center),
text = "Draw hints here",
color = Color.White,
)
}
}
}
/**
* Set `background` either from [LocalHintOverlayBrush] or from [LocalHintOverlayColor].
*/
private fun Modifier.overlayBackground(): Modifier = composed {
LocalHintOverlayBrush.current?.let { background(it) }
?: background(LocalHintOverlayColor.current)
}
view raw HintOverlay.kt hosted with ❤ by GitHub

I can set the Brush to override the overlay’s background by using CompositionLocalProvider.

CompositionLocalProvider(
LocalHintOverlayBrush provides Brush.linearGradient(
listOf(
Color.Red.copy(alpha = 0.5f),
Color.Blue.copy(alpha = 0.5f),
)
),
) {
HintOverlay()
}
2. Calculate anchor coordinates

To get coordinates of an UI element in Compose we can use OnGloballyPositionedModifier, which is called with the final LayoutCoordinates of the Layout when the global position of the content may have changed.

Usage example:

Column(
Modifier.onGloballyPositioned { coordinates ->
// This will be the size of the Column.
coordinates.size
// The position of the Column relative to the application window.
coordinates.positionInWindow()
// The position of the Column relative to the Compose root.
coordinates.positionInRoot()
// These will be the alignment lines provided to the layout (empty here for Column).
coordinates.providedAlignmentLines
// This will be a LayoutCoordinates instance corresponding to the parent of Column.
coordinates.parentLayoutCoordinates
}
) {
Box(Modifier.size(20.dp).background(Color.Green))
Box(Modifier.size(20.dp).background(Color.Blue))
}

For our hints we need to create a state to hold coordinates and size of anchors, and introduce a Modifier to update the state:

@Stable
class HintAnchorState internal constructor() {
internal var size: IntSize by mutableStateOf(IntSize.Zero)
internal var offset: Offset by mutableStateOf(Offset.Zero)
}
@Composable
fun rememberHintAnchorState(): HintAnchorState {
return remember { HintAnchorState() }
}
fun Modifier.hintAnchor(state: HintAnchorState): Modifier {
return onGloballyPositioned {
state.size = it.size
state.offset = it.positionInWindow()
}
}

So, we just subscribed to size and coordinates changes of a desired UI element to update the anchor’s state.

Now we need to apply this hintAnchor modifier to our content:

val topAppBarActionHintAnchor = rememberHintAnchorState()
val actionHintAnchor = rememberHintAnchorState()
val bottomNavigationHintAnchor = rememberHintAnchorState()
IconButton(
modifier = Modifier
.hintAnchor(topAppBarActionHintAnchor),
onClick = {},
)
Button(
modifier = Modifier
.hintAnchor(actionHintAnchor)
.padding(4.dp),
onClick = {},
) {
Text("Action")
}
BottomNavigationItem(
modifier = Modifier
.hintAnchor(topAppBarActionHintAnchor),
//...pass other required params
)

Note: Modifier ordering always matter in Compose, we set 4.dp after hintAnchor to have extra space around this button (the anchor’s size will be bigger by 4.dp that the actual button’s size).

The HintOverlay composable requires some changes to use the HintAnchorState to draw hints for this anchor.

@Composable
fun HintOverlay(
anchors: () -> List<HintAnchorState>,
) {
Box(
modifier = Modifier
.fillMaxSize()
.overlayBackground(anchors)
)
}
/**
* Set `background` either from [LocalHintOverlayBrush] or from [LocalHintOverlayColor].
*/
private fun Modifier.overlayBackground(
anchors: () -> List<HintAnchorState>,
): Modifier = composed {
val backgroundBrush = LocalHintOverlayBrush.current
val backgroundColor = LocalHintOverlayColor.current
drawWithCache {
onDrawWithContent {
if (backgroundBrush != null) {
drawRect(backgroundBrush)
} else {
drawRect(backgroundColor)
}
anchors().forEach { anchor ->
drawRect(
color = Color.Red,
topLeft = anchor.offset,
size = anchor.size.toSize(),
style = Stroke(width = 5f),
)
}
drawContent()
}
}
}

For now we just draw a red rectangle around out anchors.

But if we run on mobile, we get wrong numbers on Android.

With WindowInsets

The issue is related to WindowInsets. Let’s substract these insets to fix it.

fun Modifier.hintAnchor(state: HintAnchorState): Modifier = composed {
val statusBarInsets = WindowInsets.statusBars.getTop(LocalDensity.current).toFloat()
onGloballyPositioned {
state.size = it.size
state.offset = it.positionInWindow()
// To fix WindowInsets on Android
.minus(Offset(x = 0f, y = statusBarInsets))
}
}
view raw HintAnchor.kt hosted with ❤ by GitHub

Fixed WindowInsets

3. Clip out anchor’s shape

To clip our a shape we will use Path and PathOperation.

Modify the hintAnchor Modifier to accept Shape, which will be used to set a desired shape around anchors.

fun Modifier.hintAnchor(
state: HintAnchorState,
shape: Shape = RectangleShape,
): Modifier {
state.shape = shape
//..onGloballyPositioned
}
@Stable
class HintAnchorState internal constructor() {
//...other states here
internal var shape: Shape by mutableStateOf(RectangleShape)
}
view raw HintAnchor.kt hosted with ❤ by GitHub

Based on the provided Shape we can create anOutline which will be used to clip the anchor’s shape out of the background.

internal fun Modifier.overlayBackground(
anchors: () -> List<HintAnchorState>,
): Modifier = composed {
val backgroundBrush = LocalHintOverlayBrush.current
val backgroundColor = LocalHintOverlayColor.current
val layoutDirection = LocalLayoutDirection.current
val density = LocalDensity.current
drawWithCache {
// Prepare path for background
val path = Path().apply {
lineTo(size.width, 0f)
lineTo(size.width, size.height)
lineTo(0f, size.height)
lineTo(0f, 0f)
close()
}
anchors().forEach { anchor ->
// Prepare path for the anchor
val anchorPath = Path()
anchorPath.addOutline(
anchor.shape.createOutline(
size = anchor.size.toSize(),
layoutDirection = layoutDirection,
density = density,
)
)
anchorPath.translate(anchor.offset)
anchorPath.close()
// Clip out the anchor
path.op(path, anchorPath, PathOperation.Xor)
}
onDrawWithContent {
// Not we just draw path and not a rect as previously
if (backgroundBrush != null) {
drawPath(path, backgroundBrush)
} else {
drawPath(path, backgroundColor)
}
drawContent()
}
}
}

Let’s pass CircleShape and RoundedCornerShape to see how the hints look now.

At this point we know how to draw a background overlay, calculate positions of our anchors, and how to clip out the background.

4. Draw hints

Before the actual drawing we should define what information hints need to present.

Not to force providing only a text, let’s go with “slot” approach. By defining a “slot” we allow to use any desired composable.

I will introduce a new class Hint to hold our Composable content.

@Stable
class Hint internal constructor() {
internal var content: @Composable () -> Unit by mutableStateOf({})
}
@Composable
fun rememberHint(content: @Composable () -> Unit): Hint {
return remember {
Hint().also { it.content = content }
}
}
view raw Hint.kt hosted with ❤ by GitHub

And will add this Hint to be part of HintAnchorState.

@Stable
class HintAnchorState internal constructor(
internal val hint: Hint,
) {
//...other states here
}
@Composable
fun rememberHintAnchorState(hint: Hint): HintAnchorState {
return remember(hint) {
HintAnchorState(hint)
}
}

Inside HintOverlay we might go with the simplest solution — BoxWithConstrains.

@Composable
fun HintOverlay(
anchors: () -> List<HintAnchorState>,
) {
//...
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.overlayBackground(anchors)
) {
anchors().forEach { anchor ->
Box(
modifier = Modifier
.graphicsLayer {
translationX = anchor.offset.x
translationY = anchor.offset.y + anchor.size.height
},
) {
anchor.hint.content()
}
}
}
}
view raw HintOverlay.kt hosted with ❤ by GitHub

Modify the app content.

val topAppBarHint = rememberHint {
OutlinedButton(onClick = {}) { Text("Hint for TopAppBar") }
}
val topAppBarActionHintAnchor = rememberHintAnchorState(topAppBarHint)
val actionHint = rememberHint {
Text("Hint for Action")
}
val actionHintAnchor = rememberHintAnchorState(actionHint)
val bottomNavigationHint = rememberHint {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Spacer(Modifier.size(32.dp).background(Color.Magenta, CircleShape))
Spacer(Modifier.size(8.dp))
Text("Hint for BottomNavigation")
}
}
val bottomNavigationHintAnchor = rememberHintAnchorState(bottomNavigationHint)
view raw App.kt hosted with ❤ by GitHub

The result will be following.

Let’s introduce an app specific code to draw backgrounds for out hints.

@Composable
fun rememberHintContainer(content: @Composable () -> Unit): Hint {
return rememberHint {
Box(
modifier = Modifier
.padding(top = 8.dp)
.background(Color.Yellow, shape = RoundedCornerShape(16.dp))
.padding(16.dp),
) {
CompositionLocalProvider(
LocalTextStyle provides TextStyle(
color = Color.Black,
fontSize = 12.sp,
fontWeight = FontWeight.Light,
),
) {
content()
}
}
}
}

We got 2 issues:

  1. Horizontal alignment, the hint should be center align to its anchor.
  2. Hint for BottomNavigation is out of the screen.

Let’s switch to a custom layout instead and fix those issues there.

To measure and layout multiple composables, use the Layout composable. This composable allows us to measure and lay out children manually. All higher-level layouts like Column and Row are built with the Layout composable.

@Composable
internal fun HintsContainer(
modifier: Modifier,
anchors: () -> List<HintAnchorState>,
) {
val anchors = anchors()
Layout(
modifier = modifier
.overlayBackground(anchors),
content = {
anchors.forEach { it.hint.content() }
},
) { measurables, constraints ->
// Measure each hint
val placeables = measurables.map { measurable ->
measurable.measure(
constraints.copy(minWidth = 0, minHeight = 0)
)
}
// Set the size of the layout as big as it can
layout(constraints.maxWidth, constraints.maxHeight) {
// Place each hint relatively to it's anchor
placeables.forEachIndexed { index, placeable ->
val anchor = anchors[index]
// Center align this hint
val x = (anchor.offset.x.toInt() - (placeable.width - anchor.size.width) / 2)
// Fix the coordinate if it's out of the screen
.coerceAtLeast(0)
.coerceAtMost(constraints.maxWidth - placeable.width)
// Put this hint below its anchor
var y = (anchor.offset.y.toInt() + anchor.size.height)
// Fix y-coordinate if it's out of the screen
.coerceAtMost(constraints.maxHeight - placeable.height)
if (y < anchor.offset.y + anchor.size.height) {
// Hint is be overlapping its anchor, put this hint above its anchor
y = anchor.offset.y.toInt() - placeable.height
}
placeable.placeRelative(x = x, y = y)
}
}
}
}

Because we allowed to pass any composable to be as a hint, the caller has all control on how the hints will look like, e.g. we can use just Text, or a complex Row with many children.

5. How to control hints?

We statically added our hints to show them on the screen. But it’s not the case for production apps. Let’s introduce HintController to control when we want to show the hints.

@Stable
class HintController internal constructor() {
internal var hint by mutableStateOf<HintAnchorState?>(null)
fun show(hint: HintAnchorState) {
this.hint = hint
}
}
@Composable
fun rememberHintController(): HintController {
val controller = remember { HintController() }
controller.hint?.let { hint ->
HintOverlay(anchor = hint)
}
return controller
}

Modify the app content to show hints when we click on anchors.

val hintController = rememberHintController()
IconButton(
modifier = Modifier
.hintAnchor(topAppBarActionHintAnchor, CircleShape),
onClick = {
hintController.show(topAppBarActionHintAnchor)
},
)
BottomNavigationItem(
modifier = Modifier
.hintAnchor(
bottomNavigationHintAnchor,
shape = RoundedCornerShape(50f),
),
onClick = {
hintController.show(bottomNavigationHintAnchor)
},
)
Button(
modifier = Modifier
.hintAnchor(actionHintAnchor, RoundedCornerShape(16.dp))
.padding(4.dp),
onClick = {
hintController.show(actionHintAnchor)
},
)
view raw App.kt hosted with ❤ by GitHub

Note: we no longed need to HintOverlay, it becomes internal now.

Now we can show hints one by one, but there are 2 missings parts: how to dismiss hints, and how to override the overlay color.

Make changes to HintController to allow passing the overlay color.

@Composable
fun rememberHintController(overlay: Brush): HintController {
return rememberHintController(overlay = LocalHintOverlayBrush provides overlay)
}
@Composable
fun rememberHintController(overlay: Color = HintOverlayColorDefault): HintController {
return rememberHintController(overlay = LocalHintOverlayColor provides overlay)
}
@Composable
private fun rememberHintController(overlay: ProvidedValue<*>): HintController {
val controller = remember { HintController() }
controller.hint?.let { hint ->
CompositionLocalProvider(overlay) {
HintOverlay(anchor = hint)
}
}
return controller
}

To allow dismissing our hints let’s introduce following changes.

@Stable
class HintController internal constructor() {
internal var hint by mutableStateOf<HintAnchorState?>(null)
fun show(hint: HintAnchorState) {
this.hint = hint
}
fun dismiss() {
hint = null
}
}

We used Popup as a container for our overlay, and the Popup can be dismissed if users click back button on Android.

@Composable
internal fun HintOverlay(
anchor: HintAnchorState,
onDismiss: () -> Unit,
) {
Popup(
onDismissRequest = onDismiss,
// Set focusable to handle back press events
properties = remember { PopupProperties(focusable = true) },
) {
//...draw our hints here
}
}
@Composable
internal fun HintsContainer(
modifier: Modifier,
anchor: HintAnchorState,
onDismiss: () -> Unit,
) {
Layout(
modifier = modifier
.overlayBackground(anchor)
.clickable(
interactionSource = null,
// Disable ripple
indication = null,
onClick = onDismiss,
)
)
}
view raw HintOverlay.kt hosted with ❤ by GitHub

Now the HintController allows us to show one hint by time, but there is no actual queue in case we want to show many hints sequentially.

Extend the HintController and add suspend modifier to know when a hint was shown (e.g. do something right after it’s shown).

@Stable
class HintController internal constructor() {
private var queue = mutableStateListOf<HintAnchorState>()
internal val hint: HintAnchorState? get() = queue.firstOrNull()
private val pendingRequests = mutableMapOf<HintAnchorState, Continuation<Unit>>()
suspend fun show(hint: HintAnchorState) {
suspendCoroutine { continuation ->
pendingRequests[hint] = continuation
queue.add(hint)
}
}
suspend fun show(vararg hint: HintAnchorState) {
show(hint.toList())
}
suspend fun show(hints: List<HintAnchorState>) {
suspendCoroutine { continuation ->
pendingRequests[hints.last()] = continuation
queue.addAll(hints)
}
}
internal fun onDismissed(hint: HintAnchorState) {
pendingRequests[hint]?.let { continuation ->
continuation.resume(Unit)
pendingRequests.remove(hint)
}
queue.remove(hint)
}
fun dismiss() {
pendingRequests.values
.forEach { continuation ->
continuation.resumeWithException(CancellationException("Hint was dismissed"))
}
pendingRequests.clear()
queue.clear()
}
}

Now to show a hint inside app we need to have a CoroutineScope.

val coroutineScope = rememberCoroutineScope()
val hintController = rememberHintController()
// Now we can dismiss all pending hints from a hint itself
val topAppBarHint = rememberHintContainer {
OutlinedButton(
onClick = {
hintController.dismiss()
}
) { Text("Hint for TopAppBar") }
}
// Show 1 hint
BottomNavigationItem(
onClick = {
coroutineScope.launch {
hintController.show(bottomNavigationHintAnchor)
scaffoldState.snackbarHostState.showSnackbar("One hint was shown")
}
},
)
// Show many hints sequentially
Button(
onClick = {
coroutineScope.launch {
hintController.show(
topAppBarActionHintAnchor,
actionHintAnchor,
bottomNavigationHintAnchor,
)
scaffoldState.snackbarHostState.showSnackbar("Many hints were shown")
}
},
)
view raw App.kt hosted with ❤ by GitHub

Note: if we dismiss a hint by calling hintController.dismiss(), code after hintController.show will not be called.

coroutineScope.launch {
    hintController.show(topAppBarActionHintAnchor)
    // Snackbar will not be shown if the 
    // previous hint was dismissed by calling hintController.dismiss
    scaffoldState.snackbarHostState.showSnackbar("One hint was shown")
}

The final result is following: we can show a single hint, and we can show a list of hints.

Desktop

Since the project is using Compose Multiplatform, we can run the app for different targets.

Android

 

iOS

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Cutting-Edge-to-Edge in Android 15: Using Previews and Testing in Jetpack Compose to Manage Insets.

With the advent of Android 15, edge-to-edge design has become the default configuration. Consequently, applications must be capable of accommodating window insets, including the system status bar and navigation bar, as well as supporting drawing…
Watch Video

Cutting-Edge-to-Edge in Android 15: Using Previews and Testing in Jetpack Compose to Manage Insets.

Timo Drick
Lead Android developer
Seven Principles Mobility GmbH

Cutting-Edge-to-Edge in Android 15: Using Previews and Testing in Jetpack Compose to Manage Insets.

Timo Drick
Lead Android develop ...
Seven Principles Mob ...

Cutting-Edge-to-Edge in Android 15: Using Previews and Testing in Jetpack Compose to Manage Insets.

Timo Drick
Lead Android developer
Seven Principles Mobility ...

Jobs

No results found.

Summary

Compose and Kotlin Multiplatform is a powerful combination which allows us to use Kotlin for UI and for business logic. CMP libraries are fully compatible with Jetpack Compose Android projects only.

Check out my library on GitHub:

Thank you for reading, looking forward to getting your stars on GitHub 🙂

This article is previously published on proandroiddev.com.

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
READ MORE
blog

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE
Menu