Blog Infos
Author
Published
Topics
, , , ,
Published

Photo by Federica Galli on Unsplash

Introduction

Tooltips are one of the best ways to improve user experience in modern mobile development. As the product evolves, new features will be introduced, some of which might be visible on the home screen or tucked away in the nested screens. Additionally, hidden features like long clicks, swipe gestures, etc to perform certain actions that users won’t be aware of unless informed.

The better approach would be to catch the user’s attention with a simple yet well-designed informative user interface. There are many solutions to this like introducing a What’s New Screen, etc. But tooltips are the best approach for this because of three reasons:

  1. Focused Attention: Tooltips capture the user’s attention by highlighting a specific component and dimming the rest of the screen, ensuring that the user is immediately drawn to the feature being explained.
  2. Contextual Guidance: Tooltips help users quickly understand the placement and purpose of key entry points by highlighting the relevant component.
  3. Rich Content Delivery: Tooltips can incorporate text, images, and even animations, providing a comprehensive explanation of features without overwhelming the user.

Now let’s move to the technical part of the implementation. We’ll use Modifier in Compose to implement tooltips, because it’s easy to draw shapes, overlays, etc, and integration in the feature-level composables will be concise.

Design The Tooltip

Let’s create a tooltip extension function on the compose modifier with the return type as the Modifier to apply at the feature level. This will be the entry point to show the tooltip from the feature-level composables. Have a look:

@Composable
fun Modifier.tooltip(

): Modifier {

  return this
}

Let’s start with the design, for the sake of simplicity, we’ll have a title, and description, nothing fancy just a regular tooltip design. For this, we need a few parameters such as title, description, max width, text alignment, arrow alignment, and dismiss listener, have a look:

@Composable
fun Modifier.tooltips(
    title: String? = null,
    text: String,
    arrowAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
    textAlign: TextAlign = TextAlign.Center,
    maxWidth: Dp = Dp.Unspecified,
    onDismiss: () -> Unit = {}
): Modifier {

  return this
}

Now let’s design the tooltip view, let’s name it as ArrowTooltip. Have a look at the implementation:

@Composable
fun ArrowTooltip(
    modifier: Modifier = Modifier,
    title: String? = null,
    text: String,
    arrowAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
    textAlign: TextAlign = TextAlign.Center,
) {

    Column(
        modifier = modifier,
        horizontalAlignment = arrowAlignment,
    ) {
        Icon(
            modifier = Modifier
                .padding(
                    start = if (arrowAlignment != Alignment.CenterHorizontally) 6.dp else 0.dp,
                    end = if (arrowAlignment != Alignment.CenterHorizontally) 6.dp else 0.dp
                ),
            painter = painterResource(id = R.drawable.ic_arrow_up),
            contentDescription = "Tool tip Arrow",
            tint = MaterialTheme.colorScheme.surfaceContainer,
        )

        Column(
            modifier = Modifier
                .clip(RoundedCornerShape(8.dp))
                .background(MaterialTheme.colorScheme.surfaceContainer)
                .padding(16.dp),
        ) {
            Text(
                modifier = Modifier
                    .fillMaxWidth(),
                text = title ?: "",
                color = MaterialTheme.colorScheme.primary,
                textAlign = textAlign,
                style = MaterialTheme.typography.bodyMedium.copy(
                    fontWeight = FontWeight.Medium
                )
            )
            Spacer(modifier = Modifier.height(4.dp))
            Text(
                modifier = Modifier
                    .fillMaxWidth(),
                text = text,
                textAlign = textAlign,
                style = MaterialTheme.typography.bodySmall
            )
        }
    }
}

Let’s be good developers and write the preview function to see the output. Have a look:

@Composable
@Preview
fun previewArrowTooltip() {
    ArrowTooltip(
        title = "Long Press to Edit",
        text = "You can edit and delete the category by long press of any item in the list."
    )
}

tooltip preview

Of course we can add a cross icons, images, or pretty much anything based on the requirement or to match the application theme. But remember to keep it simple and informative as a thumb rule.

Now we need to invoke ArrowTooltip in tooltip modifier extension. But remember tooltips are not just another view in the screen hierarchy, they have to be elevated over the normal view hierarchy and should be able to dismiss on touch. What better way to achieve it than to use the Compose Popup.

