Skip to content

Instantly share code, notes, and snippets.

@amal
Last active December 21, 2023 11:25
Show Gist options
  • Star 20 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save amal/aad53791308e6edb055f3cf61f881451 to your computer and use it in GitHub Desktop.
Save amal/aad53791308e6edb055f3cf61f881451 to your computer and use it in GitHub Desktop.
How to show a tooltip in AndroidX Jetpack Compose?
@file:Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER")
// Tooltip implementation for AndroidX Jetpack Compose
// See usage example in the next file
// Tested with Compose version **1.1.0-alpha06**
// Based on material DropdownMenu implementation.
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.core.graphics.ColorUtils
import kotlinx.coroutines.delay
/**
* Tooltip implementation for AndroidX Jetpack Compose.
* Based on material [DropdownMenu] implementation
*
* A [Tooltip] behaves similarly to a [Popup], and will use the position of the parent layout
* to position itself on screen. Commonly a [Tooltip] will be placed in a [Box] with a sibling
* that will be used as the 'anchor'. Note that a [Tooltip] by itself will not take up any
* space in a layout, as the tooltip is displayed in a separate window, on top of other content.
*
* The [content] of a [Tooltip] will typically be [Text], as well as custom content.
*
* [Tooltip] changes its positioning depending on the available space, always trying to be
* fully visible. It will try to expand horizontally, depending on layout direction, to the end of
* its parent, then to the start of its parent, and then screen end-aligned. Vertically, it will
* try to expand to the bottom of its parent, then from the top of its parent, and then screen
* top-aligned. An [offset] can be provided to adjust the positioning of the menu for cases when
* the layout bounds of its parent do not coincide with its visual bounds. Note the offset will
* be applied in the direction in which the menu will decide to expand.
*
* @param expanded Whether the tooltip is currently visible to the user
* @param offset [DpOffset] to be added to the position of the tooltip
*
* @see androidx.compose.material.DropdownMenu
* @see androidx.compose.material.DropdownMenuPositionProvider
* @see androidx.compose.ui.window.Popup
*
* @author Artyom Krivolapov
*/
@Composable
fun Tooltip(
expanded: MutableState<Boolean>,
modifier: Modifier = Modifier,
timeoutMillis: Long = TooltipTimeout,
backgroundColor: Color = Color.Black,
offset: DpOffset = TooltipOffset,
properties: PopupProperties = TooltipPopupProperties,
content: @Composable ColumnScope.() -> Unit,
) {
val expandedStates = remember { MutableTransitionState(false) }
expandedStates.targetState = expanded.value
if (expandedStates.currentState || expandedStates.targetState) {
if (expandedStates.isIdle) {
LaunchedEffect(timeoutMillis, expanded) {
delay(timeoutMillis)
expanded.value = false
}
}
Popup(
onDismissRequest = { expanded.value = false },
popupPositionProvider = DropdownMenuPositionProvider(offset, LocalDensity.current),
properties = properties,
) {
Box(
// Add space for elevation shadow
modifier = Modifier.padding(TooltipElevation),
) {
TooltipContent(expandedStates, backgroundColor, modifier, content)
}
}
}
}
/**
* Simple text version of [Tooltip]
*/
@Composable
fun Tooltip(
expanded: MutableState<Boolean>,
text: String,
modifier: Modifier = Modifier,
timeoutMillis: Long = TooltipTimeout,
backgroundColor: Color = Color.Black,
offset: DpOffset = TooltipOffset,
properties: PopupProperties = TooltipPopupProperties,
) {
Tooltip(expanded, modifier, timeoutMillis, backgroundColor, offset, properties) {
Text(text)
}
}
/** @see androidx.compose.material.DropdownMenuContent */
@Composable
private fun TooltipContent(
expandedStates: MutableTransitionState<Boolean>,
backgroundColor: Color,
modifier: Modifier,
content: @Composable ColumnScope.() -> Unit,
) {
// Tooltip open/close animation.
val transition = updateTransition(expandedStates, "Tooltip")
val alpha by transition.animateFloat(
label = "alpha",
transitionSpec = {
if (false isTransitioningTo true) {
// Dismissed to expanded
tween(durationMillis = InTransitionDuration)
} else {
// Expanded to dismissed.
tween(durationMillis = OutTransitionDuration)
}
}
) { if (it) 1f else 0f }
Card(
backgroundColor = backgroundColor.copy(alpha = 0.75f),
contentColor = MaterialTheme.colors.contentColorFor(backgroundColor)
.takeOrElse { backgroundColor.onColor() },
modifier = Modifier.alpha(alpha),
elevation = TooltipElevation,
) {
val p = TooltipPadding
Column(
modifier = modifier
.padding(start = p, top = p * 0.5f, end = p, bottom = p * 0.7f)
.width(IntrinsicSize.Max)
.verticalScroll(rememberScrollState()),
content = content,
)
}
}
private val TooltipElevation = 16.dp
private val TooltipPadding = 16.dp
private val TooltipPopupProperties = PopupProperties(focusable = true)
private val TooltipOffset = DpOffset(0.dp, 0.dp)
// Tooltip open/close animation duration.
private const val InTransitionDuration = 64
private const val OutTransitionDuration = 240
// Default timeout before tooltip close
private const val TooltipTimeout = 2_000L - OutTransitionDuration
// Color helpers
/**
* Calculates an 'on' color for this color.
*
* @return [Color.Black] or [Color.White], depending on [isLightColor].
*/
fun Color.onColor(): Color {
return if (isLightColor()) Color.Black else Color.White
}
/**
* Calculates if this color is considered light.
*
* @return true or false, depending on the higher contrast between [Color.Black] and [Color.White].
*/
fun Color.isLightColor(): Boolean {
val contrastForBlack = calculateContrastFor(foreground = Color.Black)
val contrastForWhite = calculateContrastFor(foreground = Color.White)
return contrastForBlack > contrastForWhite
}
fun Color.calculateContrastFor(foreground: Color): Double {
return ColorUtils.calculateContrast(foreground.toArgb(), toArgb())
}
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.material.Text
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
/**
* How to show a Tooltip in AndroidX Jetpack Compose on long click.
* Usage example.
*/
@Composable
@OptIn(ExperimentalFoundationApi::class)
fun TooltipOnLongClickExample(onClick: () -> Unit = {}) {
// Commonly a Tooltip can be placed in a Box with a sibling
// that will be used as the 'anchor' for positioning.
Box {
val showTooltip = remember { mutableStateOf(false) }
// Buttons and Surfaces don't support onLongClick out of the box,
// so use a simple Box with combinedClickable
Box(
modifier = Modifier
.combinedClickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(),
onClickLabel = "Button action description",
role = Role.Button,
onClick = onClick,
onLongClick = { showTooltip.value = true },
),
) {
Text("Click Me (will show tooltip on long click)")
}
Tooltip(showTooltip) {
// Tooltip content goes here.
Text("Tooltip Text!!")
}
}
}
@Skaldebane
Copy link

