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