-
-
Save amal/aad53791308e6edb055f3cf61f881451 to your computer and use it in GitHub Desktop.
@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!!") | |
} | |
} | |
} |
Thanks for sharing this extremely useful Composable!
@Skaldebane, thank you for the feedback. Glad that it's useful :)
Which libraries you imported for using this?
@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 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
@amal thanks a lot
@amal @file:Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") This is not working for me. Error still remains in file like for @camper9993
@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.
Thanks!!!
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.
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.
Thanks for sharing this extremely useful Composable!