1 /*
<lambda>null2 * Copyright 2023 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package androidx.compose.material3
18
19 import androidx.compose.animation.core.MutableTransitionState
20 import androidx.compose.animation.core.Transition
21 import androidx.compose.animation.core.animateFloat
22 import androidx.compose.animation.core.updateTransition
23 import androidx.compose.foundation.MutatePriority
24 import androidx.compose.foundation.MutatorMutex
25 import androidx.compose.foundation.layout.Box
26 import androidx.compose.foundation.layout.Column
27 import androidx.compose.foundation.layout.PaddingValues
28 import androidx.compose.foundation.layout.padding
29 import androidx.compose.foundation.layout.paddingFromBaseline
30 import androidx.compose.foundation.layout.requiredHeightIn
31 import androidx.compose.foundation.layout.sizeIn
32 import androidx.compose.material3.internal.BasicTooltipBox
33 import androidx.compose.material3.internal.BasicTooltipDefaults
34 import androidx.compose.material3.tokens.ElevationTokens
35 import androidx.compose.material3.tokens.MotionSchemeKeyTokens
36 import androidx.compose.material3.tokens.PlainTooltipTokens
37 import androidx.compose.material3.tokens.RichTooltipTokens
38 import androidx.compose.runtime.Composable
39 import androidx.compose.runtime.CompositionLocalProvider
40 import androidx.compose.runtime.Immutable
41 import androidx.compose.runtime.MutableState
42 import androidx.compose.runtime.Stable
43 import androidx.compose.runtime.getValue
44 import androidx.compose.runtime.mutableStateOf
45 import androidx.compose.runtime.remember
46 import androidx.compose.ui.Modifier
47 import androidx.compose.ui.composed
48 import androidx.compose.ui.draw.CacheDrawScope
49 import androidx.compose.ui.draw.DrawResult
50 import androidx.compose.ui.draw.drawWithCache
51 import androidx.compose.ui.geometry.Offset
52 import androidx.compose.ui.graphics.Color
53 import androidx.compose.ui.graphics.Path
54 import androidx.compose.ui.graphics.Shape
55 import androidx.compose.ui.graphics.graphicsLayer
56 import androidx.compose.ui.graphics.takeOrElse
57 import androidx.compose.ui.layout.LayoutCoordinates
58 import androidx.compose.ui.layout.boundsInWindow
59 import androidx.compose.ui.layout.onGloballyPositioned
60 import androidx.compose.ui.platform.LocalDensity
61 import androidx.compose.ui.platform.debugInspectorInfo
62 import androidx.compose.ui.unit.Density
63 import androidx.compose.ui.unit.Dp
64 import androidx.compose.ui.unit.DpSize
65 import androidx.compose.ui.unit.IntOffset
66 import androidx.compose.ui.unit.IntRect
67 import androidx.compose.ui.unit.IntSize
68 import androidx.compose.ui.unit.LayoutDirection
69 import androidx.compose.ui.unit.dp
70 import androidx.compose.ui.unit.isSpecified
71 import androidx.compose.ui.window.PopupPositionProvider
72 import kotlinx.coroutines.CancellableContinuation
73 import kotlinx.coroutines.suspendCancellableCoroutine
74 import kotlinx.coroutines.withTimeout
75
76 /**
77 * Material TooltipBox that wraps a composable with a tooltip.
78 *
79 * tooltips provide a descriptive message for an anchor. It can be used to call the users attention
80 * to the anchor.
81 *
82 * Tooltip that is invoked when the anchor is long pressed:
83 *
84 * @sample androidx.compose.material3.samples.PlainTooltipSample
85 *
86 * If control of when the tooltip is shown is desired please see
87 *
88 * @sample androidx.compose.material3.samples.PlainTooltipWithManualInvocationSample
89 *
90 * Plain tooltip with caret shown on long press:
91 *
92 * @sample androidx.compose.material3.samples.PlainTooltipWithCaret
93 *
94 * Plain tooltip shown on long press with a custom caret:
95 *
96 * @sample androidx.compose.material3.samples.PlainTooltipWithCustomCaret
97 *
98 * Tooltip that is invoked when the anchor is long pressed:
99 *
100 * @sample androidx.compose.material3.samples.RichTooltipSample
101 *
102 * If control of when the tooltip is shown is desired please see
103 *
104 * @sample androidx.compose.material3.samples.RichTooltipWithManualInvocationSample
105 *
106 * Rich tooltip with caret shown on long press:
107 *
108 * @sample androidx.compose.material3.samples.RichTooltipWithCaretSample
109 *
110 * Rich tooltip shown on long press with a custom caret
111 *
112 * @sample androidx.compose.material3.samples.RichTooltipWithCustomCaretSample
113 * @param positionProvider [PopupPositionProvider] that will be used to place the tooltip relative
114 * to the anchor content.
115 * @param tooltip the composable that will be used to populate the tooltip's content.
116 * @param state handles the state of the tooltip's visibility.
117 * @param modifier the [Modifier] to be applied to the TooltipBox.
118 * @param focusable [Boolean] that determines if the tooltip is focusable. When true, the tooltip
119 * will consume touch events while it's shown and will have accessibility focus move to the first
120 * element of the component. When false, the tooltip won't consume touch events while it's shown
121 * but assistive-tech users will need to swipe or drag to get to the first element of the
122 * component.
123 * @param enableUserInput [Boolean] which determines if this TooltipBox will handle long press and
124 * mouse hover to trigger the tooltip through the state provided.
125 * @param content the composable that the tooltip will anchor to.
126 */
127 @Deprecated(
128 "Deprecated in favor of TooltipBox API that contains onDismissRequest.",
129 level = DeprecationLevel.HIDDEN
130 )
131 @Composable
132 @ExperimentalMaterial3Api
133 fun TooltipBox(
134 positionProvider: PopupPositionProvider,
135 tooltip: @Composable TooltipScope.() -> Unit,
136 state: TooltipState,
137 modifier: Modifier = Modifier,
138 focusable: Boolean = true,
139 enableUserInput: Boolean = true,
140 content: @Composable () -> Unit,
141 ) =
142 TooltipBox(
143 positionProvider = positionProvider,
144 tooltip = tooltip,
145 state = state,
146 modifier = modifier,
147 onDismissRequest = null,
148 focusable = focusable,
149 enableUserInput = enableUserInput,
150 content = content
151 )
152
153 /**
154 * Material TooltipBox that wraps a composable with a tooltip.
155 *
156 * tooltips provide a descriptive message for an anchor. It can be used to call the users attention
157 * to the anchor.
158 *
159 * Tooltip that is invoked when the anchor is long pressed:
160 *
161 * @sample androidx.compose.material3.samples.PlainTooltipSample
162 *
163 * If control of when the tooltip is shown is desired please see
164 *
165 * @sample androidx.compose.material3.samples.PlainTooltipWithManualInvocationSample
166 *
167 * Plain tooltip with caret shown on long press:
168 *
169 * @sample androidx.compose.material3.samples.PlainTooltipWithCaret
170 *
171 * Plain tooltip shown on long press with a custom caret:
172 *
173 * @sample androidx.compose.material3.samples.PlainTooltipWithCustomCaret
174 *
175 * Tooltip that is invoked when the anchor is long pressed:
176 *
177 * @sample androidx.compose.material3.samples.RichTooltipSample
178 *
179 * If control of when the tooltip is shown is desired please see
180 *
181 * @sample androidx.compose.material3.samples.RichTooltipWithManualInvocationSample
182 *
183 * Rich tooltip with caret shown on long press:
184 *
185 * @sample androidx.compose.material3.samples.RichTooltipWithCaretSample
186 *
187 * Rich tooltip shown on long press with a custom caret
188 *
189 * @sample androidx.compose.material3.samples.RichTooltipWithCustomCaretSample
190 * @param positionProvider [PopupPositionProvider] that will be used to place the tooltip relative
191 * to the anchor content.
192 * @param tooltip the composable that will be used to populate the tooltip's content.
193 * @param state handles the state of the tooltip's visibility.
194 * @param modifier the [Modifier] to be applied to the TooltipBox.
195 * @param onDismissRequest executes when the user clicks outside of the tooltip. By default, the
196 * tooltip will dismiss when it's being shown when a user clicks outside of the tooltip.
197 * @param focusable [Boolean] that determines if the tooltip is focusable. When true, the tooltip
198 * will consume touch events while it's shown and will have accessibility focus move to the first
199 * element of the component. When false, the tooltip won't consume touch events while it's shown
200 * but assistive-tech users will need to swipe or drag to get to the first element of the
201 * component.
202 * @param enableUserInput [Boolean] which determines if this TooltipBox will handle long press and
203 * mouse hover to trigger the tooltip through the state provided.
204 * @param content the composable that the tooltip will anchor to.
205 */
206 @Composable
207 @ExperimentalMaterial3Api
208 fun TooltipBox(
209 positionProvider: PopupPositionProvider,
210 tooltip: @Composable TooltipScope.() -> Unit,
211 state: TooltipState,
212 modifier: Modifier = Modifier,
213 onDismissRequest: (() -> Unit)? = null,
214 focusable: Boolean = true,
215 enableUserInput: Boolean = true,
216 content: @Composable () -> Unit,
217 ) {
218 @Suppress("DEPRECATION")
219 val transition = updateTransition(state.transition, label = "tooltip transition")
220 val anchorBounds: MutableState<LayoutCoordinates?> = remember { mutableStateOf(null) }
221 val scope = remember { TooltipScopeImpl { anchorBounds.value } }
222
223 val wrappedContent: @Composable () -> Unit = {
224 Box(modifier = Modifier.onGloballyPositioned { anchorBounds.value = it }) { content() }
225 }
226
227 BasicTooltipBox(
228 positionProvider = positionProvider,
229 tooltip = { Box(Modifier.animateTooltip(transition)) { scope.tooltip() } },
230 focusable = focusable,
231 enableUserInput = enableUserInput,
232 onDismissRequest = onDismissRequest,
233 state = state,
234 modifier = modifier,
235 content = wrappedContent
236 )
237 }
238
239 /**
240 * Tooltip scope for [TooltipBox] to be used to obtain the [LayoutCoordinates] of the anchor
241 * content, and to draw a caret for the tooltip.
242 */
243 @ExperimentalMaterial3Api
244 sealed interface TooltipScope {
245 /**
246 * [Modifier] that is used to draw the caret for the tooltip. A [LayoutCoordinates] will be
247 * provided that can be used to obtain the bounds of the anchor content, which can be used to
248 * draw the caret more precisely. [PlainTooltip] and [RichTooltip] have default implementations
249 * for their caret.
250 */
drawCaretnull251 fun Modifier.drawCaret(draw: CacheDrawScope.(LayoutCoordinates?) -> DrawResult): Modifier
252 }
253
254 @OptIn(ExperimentalMaterial3Api::class)
255 internal class TooltipScopeImpl(val getAnchorBounds: () -> LayoutCoordinates?) : TooltipScope {
256 override fun Modifier.drawCaret(
257 draw: CacheDrawScope.(LayoutCoordinates?) -> DrawResult
258 ): Modifier = this.drawWithCache { draw(getAnchorBounds()) }
259 }
260
261 /**
262 * Plain tooltip that provides a descriptive message.
263 *
264 * Usually used with [TooltipBox].
265 *
266 * @param modifier the [Modifier] to be applied to the tooltip.
267 * @param caretSize [DpSize] for the caret of the tooltip, if a default caret is desired with a
268 * specific dimension. Please see [TooltipDefaults.caretSize] to see the default dimensions. Pass
269 * in Dp.Unspecified for this parameter if no caret is desired.
270 * @param maxWidth the maximum width for the plain tooltip
271 * @param shape the [Shape] that should be applied to the tooltip container.
272 * @param contentColor [Color] that will be applied to the tooltip's content.
273 * @param containerColor [Color] that will be applied to the tooltip's container.
274 * @param tonalElevation the tonal elevation of the tooltip.
275 * @param shadowElevation the shadow elevation of the tooltip.
276 * @param content the composable that will be used to populate the tooltip's content.
277 */
278 @Composable
279 @ExperimentalMaterial3Api
TooltipScopenull280 fun TooltipScope.PlainTooltip(
281 modifier: Modifier = Modifier,
282 caretSize: DpSize = DpSize.Unspecified,
283 maxWidth: Dp = TooltipDefaults.plainTooltipMaxWidth,
284 shape: Shape = TooltipDefaults.plainTooltipContainerShape,
285 contentColor: Color = TooltipDefaults.plainTooltipContentColor,
286 containerColor: Color = TooltipDefaults.plainTooltipContainerColor,
287 tonalElevation: Dp = 0.dp,
288 shadowElevation: Dp = 0.dp,
289 content: @Composable () -> Unit
290 ) {
291 val drawCaretModifier =
292 if (caretSize.isSpecified) {
293 val density = LocalDensity.current
294 val windowContainerWidthInPx = windowContainerWidthInPx()
295 Modifier.drawCaret { anchorLayoutCoordinates ->
296 drawCaretWithPath(
297 density,
298 windowContainerWidthInPx,
299 containerColor,
300 caretSize,
301 anchorLayoutCoordinates
302 )
303 }
304 .then(modifier)
305 } else modifier
306 Surface(
307 modifier = drawCaretModifier,
308 shape = shape,
309 color = containerColor,
310 tonalElevation = tonalElevation,
311 shadowElevation = shadowElevation
312 ) {
313 Box(
314 modifier =
315 Modifier.sizeIn(
316 minWidth = TooltipMinWidth,
317 maxWidth = maxWidth,
318 minHeight = TooltipMinHeight
319 )
320 .padding(PlainTooltipContentPadding)
321 ) {
322 val textStyle = PlainTooltipTokens.SupportingTextFont.value
323
324 CompositionLocalProvider(
325 LocalContentColor provides contentColor,
326 LocalTextStyle provides textStyle,
327 content = content
328 )
329 }
330 }
331 }
332
333 /**
334 * Rich text tooltip that allows the user to pass in a title, text, and action. Tooltips are used to
335 * provide a descriptive message.
336 *
337 * Usually used with [TooltipBox]
338 *
339 * @param modifier the [Modifier] to be applied to the tooltip.
340 * @param title An optional title for the tooltip.
341 * @param action An optional action for the tooltip.
342 * @param caretSize [DpSize] for the caret of the tooltip, if a default caret is desired with a
343 * specific dimension. Please see [TooltipDefaults.caretSize] to see the default dimensions. Pass
344 * in Dp.Unspecified for this parameter if no caret is desired.
345 * @param maxWidth the maximum width for the rich tooltip
346 * @param shape the [Shape] that should be applied to the tooltip container.
347 * @param colors [RichTooltipColors] that will be applied to the tooltip's container and content.
348 * @param tonalElevation the tonal elevation of the tooltip.
349 * @param shadowElevation the shadow elevation of the tooltip.
350 * @param text the composable that will be used to populate the rich tooltip's text.
351 */
352 @Composable
353 @ExperimentalMaterial3Api
TooltipScopenull354 fun TooltipScope.RichTooltip(
355 modifier: Modifier = Modifier,
356 title: (@Composable () -> Unit)? = null,
357 action: (@Composable () -> Unit)? = null,
358 caretSize: DpSize = DpSize.Unspecified,
359 maxWidth: Dp = TooltipDefaults.richTooltipMaxWidth,
360 shape: Shape = TooltipDefaults.richTooltipContainerShape,
361 colors: RichTooltipColors = TooltipDefaults.richTooltipColors(),
362 tonalElevation: Dp = ElevationTokens.Level0,
363 shadowElevation: Dp = RichTooltipTokens.ContainerElevation,
364 text: @Composable () -> Unit
365 ) {
366 val absoluteElevation = LocalAbsoluteTonalElevation.current + tonalElevation
367 val elevatedColor =
368 MaterialTheme.colorScheme.applyTonalElevation(colors.containerColor, absoluteElevation)
369 val drawCaretModifier =
370 if (caretSize.isSpecified) {
371 val density = LocalDensity.current
372 val windowContainerWidthInPx = windowContainerWidthInPx()
373 Modifier.drawCaret { anchorLayoutCoordinates ->
374 drawCaretWithPath(
375 density,
376 windowContainerWidthInPx,
377 elevatedColor,
378 caretSize,
379 anchorLayoutCoordinates
380 )
381 }
382 .then(modifier)
383 } else modifier
384 Surface(
385 modifier =
386 drawCaretModifier.sizeIn(
387 minWidth = TooltipMinWidth,
388 maxWidth = maxWidth,
389 minHeight = TooltipMinHeight
390 ),
391 shape = shape,
392 color = colors.containerColor,
393 tonalElevation = tonalElevation,
394 shadowElevation = shadowElevation
395 ) {
396 val actionLabelTextStyle = RichTooltipTokens.ActionLabelTextFont.value
397 val subheadTextStyle = RichTooltipTokens.SubheadFont.value
398 val supportingTextStyle = RichTooltipTokens.SupportingTextFont.value
399
400 Column(modifier = Modifier.padding(horizontal = RichTooltipHorizontalPadding)) {
401 title?.let {
402 Box(modifier = Modifier.paddingFromBaseline(top = HeightToSubheadFirstLine)) {
403 CompositionLocalProvider(
404 LocalContentColor provides colors.titleContentColor,
405 LocalTextStyle provides subheadTextStyle,
406 content = it
407 )
408 }
409 }
410 Box(modifier = Modifier.textVerticalPadding(title != null, action != null)) {
411 CompositionLocalProvider(
412 LocalContentColor provides colors.contentColor,
413 LocalTextStyle provides supportingTextStyle,
414 content = text
415 )
416 }
417 action?.let {
418 Box(
419 modifier =
420 Modifier.requiredHeightIn(min = ActionLabelMinHeight)
421 .padding(bottom = ActionLabelBottomPadding)
422 ) {
423 CompositionLocalProvider(
424 LocalContentColor provides colors.actionContentColor,
425 LocalTextStyle provides actionLabelTextStyle,
426 content = it
427 )
428 }
429 }
430 }
431 }
432 }
433
434 /** Tooltip defaults that contain default values for both [PlainTooltip] and [RichTooltip] */
435 @ExperimentalMaterial3Api
436 object TooltipDefaults {
437 /** The default [Shape] for a [PlainTooltip]'s container. */
438 val plainTooltipContainerShape: Shape
439 @Composable get() = PlainTooltipTokens.ContainerShape.value
440
441 /** The default [Color] for a [PlainTooltip]'s container. */
442 val plainTooltipContainerColor: Color
443 @Composable get() = PlainTooltipTokens.ContainerColor.value
444
445 /** The default [Color] for the content within the [PlainTooltip]. */
446 val plainTooltipContentColor: Color
447 @Composable get() = PlainTooltipTokens.SupportingTextColor.value
448
449 /** The default [Shape] for a [RichTooltip]'s container. */
450 val richTooltipContainerShape: Shape
451 @Composable get() = RichTooltipTokens.ContainerShape.value
452
453 /** The default [DpSize] for tooltip carets. */
454 val caretSize: DpSize = DpSize(16.dp, 8.dp)
455
456 /** The default maximum width for plain tooltips. */
457 val plainTooltipMaxWidth: Dp = 200.dp
458
459 /** The default maximum width for rich tooltips. */
460 val richTooltipMaxWidth: Dp = 320.dp
461
462 /**
463 * Method to create a [RichTooltipColors] for [RichTooltip] using [RichTooltipTokens] to obtain
464 * the default colors.
465 */
richTooltipColorsnull466 @Composable fun richTooltipColors() = MaterialTheme.colorScheme.defaultRichTooltipColors
467
468 /**
469 * Method to create a [RichTooltipColors] for [RichTooltip] using [RichTooltipTokens] to obtain
470 * the default colors.
471 */
472 @Composable
473 fun richTooltipColors(
474 containerColor: Color = Color.Unspecified,
475 contentColor: Color = Color.Unspecified,
476 titleContentColor: Color = Color.Unspecified,
477 actionContentColor: Color = Color.Unspecified,
478 ): RichTooltipColors =
479 MaterialTheme.colorScheme.defaultRichTooltipColors.copy(
480 containerColor = containerColor,
481 contentColor = contentColor,
482 titleContentColor = titleContentColor,
483 actionContentColor = actionContentColor
484 )
485
486 internal val ColorScheme.defaultRichTooltipColors: RichTooltipColors
487 get() {
488 return defaultRichTooltipColorsCached
489 ?: RichTooltipColors(
490 containerColor = fromToken(RichTooltipTokens.ContainerColor),
491 contentColor = fromToken(RichTooltipTokens.SupportingTextColor),
492 titleContentColor = fromToken(RichTooltipTokens.SubheadColor),
493 actionContentColor = fromToken(RichTooltipTokens.ActionLabelTextColor),
494 )
495 .also { defaultRichTooltipColorsCached = it }
496 }
497
498 /**
499 * [PopupPositionProvider] that should be used with [PlainTooltip]. It correctly positions the
500 * tooltip in respect to the anchor content.
501 *
502 * @param spacingBetweenTooltipAndAnchor the spacing between the tooltip and the anchor content.
503 */
504 @Deprecated(
505 "Deprecated in favor of rememberTooltipPositionProvider API.",
506 replaceWith =
507 ReplaceWith("rememberTooltipPositionProvider(spacingBetweenTooltipAndAnchor)"),
508 level = DeprecationLevel.WARNING
509 )
510 @Composable
rememberPlainTooltipPositionProvidernull511 fun rememberPlainTooltipPositionProvider(
512 spacingBetweenTooltipAndAnchor: Dp = SpacingBetweenTooltipAndAnchor
513 ): PopupPositionProvider {
514 val tooltipAnchorSpacing =
515 with(LocalDensity.current) { spacingBetweenTooltipAndAnchor.roundToPx() }
516 return remember(tooltipAnchorSpacing) {
517 object : PopupPositionProvider {
518 override fun calculatePosition(
519 anchorBounds: IntRect,
520 windowSize: IntSize,
521 layoutDirection: LayoutDirection,
522 popupContentSize: IntSize
523 ): IntOffset {
524 val x = anchorBounds.left + (anchorBounds.width - popupContentSize.width) / 2
525
526 // Tooltip prefers to be above the anchor,
527 // but if this causes the tooltip to overlap with the anchor
528 // then we place it below the anchor
529 var y = anchorBounds.top - popupContentSize.height - tooltipAnchorSpacing
530 if (y < 0) y = anchorBounds.bottom + tooltipAnchorSpacing
531 return IntOffset(x, y)
532 }
533 }
534 }
535 }
536
537 /**
538 * [PopupPositionProvider] that should be used with [RichTooltip]. It correctly positions the
539 * tooltip in respect to the anchor content.
540 *
541 * @param spacingBetweenTooltipAndAnchor the spacing between the tooltip and the anchor content.
542 */
543 @Deprecated(
544 "Deprecated in favor of rememberTooltipPositionProvider API.",
545 replaceWith =
546 ReplaceWith("rememberTooltipPositionProvider(spacingBetweenTooltipAndAnchor)"),
547 level = DeprecationLevel.WARNING
548 )
549 @Composable
rememberRichTooltipPositionProvidernull550 fun rememberRichTooltipPositionProvider(
551 spacingBetweenTooltipAndAnchor: Dp = SpacingBetweenTooltipAndAnchor
552 ): PopupPositionProvider {
553 val tooltipAnchorSpacing =
554 with(LocalDensity.current) { spacingBetweenTooltipAndAnchor.roundToPx() }
555 return remember(tooltipAnchorSpacing) {
556 object : PopupPositionProvider {
557 override fun calculatePosition(
558 anchorBounds: IntRect,
559 windowSize: IntSize,
560 layoutDirection: LayoutDirection,
561 popupContentSize: IntSize
562 ): IntOffset {
563 var x = anchorBounds.left
564 // Try to shift it to the left of the anchor
565 // if the tooltip would collide with the right side of the screen
566 if (x + popupContentSize.width > windowSize.width) {
567 x = anchorBounds.right - popupContentSize.width
568 // Center if it'll also collide with the left side of the screen
569 if (x < 0)
570 x =
571 anchorBounds.left +
572 (anchorBounds.width - popupContentSize.width) / 2
573 }
574
575 // Tooltip prefers to be above the anchor,
576 // but if this causes the tooltip to overlap with the anchor
577 // then we place it below the anchor
578 var y = anchorBounds.top - popupContentSize.height - tooltipAnchorSpacing
579 if (y < 0) y = anchorBounds.bottom + tooltipAnchorSpacing
580 return IntOffset(x, y)
581 }
582 }
583 }
584 }
585
586 /**
587 * [PopupPositionProvider] that should be used with either [RichTooltip] or [PlainTooltip]. It
588 * correctly positions the tooltip in respect to the anchor content.
589 *
590 * @param spacingBetweenTooltipAndAnchor the spacing between the tooltip and the anchor content.
591 */
592 @Composable
rememberTooltipPositionProvidernull593 fun rememberTooltipPositionProvider(
594 spacingBetweenTooltipAndAnchor: Dp = SpacingBetweenTooltipAndAnchor
595 ): PopupPositionProvider {
596 val tooltipAnchorSpacing =
597 with(LocalDensity.current) { spacingBetweenTooltipAndAnchor.roundToPx() }
598 return remember(tooltipAnchorSpacing) {
599 object : PopupPositionProvider {
600 override fun calculatePosition(
601 anchorBounds: IntRect,
602 windowSize: IntSize,
603 layoutDirection: LayoutDirection,
604 popupContentSize: IntSize
605 ): IntOffset {
606 // Horizontal alignment preference: middle -> start -> end
607 // Vertical preference: above -> below
608
609 // Tooltip prefers to be center aligned horizontally.
610 var x = anchorBounds.left + (anchorBounds.width - popupContentSize.width) / 2
611
612 if (x < 0) {
613 // Make tooltip start aligned if colliding with the
614 // left side of the screen
615 x = anchorBounds.left
616 } else if (x + popupContentSize.width > windowSize.width) {
617 // Make tooltip end aligned if colliding with the
618 // right side of the screen
619 x = anchorBounds.right - popupContentSize.width
620 }
621
622 // Tooltip prefers to be above the anchor,
623 // but if this causes the tooltip to overlap with the anchor
624 // then we place it below the anchor
625 var y = anchorBounds.top - popupContentSize.height - tooltipAnchorSpacing
626 if (y < 0) y = anchorBounds.bottom + tooltipAnchorSpacing
627 return IntOffset(x, y)
628 }
629 }
630 }
631 }
632 }
633
634 @Stable
635 @Immutable
636 @ExperimentalMaterial3Api
637 class RichTooltipColors(
638 val containerColor: Color,
639 val contentColor: Color,
640 val titleContentColor: Color,
641 val actionContentColor: Color
642 ) {
643 /**
644 * Returns a copy of this RichTooltipColors, optionally overriding some of the values. This uses
645 * the Color.Unspecified to mean “use the value from the source”
646 */
copynull647 fun copy(
648 containerColor: Color = this.containerColor,
649 contentColor: Color = this.contentColor,
650 titleContentColor: Color = this.titleContentColor,
651 actionContentColor: Color = this.actionContentColor,
652 ) =
653 RichTooltipColors(
654 containerColor.takeOrElse { this.containerColor },
<lambda>null655 contentColor.takeOrElse { this.contentColor },
<lambda>null656 titleContentColor.takeOrElse { this.titleContentColor },
<lambda>null657 actionContentColor.takeOrElse { this.actionContentColor },
658 )
659
equalsnull660 override fun equals(other: Any?): Boolean {
661 if (this === other) return true
662 if (other !is RichTooltipColors) return false
663
664 if (containerColor != other.containerColor) return false
665 if (contentColor != other.contentColor) return false
666 if (titleContentColor != other.titleContentColor) return false
667 if (actionContentColor != other.actionContentColor) return false
668
669 return true
670 }
671
hashCodenull672 override fun hashCode(): Int {
673 var result = containerColor.hashCode()
674 result = 31 * result + contentColor.hashCode()
675 result = 31 * result + titleContentColor.hashCode()
676 result = 31 * result + actionContentColor.hashCode()
677 return result
678 }
679 }
680
681 /**
682 * Create and remember the default [TooltipState] for [TooltipBox].
683 *
684 * @param initialIsVisible the initial value for the tooltip's visibility when drawn.
685 * @param isPersistent [Boolean] that determines if the tooltip associated with this will be
686 * persistent or not. If isPersistent is true, then the tooltip will only be dismissed when the
687 * user clicks outside the bounds of the tooltip or if [TooltipState.dismiss] is called. When
688 * isPersistent is false, the tooltip will dismiss after a short duration. Ideally, this should be
689 * set to true when there is actionable content being displayed within a tooltip.
690 * @param mutatorMutex [MutatorMutex] used to ensure that for all of the tooltips associated with
691 * the mutator mutex, only one will be shown on the screen at any time.
692 */
693 @Composable
694 @ExperimentalMaterial3Api
rememberTooltipStatenull695 fun rememberTooltipState(
696 initialIsVisible: Boolean = false,
697 isPersistent: Boolean = false,
698 mutatorMutex: MutatorMutex = BasicTooltipDefaults.GlobalMutatorMutex
699 ): TooltipState =
700 remember(isPersistent, mutatorMutex) {
701 TooltipStateImpl(
702 initialIsVisible = initialIsVisible,
703 isPersistent = isPersistent,
704 mutatorMutex = mutatorMutex
705 )
706 }
707
708 /**
709 * Constructor extension function for [TooltipState]
710 *
711 * @param initialIsVisible the initial value for the tooltip's visibility when drawn.
712 * @param isPersistent [Boolean] that determines if the tooltip associated with this will be
713 * persistent or not. If isPersistent is true, then the tooltip will only be dismissed when the
714 * user clicks outside the bounds of the tooltip or if [TooltipState.dismiss] is called. When
715 * isPersistent is false, the tooltip will dismiss after a short duration. Ideally, this should be
716 * set to true when there is actionable content being displayed within a tooltip.
717 * @param mutatorMutex [MutatorMutex] used to ensure that for all of the tooltips associated with
718 * the mutator mutex, only one will be shown on the screen at any time.
719 */
720 @ExperimentalMaterial3Api
TooltipStatenull721 fun TooltipState(
722 initialIsVisible: Boolean = false,
723 isPersistent: Boolean = true,
724 mutatorMutex: MutatorMutex = BasicTooltipDefaults.GlobalMutatorMutex
725 ): TooltipState =
726 TooltipStateImpl(
727 initialIsVisible = initialIsVisible,
728 isPersistent = isPersistent,
729 mutatorMutex = mutatorMutex
730 )
731
732 @OptIn(ExperimentalMaterial3Api::class)
733 @Stable
734 private class TooltipStateImpl(
735 initialIsVisible: Boolean,
736 override val isPersistent: Boolean,
737 private val mutatorMutex: MutatorMutex
738 ) : TooltipState {
739 override val transition: MutableTransitionState<Boolean> =
740 MutableTransitionState(initialIsVisible)
741
742 override val isVisible: Boolean
743 get() = transition.currentState || transition.targetState
744
745 /** continuation used to clean up */
746 private var job: (CancellableContinuation<Unit>)? = null
747
748 /**
749 * Show the tooltip associated with the current [TooltipState]. When this method is called, all
750 * of the other tooltips associated with [mutatorMutex] will be dismissed.
751 *
752 * @param mutatePriority [MutatePriority] to be used with [mutatorMutex].
753 */
754 override suspend fun show(mutatePriority: MutatePriority) {
755 val cancellableShow: suspend () -> Unit = {
756 suspendCancellableCoroutine { continuation ->
757 transition.targetState = true
758 job = continuation
759 }
760 }
761
762 // Show associated tooltip for [TooltipDuration] amount of time
763 // or until tooltip is explicitly dismissed depending on [isPersistent].
764 mutatorMutex.mutate(mutatePriority) {
765 try {
766 if (isPersistent) {
767 cancellableShow()
768 } else {
769 withTimeout(BasicTooltipDefaults.TooltipDuration) { cancellableShow() }
770 }
771 } finally {
772 if (mutatePriority != MutatePriority.PreventUserInput) {
773 // timeout or cancellation has occurred and we close out the current tooltip.
774 dismiss()
775 }
776 }
777 }
778 }
779
780 /** Dismiss the tooltip associated with this [TooltipState] if it's currently being shown. */
781 override fun dismiss() {
782 transition.targetState = false
783 }
784
785 /** Cleans up [mutatorMutex] when the tooltip associated with this state leaves Composition. */
786 override fun onDispose() {
787 job?.cancel()
788 }
789 }
790
791 /**
792 * The state that is associated with a [TooltipBox]. Each instance of [TooltipBox] should have its
793 * own [TooltipState].
794 */
795 @ExperimentalMaterial3Api
796 interface TooltipState {
797 /**
798 * The current transition state of the tooltip. Used to start the transition of the tooltip when
799 * fading in and out.
800 */
801 val transition: MutableTransitionState<Boolean>
802
803 /** [Boolean] that indicates if the tooltip is currently being shown or not. */
804 val isVisible: Boolean
805
806 /**
807 * [Boolean] that determines if the tooltip associated with this will be persistent or not. If
808 * isPersistent is true, then the tooltip will only be dismissed when the user clicks outside
809 * the bounds of the tooltip or if [TooltipState.dismiss] is called. When isPersistent is false,
810 * the tooltip will dismiss after a short duration. Ideally, this should be set to true when
811 * there is actionable content being displayed within a tooltip.
812 */
813 val isPersistent: Boolean
814
815 /**
816 * Show the tooltip associated with the current [TooltipState]. When this method is called all
817 * of the other tooltips currently being shown will dismiss.
818 *
819 * @param mutatePriority [MutatePriority] to be used.
820 */
shownull821 suspend fun show(mutatePriority: MutatePriority = MutatePriority.Default)
822
823 /** Dismiss the tooltip associated with this [TooltipState] if it's currently being shown. */
824 fun dismiss()
825
826 /** Clean up when the this state leaves Composition. */
827 fun onDispose()
828 }
829
830 @Stable
831 internal fun Modifier.textVerticalPadding(subheadExists: Boolean, actionExists: Boolean): Modifier {
832 return if (!subheadExists && !actionExists) {
833 this.padding(vertical = PlainTooltipVerticalPadding)
834 } else {
835 this.paddingFromBaseline(top = HeightFromSubheadToTextFirstLine)
836 .padding(bottom = TextBottomPadding)
837 }
838 }
839
animateTooltipnull840 internal fun Modifier.animateTooltip(transition: Transition<Boolean>): Modifier =
841 composed(
842 inspectorInfo =
843 debugInspectorInfo {
844 name = "animateTooltip"
845 properties["transition"] = transition
846 }
<lambda>null847 ) {
848 // TODO Load the motionScheme tokens from the component tokens file
849 val inOutScaleAnimationSpec = MotionSchemeKeyTokens.FastSpatial.value<Float>()
850 val inOutAlphaAnimationSpec = MotionSchemeKeyTokens.FastEffects.value<Float>()
851 val scale by
852 transition.animateFloat(
853 transitionSpec = { inOutScaleAnimationSpec },
854 label = "tooltip transition: scaling"
855 ) {
856 if (it) 1f else 0.8f
857 }
858
859 val alpha by
860 transition.animateFloat(
861 transitionSpec = { inOutAlphaAnimationSpec },
862 label = "tooltip transition: alpha"
863 ) {
864 if (it) 1f else 0f
865 }
866
867 this.graphicsLayer(scaleX = scale, scaleY = scale, alpha = alpha)
868 }
869
870 @ExperimentalMaterial3Api
drawCaretWithPathnull871 private fun CacheDrawScope.drawCaretWithPath(
872 density: Density,
873 windowContainerWidthInPx: Int,
874 containerColor: Color,
875 caretSize: DpSize,
876 anchorLayoutCoordinates: LayoutCoordinates?
877 ): DrawResult {
878 val path = Path()
879
880 if (anchorLayoutCoordinates != null) {
881 val caretHeightPx: Int
882 val caretWidthPx: Int
883 val screenWidthPx: Int
884 val tooltipAnchorSpacing: Int
885 with(density) {
886 caretHeightPx = caretSize.height.roundToPx()
887 caretWidthPx = caretSize.width.roundToPx()
888 screenWidthPx = windowContainerWidthInPx
889 tooltipAnchorSpacing = SpacingBetweenTooltipAndAnchor.roundToPx()
890 }
891 val anchorBounds = anchorLayoutCoordinates.boundsInWindow()
892 val anchorLeft = anchorBounds.left
893 val anchorRight = anchorBounds.right
894 val anchorTop = anchorBounds.top
895 val anchorMid = (anchorRight + anchorLeft) / 2
896 val anchorWidth = anchorRight - anchorLeft
897 val tooltipWidth = this.size.width
898 val tooltipHeight = this.size.height
899 val isCaretTop = anchorTop - tooltipHeight - tooltipAnchorSpacing < 0
900 val caretY =
901 if (isCaretTop) {
902 0f
903 } else {
904 tooltipHeight
905 }
906
907 // Default the caret to be in the middle
908 // caret might need to be offset depending on where
909 // the tooltip is placed relative to the anchor
910 var position: Offset =
911 if (anchorLeft - tooltipWidth / 2 + anchorWidth / 2 <= 0) {
912 Offset(anchorMid, caretY)
913 } else if (anchorRight + tooltipWidth / 2 - anchorWidth / 2 >= screenWidthPx) {
914 val anchorMidFromRightScreenEdge = screenWidthPx - anchorMid
915 val caretX = tooltipWidth - anchorMidFromRightScreenEdge
916 Offset(caretX, caretY)
917 } else {
918 Offset(tooltipWidth / 2, caretY)
919 }
920 if (anchorMid - tooltipWidth / 2 < 0) {
921 // The tooltip needs to be start aligned if it would collide with the left side of
922 // screen.
923 position = Offset(anchorMid - anchorLeft, caretY)
924 } else if (anchorMid + tooltipWidth / 2 > screenWidthPx) {
925 // The tooltip needs to be end aligned if it would collide with the right side of the
926 // screen.
927 position = Offset(anchorMid - (anchorRight - tooltipWidth), caretY)
928 }
929
930 if (isCaretTop) {
931 path.apply {
932 moveTo(x = position.x, y = position.y)
933 lineTo(x = position.x + caretWidthPx / 2, y = position.y)
934 lineTo(x = position.x, y = position.y - caretHeightPx)
935 lineTo(x = position.x - caretWidthPx / 2, y = position.y)
936 close()
937 }
938 } else {
939 path.apply {
940 moveTo(x = position.x, y = position.y)
941 lineTo(x = position.x + caretWidthPx / 2, y = position.y)
942 lineTo(x = position.x, y = position.y + caretHeightPx.toFloat())
943 lineTo(x = position.x - caretWidthPx / 2, y = position.y)
944 close()
945 }
946 }
947 }
948
949 return onDrawWithContent {
950 if (anchorLayoutCoordinates != null) {
951 drawContent()
952 drawPath(path = path, color = containerColor)
953 }
954 }
955 }
956
windowContainerWidthInPxnull957 @Composable internal expect fun windowContainerWidthInPx(): Int
958
959 internal val SpacingBetweenTooltipAndAnchor = 4.dp
960 internal val TooltipMinHeight = 24.dp
961 internal val TooltipMinWidth = 40.dp
962 private val PlainTooltipVerticalPadding = 4.dp
963 private val PlainTooltipHorizontalPadding = 8.dp
964 internal val PlainTooltipContentPadding =
965 PaddingValues(PlainTooltipHorizontalPadding, PlainTooltipVerticalPadding)
966 internal val RichTooltipHorizontalPadding = 16.dp
967 internal val HeightToSubheadFirstLine = 28.dp
968 private val HeightFromSubheadToTextFirstLine = 24.dp
969 private val TextBottomPadding = 16.dp
970 internal val ActionLabelMinHeight = 36.dp
971 internal val ActionLabelBottomPadding = 8.dp
972