As we all know in compose every UI update is based on the state. So we need to introduce another boolean parameter in tooltip, based on which we’ll show/hide the visibility of the tool tip popup. Also, add the padding values to align the tooltip with the screen padding. Have a look:

@Composable
fun Modifier.tooltips(
    title: String? = null,
    text: String,
    arrowAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
    textAlign: TextAlign = TextAlign.Center,
    maxWidth: Dp = Dp.Unspecified,
    enabled: Boolean = true,
    paddingValues: PaddingValues = PaddingValues(),
    onDismiss: () -> Unit = {}
): Modifier {

    if (enabled) {
        Popup(
            alignment = Alignment.TopEnd
        ) {
            Box(
                modifier = Modifier
                    .fillMaxSize()
                    .clickable(
                        onClick = {
                            onDismiss()
                        }
                    )
            ) {
                ArrowTooltip(
                    modifier = Modifier
                        .widthIn(max = maxWidth)
                        .padding(paddingValues),
                    title = title,
                    text = text,
                    arrowAlignment = arrowAlignment,
                    textAlign = textAlign
                )
            }
        }
    }

    return this
}

We’re invoking onDismiss if the user clicks anywhere on the popup.

Draw Overlay And Highlight The Component

Now that we’re done with designing the tooltip and displaying it via popup, it’s time to draw and overlay between the screen view hierarchy and the popup so that it’ll be visible on top of the screen then highlight the view in which the tooltip modifier was invoked.

This is where it gets interesting, let’s start with the parameters we need to add to the tooltip extension.

  • showOverlay: Flag to determine if we need to show the overlay behind the popup or now.
  • highlightComponent: Flag to determine if the component to which we are pointing the tooltip is to be highlighted or not.

Have a look at the function signature after adding these parameters.

@Composable
fun Modifier.tooltip(
    title: String? = null,
    text: String,
    arrowAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
    textAlign: TextAlign = TextAlign.Center,
    maxWidth: Dp = Dp.Unspecified,
    enabled: Boolean = true,
    paddingValues: PaddingValues = PaddingValues(),
    horizontalAlignment: Alignment.Horizontal = Alignment.Start,
    verticalAlignment: Alignment.Vertical = Alignment.Top,
    showOverlay: Boolean = true,
    highlightComponent: Boolean = true,
    onDismiss: () -> Unit = {}
)

Now the crucial part of overlay draw and highlighting the component. To keep the code organized, let’s create a modifier extension drawOverlayBackground. This function requires a few parameters which are pretty much self-explanatory, have a look:

private fun Modifier.drawOverlayBackground(
    showOverlay: Boolean,
    highlightComponent: Boolean,
    positionInRoot: IntOffset,
    componentSize: IntSize,
    backgroundColor: Color,
    backgroundAlpha: Float
) : Modifier

positionInRoot and componentSize are two parameters that we need to discuss.

  • positionInRoot is the IntOffset that determines the position of the component in the view hierarchy.
  • componentSize The size of the composable to which we are applying the tooltip.

Have a look at the core compose logic to draw the background and highlight the component based on the inputs:

private fun Modifier.drawOverlayBackground(
    showOverlay: Boolean,
    highlightComponent: Boolean,
    positionInRoot: IntOffset,
    componentSize: IntSize,
    backgroundColor: Color,
    backgroundAlpha: Float
) : Modifier {

    return if (showOverlay) {
        if (highlightComponent) {
            drawBehind {
                val highlightPath = Path().apply {
                    addRect(Rect(positionInRoot.toOffset(), componentSize.toSize()))
                }
                clipPath(highlightPath, clipOp = ClipOp.Difference) {
                    drawRect(SolidColor(backgroundColor.copy(alpha = backgroundAlpha)))
                }
            }
        } else {
            background(backgroundColor.copy(alpha = backgroundAlpha))
        }
    } else {
        this
    }
}

Now we need to use this in the tooltip extension. We can determine the value of positionInRoot and componentSize via onPlaced modifier extension. onPlaced will be invoked after the parent layout is successfully placed in the view hierarchy with the information of its placement and size. Have a look:

