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.foundation
18 
19 import androidx.compose.foundation.gestures.LongPressResult
20 import androidx.compose.foundation.gestures.awaitEachGesture
21 import androidx.compose.foundation.gestures.awaitFirstDown
22 import androidx.compose.foundation.gestures.waitForLongPress
23 import androidx.compose.foundation.layout.Box
24 import androidx.compose.runtime.Composable
25 import androidx.compose.runtime.DisposableEffect
26 import androidx.compose.runtime.Stable
27 import androidx.compose.runtime.getValue
28 import androidx.compose.runtime.mutableStateOf
29 import androidx.compose.runtime.remember
30 import androidx.compose.runtime.rememberCoroutineScope
31 import androidx.compose.runtime.setValue
32 import androidx.compose.ui.Modifier
33 import androidx.compose.ui.input.pointer.PointerEventPass
34 import androidx.compose.ui.input.pointer.PointerEventType
35 import androidx.compose.ui.input.pointer.PointerType
36 import androidx.compose.ui.input.pointer.pointerInput
37 import androidx.compose.ui.semantics.LiveRegionMode
38 import androidx.compose.ui.semantics.liveRegion
39 import androidx.compose.ui.semantics.onLongClick
40 import androidx.compose.ui.semantics.paneTitle
41 import androidx.compose.ui.semantics.semantics
42 import androidx.compose.ui.window.Popup
43 import androidx.compose.ui.window.PopupPositionProvider
44 import androidx.compose.ui.window.PopupProperties
45 import kotlinx.coroutines.CancellableContinuation
46 import kotlinx.coroutines.CoroutineScope
47 import kotlinx.coroutines.coroutineScope
48 import kotlinx.coroutines.launch
49 import kotlinx.coroutines.suspendCancellableCoroutine
50 import kotlinx.coroutines.withTimeout
51 
52 /**
53  * BasicTooltipBox that wraps a composable with a tooltip.
54  *
55  * Tooltip that provides a descriptive message for an anchor. It can be used to call the users
56  * attention to the anchor.
57  *
58  * @param positionProvider [PopupPositionProvider] that will be used to place the tooltip relative
59  *   to the anchor content.
60  * @param tooltip the composable that will be used to populate the tooltip's content.
61  * @param state handles the state of the tooltip's visibility.
62  * @param modifier the [Modifier] to be applied to this BasicTooltipBox.
63  * @param focusable [Boolean] that determines if the tooltip is focusable. When true, the tooltip
64  *   will consume touch events while it's shown and will have accessibility focus move to the first
65  *   element of the component. When false, the tooltip won't consume touch events while it's shown
66  *   but assistive-tech users will need to swipe or drag to get to the first element of the
67  *   component.
68  * @param enableUserInput [Boolean] which determines if this BasicTooltipBox will handle long press
69  *   and mouse hover to trigger the tooltip through the state provided.
70  * @param content the composable that the tooltip will anchor to.
71  */
72 @Composable
73 @ExperimentalFoundationApi
74 fun BasicTooltipBox(
75     positionProvider: PopupPositionProvider,
76     tooltip: @Composable () -> Unit,
77     state: BasicTooltipState,
78     modifier: Modifier = Modifier,
79     focusable: Boolean = true,
80     enableUserInput: Boolean = true,
81     content: @Composable () -> Unit
82 ) {
83     val scope = rememberCoroutineScope()
84     Box {
85         if (state.isVisible) {
86             TooltipPopup(
87                 positionProvider = positionProvider,
88                 state = state,
89                 scope = scope,
90                 focusable = focusable,
91                 content = tooltip
92             )
93         }
94 
95         WrappedAnchor(
96             enableUserInput = enableUserInput,
97             state = state,
98             modifier = modifier,
99             content = content
100         )
101     }
102 
103     DisposableEffect(state) { onDispose { state.onDispose() } }
104 }
105 
106 @Composable
107 @OptIn(ExperimentalFoundationApi::class)
WrappedAnchornull108 private fun WrappedAnchor(
109     enableUserInput: Boolean,
110     state: BasicTooltipState,
111     modifier: Modifier = Modifier,
112     content: @Composable () -> Unit
113 ) {
114     val scope = rememberCoroutineScope()
115     val longPressLabel = BasicTooltipStrings.label()
116     Box(
117         modifier =
118             modifier
119                 .handleGestures(enableUserInput, state)
120                 .anchorSemantics(longPressLabel, enableUserInput, state, scope)
121     ) {
122         content()
123     }
124 }
125 
126 @Composable
127 @OptIn(ExperimentalFoundationApi::class)
TooltipPopupnull128 private fun TooltipPopup(
129     positionProvider: PopupPositionProvider,
130     state: BasicTooltipState,
131     scope: CoroutineScope,
132     focusable: Boolean,
133     content: @Composable () -> Unit
134 ) {
135     val tooltipDescription = BasicTooltipStrings.description()
136     Popup(
137         popupPositionProvider = positionProvider,
138         onDismissRequest = {
139             if (state.isVisible) {
140                 scope.launch { state.dismiss() }
141             }
142         },
143         properties = PopupProperties(focusable = focusable)
144     ) {
145         Box(
146             modifier =
147                 Modifier.semantics {
148                     liveRegion = LiveRegionMode.Assertive
149                     paneTitle = tooltipDescription
150                 }
151         ) {
152             content()
153         }
154     }
155 }
156 
157 @OptIn(ExperimentalFoundationApi::class)
handleGesturesnull158 private fun Modifier.handleGestures(enabled: Boolean, state: BasicTooltipState): Modifier =
159     if (enabled) {
160         this.pointerInput(state) {
161                 coroutineScope {
162                     awaitEachGesture {
163                         val pass = PointerEventPass.Initial
164 
165                         // wait for the first down press
166                         val inputType = awaitFirstDown(pass = pass).type
167 
168                         if (inputType == PointerType.Touch || inputType == PointerType.Stylus) {
169                             val longPress = waitForLongPress(pass = pass)
170                             if (longPress is LongPressResult.Success) {
171                                 // handle long press - Show the tooltip
172                                 launch { state.show(MutatePriority.UserInput) }
173 
174                                 // consume the children's click handling
175                                 val changes = awaitPointerEvent(pass = pass).changes
176                                 for (i in 0 until changes.size) {
177                                     changes[i].consume()
178                                 }
179                             }
180                         }
181                     }
182                 }
183             }
184             .pointerInput(state) {
185                 coroutineScope {
186                     awaitPointerEventScope {
187                         val pass = PointerEventPass.Main
188 
189                         while (true) {
190                             val event = awaitPointerEvent(pass)
191                             val inputType = event.changes[0].type
192                             if (inputType == PointerType.Mouse) {
193                                 when (event.type) {
194                                     PointerEventType.Enter -> {
195                                         launch { state.show(MutatePriority.UserInput) }
196                                     }
197                                     PointerEventType.Exit -> {
198                                         state.dismiss()
199                                     }
200                                 }
201                             }
202                         }
203                     }
204                 }
205             }
206     } else this
207 
208 @OptIn(ExperimentalFoundationApi::class)
anchorSemanticsnull209 private fun Modifier.anchorSemantics(
210     label: String,
211     enabled: Boolean,
212     state: BasicTooltipState,
213     scope: CoroutineScope
214 ): Modifier =
215     if (enabled) {
216         this.semantics(mergeDescendants = true) {
217             onLongClick(
218                 label = label,
219                 action = {
220                     scope.launch { state.show() }
221                     true
222                 }
223             )
224         }
225     } else this
226 
227 /**
228  * Create and remember the default [BasicTooltipState].
229  *
230  * @param initialIsVisible the initial value for the tooltip's visibility when drawn.
231  * @param isPersistent [Boolean] that determines if the tooltip associated with this will be
232  *   persistent or not. If isPersistent is true, then the tooltip will only be dismissed when the
233  *   user clicks outside the bounds of the tooltip or if [BasicTooltipState.dismiss] is called. When
234  *   isPersistent is false, the tooltip will dismiss after a short duration. Ideally, this should be
235  *   set to true when there is actionable content being displayed within a tooltip.
236  * @param mutatorMutex [MutatorMutex] used to ensure that for all of the tooltips associated with
237  *   the mutator mutex, only one will be shown on the screen at any time.
238  */
239 @Composable
240 @ExperimentalFoundationApi
rememberBasicTooltipStatenull241 fun rememberBasicTooltipState(
242     initialIsVisible: Boolean = false,
243     isPersistent: Boolean = true,
244     mutatorMutex: MutatorMutex = BasicTooltipDefaults.GlobalMutatorMutex
245 ): BasicTooltipState =
246     remember(isPersistent, mutatorMutex) {
247         BasicTooltipStateImpl(
248             initialIsVisible = initialIsVisible,
249             isPersistent = isPersistent,
250             mutatorMutex = mutatorMutex
251         )
252     }
253 
254 /**
255  * Constructor extension function for [BasicTooltipState]
256  *
257  * @param initialIsVisible the initial value for the tooltip's visibility when drawn.
258  * @param isPersistent [Boolean] that determines if the tooltip associated with this will be
259  *   persistent or not. If isPersistent is true, then the tooltip will only be dismissed when the
260  *   user clicks outside the bounds of the tooltip or if [BasicTooltipState.dismiss] is called. When
261  *   isPersistent is false, the tooltip will dismiss after a short duration. Ideally, this should be
262  *   set to true when there is actionable content being displayed within a tooltip.
263  * @param mutatorMutex [MutatorMutex] used to ensure that for all of the tooltips associated with
264  *   the mutator mutex, only one will be shown on the screen at any time.
265  */
266 @Stable
267 @ExperimentalFoundationApi
BasicTooltipStatenull268 fun BasicTooltipState(
269     initialIsVisible: Boolean = false,
270     isPersistent: Boolean = true,
271     mutatorMutex: MutatorMutex = BasicTooltipDefaults.GlobalMutatorMutex
272 ): BasicTooltipState =
273     BasicTooltipStateImpl(
274         initialIsVisible = initialIsVisible,
275         isPersistent = isPersistent,
276         mutatorMutex = mutatorMutex
277     )
278 
279 @Stable
280 @OptIn(ExperimentalFoundationApi::class)
281 private class BasicTooltipStateImpl(
282     initialIsVisible: Boolean,
283     override val isPersistent: Boolean,
284     private val mutatorMutex: MutatorMutex
285 ) : BasicTooltipState {
286     override var isVisible by mutableStateOf(initialIsVisible)
287 
288     /** continuation used to clean up */
289     private var job: (CancellableContinuation<Unit>)? = null
290 
291     /**
292      * Show the tooltip associated with the current [BasicTooltipState]. When this method is called,
293      * all of the other tooltips associated with [mutatorMutex] will be dismissed.
294      *
295      * @param mutatePriority [MutatePriority] to be used with [mutatorMutex].
296      */
297     override suspend fun show(mutatePriority: MutatePriority) {
298         val cancellableShow: suspend () -> Unit = {
299             suspendCancellableCoroutine { continuation ->
300                 isVisible = true
301                 job = continuation
302             }
303         }
304 
305         // Show associated tooltip for [TooltipDuration] amount of time
306         // or until tooltip is explicitly dismissed depending on [isPersistent].
307         mutatorMutex.mutate(mutatePriority) {
308             try {
309                 if (isPersistent) {
310                     cancellableShow()
311                 } else {
312                     withTimeout(BasicTooltipDefaults.TooltipDuration) { cancellableShow() }
313                 }
314             } finally {
315                 // timeout or cancellation has occurred
316                 // and we close out the current tooltip.
317                 isVisible = false
318             }
319         }
320     }
321 
322     /**
323      * Dismiss the tooltip associated with this [BasicTooltipState] if it's currently being shown.
324      */
325     override fun dismiss() {
326         isVisible = false
327     }
328 
329     /** Cleans up [mutatorMutex] when the tooltip associated with this state leaves Composition. */
330     override fun onDispose() {
331         job?.cancel()
332     }
333 }
334 
335 /**
336  * The state that is associated with an instance of a tooltip. Each instance of tooltips should have
337  * its own [BasicTooltipState].
338  */
339 @Stable
340 @ExperimentalFoundationApi
341 interface BasicTooltipState {
342     /** [Boolean] that indicates if the tooltip is currently being shown or not. */
343     val isVisible: Boolean
344 
345     /**
346      * [Boolean] that determines if the tooltip associated with this will be persistent or not. If
347      * isPersistent is true, then the tooltip will only be dismissed when the user clicks outside
348      * the bounds of the tooltip or if [BasicTooltipState.dismiss] is called. When isPersistent is
349      * false, the tooltip will dismiss after a short duration. Ideally, this should be set to true
350      * when there is actionable content being displayed within a tooltip.
351      */
352     val isPersistent: Boolean
353 
354     /**
355      * Show the tooltip associated with the current [BasicTooltipState]. When this method is called
356      * all of the other tooltips currently being shown will dismiss.
357      *
358      * @param mutatePriority [MutatePriority] to be used.
359      */
shownull360     suspend fun show(mutatePriority: MutatePriority = MutatePriority.Default)
361 
362     /**
363      * Dismiss the tooltip associated with this [BasicTooltipState] if it's currently being shown.
364      */
365     fun dismiss()
366 
367     /** Clean up when the this state leaves Composition. */
368     fun onDispose()
369 }
370 
371 /** BasicTooltip defaults that contain default values for tooltips created. */
372 @ExperimentalFoundationApi
373 object BasicTooltipDefaults {
374     /** The global/default [MutatorMutex] used to sync Tooltips. */
375     val GlobalMutatorMutex: MutatorMutex = MutatorMutex()
376 
377     /**
378      * The default duration, in milliseconds, that non-persistent tooltips will show on the screen
379      * before dismissing.
380      */
381     const val TooltipDuration = 1500L
382 }
383 
384 @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
385 internal expect object BasicTooltipStrings {
labelnull386     @Composable fun label(): String
387 
388     @Composable fun description(): String
389 }
390