commonMain.androidx.compose.foundation.BasicTooltipInternal.kt Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of foundation Show documentation
Show all versions of foundation Show documentation
Higher level abstractions of the Compose UI primitives. This library is design system agnostic, providing the high-level building blocks for both application and design-system developers
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.compose.foundation
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.PointerType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.onLongClick
import androidx.compose.ui.semantics.paneTitle
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
/**
* BasicTooltipBox that wraps a composable with a tooltip.
*
* Tooltip that provides a descriptive message for an anchor.
* It can be used to call the users attention to the anchor.
*
* @param positionProvider [PopupPositionProvider] that will be used to place the tooltip
* relative to the anchor content.
* @param tooltip the composable that will be used to populate the tooltip's content.
* @param state handles the state of the tooltip's visibility.
* @param modifier the [Modifier] to be applied to this BasicTooltipBox.
* @param focusable [Boolean] that determines if the tooltip is focusable. When true,
* the tooltip will consume touch events while it's shown and will have accessibility
* focus move to the first element of the component. When false, the tooltip
* won't consume touch events while it's shown but assistive-tech users will need
* to swipe or drag to get to the first element of the component.
* @param enableUserInput [Boolean] which determines if this BasicTooltipBox will handle
* long press and mouse hover to trigger the tooltip through the state provided.
* @param content the composable that the tooltip will anchor to.
*/
@Composable
@ExperimentalFoundationApi
internal fun BasicTooltipBoxInternal(
positionProvider: PopupPositionProvider,
tooltip: @Composable () -> Unit,
state: BasicTooltipState,
modifier: Modifier,
focusable: Boolean,
enableUserInput: Boolean,
content: @Composable () -> Unit
) {
val scope = rememberCoroutineScope()
Box {
if (state.isVisible) {
TooltipPopup(
positionProvider = positionProvider,
state = state,
scope = scope,
focusable = focusable,
content = tooltip
)
}
WrappedAnchor(
enableUserInput = enableUserInput,
state = state,
modifier = modifier,
content = content
)
}
DisposableEffect(state) {
onDispose { state.onDispose() }
}
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun WrappedAnchor(
enableUserInput: Boolean,
state: BasicTooltipState,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
val scope = rememberCoroutineScope()
val longPressLabel = BasicTooltipStrings.label()
Box(modifier = modifier
.handleGestures(enableUserInput, state)
.anchorSemantics(longPressLabel, enableUserInput, state, scope)
) { content() }
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun TooltipPopup(
positionProvider: PopupPositionProvider,
state: BasicTooltipState,
scope: CoroutineScope,
focusable: Boolean,
content: @Composable () -> Unit
) {
val tooltipDescription = BasicTooltipStrings.description()
Popup(
popupPositionProvider = positionProvider,
onDismissRequest = {
if (state.isVisible) {
scope.launch { state.dismiss() }
}
},
// TODO(https://youtrack.jetbrains.com/issue/COMPOSE-963/Discuss-fix-Tooltipfocusable-true-API) Discuss how to support focusable
properties = PopupProperties(focusable = false),
) {
Box(
modifier = Modifier.semantics {
liveRegion = LiveRegionMode.Assertive
paneTitle = tooltipDescription
}
) { content() }
}
}
@OptIn(ExperimentalFoundationApi::class)
private fun Modifier.handleGestures(
enabled: Boolean,
state: BasicTooltipState
): Modifier =
if (enabled) {
this.pointerInput(state) {
coroutineScope {
awaitEachGesture {
val longPressTimeout = viewConfiguration.longPressTimeoutMillis
val pass = PointerEventPass.Initial
// wait for the first down press
val inputType = awaitFirstDown(pass = pass).type
if (inputType == PointerType.Touch || inputType == PointerType.Stylus) {
try {
// listen to if there is up gesture
// within the longPressTimeout limit
withTimeout(longPressTimeout) {
waitForUpOrCancellation(pass = pass)
}
} catch (_: PointerEventTimeoutCancellationException) {
// handle long press - Show the tooltip
launch { state.show(MutatePriority.UserInput) }
// consume the children's click handling
val changes = awaitPointerEvent(pass = pass).changes
for (i in 0 until changes.size) { changes[i].consume() }
}
}
}
}
}
.pointerInput(state) {
coroutineScope {
awaitPointerEventScope {
val pass = PointerEventPass.Main
while (true) {
val event = awaitPointerEvent(pass)
val inputType = event.changes[0].type
if (inputType == PointerType.Mouse) {
when (event.type) {
PointerEventType.Enter -> {
launch { state.show(MutatePriority.UserInput) }
}
PointerEventType.Exit -> {
state.dismiss()
}
}
}
}
}
}
}
} else this
@OptIn(ExperimentalFoundationApi::class)
private fun Modifier.anchorSemantics(
label: String,
enabled: Boolean,
state: BasicTooltipState,
scope: CoroutineScope
): Modifier =
if (enabled) {
this.semantics(mergeDescendants = true) {
onLongClick(
label = label,
action = {
scope.launch { state.show() }
true
}
)
}
} else this
internal expect object BasicTooltipStrings {
@Composable
fun label(): String
@Composable
fun description(): String
}