@Composable
fun Modifier.tooltips(
    title: String? = null,
    text: String,
    arrowAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
    textAlign: TextAlign = TextAlign.Center,
    maxWidth: Dp = Dp.Unspecified,
    enabled: Boolean = true,
    paddingValues: PaddingValues = PaddingValues(),
    showOverlay: Boolean = true,
    highlightComponent: Boolean = true,
    onDismiss: () -> Unit = {}
): Modifier {

    var positionInRoot by remember { mutableStateOf(IntOffset.Zero) }
    var componentSize by remember { mutableStateOf(IntSize(0, 0)) }

    if (enabled) {
        Popup(
            alignment = Alignment.TopEnd
        ) {
            Box(
                modifier = Modifier
                    .fillMaxSize()
                    .drawOverlayBackground(
                        showOverlay = showOverlay,
                        highlightComponent = highlightComponent,
                        positionInRoot = positionInRoot,
                        componentSize = componentSize,
                        backgroundColor = MaterialTheme.colorScheme.surfaceContainerLowest,
                        backgroundAlpha = 0.8f
                    )
                    .clickable(
                        onClick = {
                            onDismiss()
                        }
                    )
            ) {
                ArrowTooltip(
                    modifier = Modifier
                        .widthIn(max = maxWidth)
                        .padding(paddingValues),
                    title = title,
                    text = text,
                    arrowAlignment = arrowAlignment,
                    textAlign = textAlign
                )
            }
        }
    }

    return this then Modifier.onPlaced {
        componentSize = it.size
        positionInRoot = it.positionInRoot().round()
    }
}

As positionInRoot and componentSize are the states once they are updated from the onPlaced extension the background will be drawn without any delay.

Tooltip Placement

This is the final part of the core logic of designing the tooltip. The logic we’ll focus on is tooltip placement on the popup. We need to add two more parameters to tooltip:

  • horizontalAlignment: To determine the horizontal alignment of the popup on the screen.
  • verticalAlignment: To determine the vertical alignment of the popup on the screen.

Have a look at the final signature of the function:

@Composable
fun Modifier.tooltip(
    title: String? = null,
    text: String,
    arrowAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
    textAlign: TextAlign = TextAlign.Center,
    maxWidth: Dp = Dp.Unspecified,
    enabled: Boolean = true,
    paddingValues: PaddingValues = PaddingValues(),
    showOverlay: Boolean = true,
    highlightComponent: Boolean = true,
    horizontalAlignment: Alignment.Horizontal = Alignment.Start,
    verticalAlignment: Alignment.Vertical = Alignment.Top,
    onDismiss: () -> Unit = {}
)

To determine the placement we need screen dimensions. In Compose we achieve this via LocalDensity and LocalConfigurationHave a look:

val configuration = LocalConfiguration.current
val density = LocalDensity.current
val screenWidthPx = remember { with(density) { configuration.screenWidthDp.dp.roundToPx() } }
val screenHeightPx = remember { with(density) { configuration.screenHeightDp.dp.roundToPx() } }

We also need the size of the tooltip, we’ll create a remember state of IntSize type similar to componentSize and update it on onSizeChanged callback on the ArrowTooltip modifier.

var tooltipSize by remember { mutableStateOf(IntSize(0, 0)) }

 

ArrowTooltip(
    modifier = Modifier
        .widthIn(max = maxWidth)
        .onSizeChanged { tooltipSize = it }
        .padding(paddingValues),
    title = title,
    text = text,
    arrowAlignment = arrowAlignment,
    textAlign = textAlign
)

 

So finally to the point, to calculate the position or offset of the tooltip we use the combination of derivedStateOf and remember to optimize the calculations on recomposition.

val tooltipOffset by remember(positionInRoot, componentSize, tooltipSize) {
    derivedStateOf {
        calculateOffset(
            positionInRoot, componentSize, tooltipSize, screenWidthPx, screenHeightPx, horizontalAlignment, verticalAlignment
        )
    }
}

 