Thanks for sharing this extremely useful Composable!

@amal
Copy link
Author

amal commented Oct 31, 2021

Thanks for sharing this extremely useful Composable!

@Skaldebane, thank you for the feedback. Glad that it's useful :)

@himanshufoodpanda
Copy link

Which libraries you imported for using this?

@amal
Copy link
Author

amal commented Aug 2, 2022

@himanshufoodpanda kotlin coroutines lib and compose libs: core, runtime, foundation, ui, animation, and material.
If you want to use material3, code need to be adjusted a bit.

@camper9993
Copy link

hi, what version of compose do you use?
i have this issue and i think that it is because i don;t use the latest version 1.2.0
image

@amal
Copy link
Author

amal commented Aug 2, 2022

@camper9993 no, this solution uses internal part of compose framework, so you need this in code to have the access:
@file:Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER")

It's probably lost in copy-paste, see here: #file-tooltip-kt-L1

@camper9993
Copy link

@amal thanks a lot

@himanshufoodpanda
Copy link

@amal @file:Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") This is not working for me. Error still remains in file like for @camper9993

@amal
Copy link
Author

amal commented Aug 3, 2022

@himanshufoodpanda it should work. Checked with many different versions of compose.

Please check that source code is copied exactly as is. @file:Suppress should be exactly on the first line, before all the imports.

@birojow
Copy link

birojow commented Aug 18, 2022

Thanks!!!

@chanjungkim
Copy link

How can I keep the popup when I click outside? It dismisses when I click outside. In my case, it must stay in place and move up and down. And I need to show and dismiss at certain events.

@pauminku
Copy link

I think this should be changed:
properties: PopupProperties = PopupProperties(focusable = false),
In that way the tooltip is not catching screen touches so you can keep interacting with the rest of the widgets while the tooltip is shown.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment