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