private fun calculateOffset(
    positionInRoot: IntOffset,
    componentSize: IntSize,
    tooltipSize: IntSize,
    screenWidthPx: Int,
    screenHeightPx: Int,
    horizontalAlignment: Alignment.Horizontal,
    verticalAlignment: Alignment.Vertical
): IntOffset {
    val horizontalAlignmentPosition = when (horizontalAlignment) {
        Alignment.Start -> positionInRoot.x
        Alignment.End -> positionInRoot.x + componentSize.width - tooltipSize.width
        else -> positionInRoot.x + (componentSize.width / 2) - (tooltipSize.width / 2)
    }
    val verticalAlignmentPosition = when (verticalAlignment) {
        Alignment.Top -> positionInRoot.y - tooltipSize.height
        Alignment.Bottom -> positionInRoot.y + componentSize.height
        else -> positionInRoot.y + (componentSize.height / 2)
    }

    val reult = IntOffset(
        x = min(screenWidthPx - tooltipSize.width, horizontalAlignmentPosition),
        y = min(screenHeightPx - tooltipSize.height, verticalAlignmentPosition)
    )
    
    return reult
}

 

Now finally we need to set the ArrowTooltip modifier with tooltipOffset, have a look:

 

ArrowTooltip(
    modifier = Modifier
        .widthIn(max = maxWidth)
        .onSizeChanged { tooltipSize = it }
        .offset { tooltipOffset }
        .padding(paddingValues),
    ...
)

 

Finally, have a look at the code when we bring all the pieces together:

@Composable
fun Modifier.tooltip(
title: String? = null,
text: String,
arrowAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
textAlign: TextAlign = TextAlign.Center,
maxWidth: Dp = Dp.Unspecified,
enabled: Boolean = true,
paddingValues: PaddingValues = PaddingValues(),
showOverlay: Boolean = true,
highlightComponent: Boolean = true,
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
verticalAlignment: Alignment.Vertical = Alignment.Top,
onDismiss: () -> Unit = {}
): Modifier {
val configuration = LocalConfiguration.current
val density = LocalDensity.current
val screenWidthPx = remember { with(density) { configuration.screenWidthDp.dp.roundToPx() } }
val screenHeightPx = remember { with(density) { configuration.screenHeightDp.dp.roundToPx() } }
var positionInRoot by remember { mutableStateOf(IntOffset.Zero) }
var tooltipSize by remember { mutableStateOf(IntSize(0, 0)) }
var componentSize by remember { mutableStateOf(IntSize(0, 0)) }
val tooltipOffset by remember(positionInRoot, componentSize, tooltipSize) {
derivedStateOf {
calculateOffset(
positionInRoot, componentSize, tooltipSize, screenWidthPx, screenHeightPx, horizontalAlignment, verticalAlignment
)
}
}
if (enabled) {
Popup(
alignment = Alignment.TopEnd
) {
Box(
modifier = Modifier
.fillMaxSize()
.drawOverlayBackground(
showOverlay = showOverlay,
highlightComponent = highlightComponent,
positionInRoot = positionInRoot,
componentSize = componentSize,
backgroundColor = MaterialTheme.colorScheme.surfaceContainerLowest,
backgroundAlpha = 0.8f
)
.clickable(
onClick = {
onDismiss()
}
)
) {
ArrowTooltip(
modifier = Modifier
.widthIn(max = maxWidth)
.onSizeChanged { tooltipSize = it }
.offset { tooltipOffset }
.padding(paddingValues),
title = title,
text = text,
arrowAlignment = arrowAlignment,
textAlign = textAlign
)
}
}
}
return this then Modifier.onPlaced {
componentSize = it.size
positionInRoot = it.positionInRoot().round()
}
}
@Composable
fun ArrowTooltip(
modifier: Modifier = Modifier,
title: String? = null,
text: String,
arrowAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
textAlign: TextAlign = TextAlign.Center,
) {
Column(
modifier = modifier,
horizontalAlignment = arrowAlignment,
) {
Icon(
modifier = Modifier
.padding(
start = if (arrowAlignment != Alignment.CenterHorizontally) 6.dp else 0.dp,
end = if (arrowAlignment != Alignment.CenterHorizontally) 6.dp else 0.dp
),
painter = painterResource(id = R.drawable.ic_arrow_up),
contentDescription = "Tool tip Arrow",
tint = MaterialTheme.colorScheme.surfaceContainer,
)
Column(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(16.dp),
) {
Text(
modifier = Modifier
.fillMaxWidth(),
text = title ?: "",
color = MaterialTheme.colorScheme.primary,
textAlign = textAlign,
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.Medium
)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
modifier = Modifier
.fillMaxWidth(),
text = text,
textAlign = textAlign,
style = MaterialTheme.typography.bodySmall
)
}
}
}
private fun calculateOffset(
positionInRoot: IntOffset,
componentSize: IntSize,
tooltipSize: IntSize,
screenWidthPx: Int,
screenHeightPx: Int,
horizontalAlignment: Alignment.Horizontal,
verticalAlignment: Alignment.Vertical
): IntOffset {
val horizontalAlignmentPosition = when (horizontalAlignment) {
Alignment.Start -> positionInRoot.x
Alignment.End -> positionInRoot.x + componentSize.width - tooltipSize.width
else -> positionInRoot.x + (componentSize.width / 2) - (tooltipSize.width / 2)
}
val verticalAlignmentPosition = when (verticalAlignment) {
Alignment.Top -> positionInRoot.y - tooltipSize.height
Alignment.Bottom -> positionInRoot.y + componentSize.height
else -> positionInRoot.y + (componentSize.height / 2)
}
val reult = IntOffset(
x = min(screenWidthPx - tooltipSize.width, horizontalAlignmentPosition),
y = min(screenHeightPx - tooltipSize.height, verticalAlignmentPosition)
)
return reult
}
private fun Modifier.drawOverlayBackground(
showOverlay: Boolean,
highlightComponent: Boolean,
positionInRoot: IntOffset,
componentSize: IntSize,
backgroundColor: Color,
backgroundAlpha: Float
) : Modifier {
return if (showOverlay) {
if (highlightComponent) {
drawBehind {
val highlightPath = Path().apply {
addRect(Rect(positionInRoot.toOffset(), componentSize.toSize()))
}
clipPath(highlightPath, clipOp = ClipOp.Difference) {
drawRect(SolidColor(backgroundColor.copy(alpha = backgroundAlpha)))
}
}
} else {
background(backgroundColor.copy(alpha = backgroundAlpha))
}
} else {
this
}
}

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Kobweb:Creating websites in Kotlin leveraging Compose HTML

Kobweb is a Kotlin web framework that aims to make web development enjoyable by building on top of Compose HTML and drawing inspiration from Jetpack Compose.
Watch Video

Kobweb:Creating websites in Kotlin leveraging Compose HTML

David Herman
Ex-Googler, author of Kobweb

Kobweb:Creating websites in Kotlin leveraging Compose HTML

David Herman
Ex-Googler, author o ...

Kobweb:Creating websites in Kotlin leveraging Compose HTML

David Herman
Ex-Googler, author of Kob ...

Jobs

Invoke the Tooltip From Feature Composable

The moment of the truth, we are done with the tool design, it’s time to use it in the feature and see the output. To keep it simple, let’s have text at the center of the screen and align the tooltip to it. Have a look:

@Composable
fun previewtooltips() {
    Scaffold {
        Box(
            modifier = Modifier
                .padding(it)
                .fillMaxSize(),
            contentAlignment = Alignment.Center,
        ) {
            Text(
                modifier = Modifier
                    .tooltips(
                        title = "This is the Title",
                        text = "This is the description",
                        textAlign = TextAlign.Center,
                        enabled = true,
                        maxWidth = 180.dp,
                        arrowAlignment = Alignment.CenterHorizontally,
                        verticalAlignment = Alignment.Bottom,
                        horizontalAlignment = Alignment.CenterHorizontally
                    ),
                text = "This is the sample text in preview"
            )
        }
    }
}
Conclusion

We’ve created the core logic of designing a tooltip without using any third-party libraries. Of course, there is room for development to make it production-ready. But with the logic and knowledge we acquired here, I’m sure we’re more than ready to optimize according to the requirements.

Reference:

That is all for now. I hope you learned something useful. Thanks for reading!

This article was previously published on proandroiddev.com.

Menu