1 /*
<lambda>null2 * Copyright 2019 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.collection.mutableLongObjectMapOf
20 import androidx.compose.foundation.gestures.PressGestureScope
21 import androidx.compose.foundation.gestures.ScrollableContainerNode
22 import androidx.compose.foundation.gestures.detectTapAndPress
23 import androidx.compose.foundation.gestures.detectTapGestures
24 import androidx.compose.foundation.interaction.HoverInteraction
25 import androidx.compose.foundation.interaction.MutableInteractionSource
26 import androidx.compose.foundation.interaction.PressInteraction
27 import androidx.compose.foundation.internal.requirePrecondition
28 import androidx.compose.runtime.remember
29 import androidx.compose.ui.Modifier
30 import androidx.compose.ui.composed
31 import androidx.compose.ui.focus.Focusability
32 import androidx.compose.ui.geometry.Offset
33 import androidx.compose.ui.hapticfeedback.HapticFeedback
34 import androidx.compose.ui.hapticfeedback.HapticFeedbackType
35 import androidx.compose.ui.input.key.Key
36 import androidx.compose.ui.input.key.KeyEvent
37 import androidx.compose.ui.input.key.KeyEventType.Companion.KeyDown
38 import androidx.compose.ui.input.key.KeyEventType.Companion.KeyUp
39 import androidx.compose.ui.input.key.KeyInputModifierNode
40 import androidx.compose.ui.input.key.key
41 import androidx.compose.ui.input.key.type
42 import androidx.compose.ui.input.pointer.PointerEvent
43 import androidx.compose.ui.input.pointer.PointerEventPass
44 import androidx.compose.ui.input.pointer.PointerEventType
45 import androidx.compose.ui.input.pointer.PointerInputScope
46 import androidx.compose.ui.input.pointer.SuspendingPointerInputModifierNode
47 import androidx.compose.ui.node.CompositionLocalConsumerModifierNode
48 import androidx.compose.ui.node.DelegatableNode
49 import androidx.compose.ui.node.DelegatingNode
50 import androidx.compose.ui.node.ModifierNodeElement
51 import androidx.compose.ui.node.ObserverModifierNode
52 import androidx.compose.ui.node.PointerInputModifierNode
53 import androidx.compose.ui.node.SemanticsModifierNode
54 import androidx.compose.ui.node.TraversableNode
55 import androidx.compose.ui.node.currentValueOf
56 import androidx.compose.ui.node.invalidateSemantics
57 import androidx.compose.ui.node.observeReads
58 import androidx.compose.ui.node.traverseAncestors
59 import androidx.compose.ui.platform.InspectorInfo
60 import androidx.compose.ui.platform.LocalHapticFeedback
61 import androidx.compose.ui.platform.LocalViewConfiguration
62 import androidx.compose.ui.platform.debugInspectorInfo
63 import androidx.compose.ui.semantics.Role
64 import androidx.compose.ui.semantics.SemanticsPropertyReceiver
65 import androidx.compose.ui.semantics.disabled
66 import androidx.compose.ui.semantics.onClick
67 import androidx.compose.ui.semantics.onLongClick
68 import androidx.compose.ui.semantics.role
69 import androidx.compose.ui.unit.IntSize
70 import androidx.compose.ui.unit.center
71 import androidx.compose.ui.unit.toOffset
72 import kotlinx.coroutines.Job
73 import kotlinx.coroutines.cancelAndJoin
74 import kotlinx.coroutines.coroutineScope
75 import kotlinx.coroutines.delay
76 import kotlinx.coroutines.launch
77
78 /**
79 * Configure component to receive clicks via input or accessibility "click" event.
80 *
81 * Add this modifier to the element to make it clickable within its bounds and show a default
82 * indication when it's pressed.
83 *
84 * This version has no [MutableInteractionSource] or [Indication] parameters, the default indication
85 * from [LocalIndication] will be used. To specify [MutableInteractionSource] or [Indication], use
86 * the other overload.
87 *
88 * If you are only creating this clickable modifier inside composition, consider using the other
89 * overload and explicitly passing `LocalIndication.current` for improved performance. For more
90 * information see the documentation on the other overload.
91 *
92 * If you need to support double click or long click alongside the single click, consider using
93 * [combinedClickable].
94 *
95 * ***Note*** Any removal operations on Android Views from `clickable` should wrap `onClick` in a
96 * `post { }` block to guarantee the event dispatch completes before executing the removal. (You do
97 * not need to do this when removing a composable because Compose guarantees it completes via the
98 * snapshot state system.)
99 *
100 * @sample androidx.compose.foundation.samples.ClickableSample
101 * @param enabled Controls the enabled state. When `false`, [onClick], and this modifier will appear
102 * disabled for accessibility services
103 * @param onClickLabel semantic / accessibility label for the [onClick] action
104 * @param role the type of user interface element. Accessibility services might use this to describe
105 * the element or do customizations
106 * @param onClick will be called when user clicks on the element
107 */
108 @Deprecated(
109 message =
110 "Replaced with new overload that only supports IndicationNodeFactory instances inside LocalIndication, and does not use composed",
111 level = DeprecationLevel.HIDDEN
112 )
113 fun Modifier.clickable(
114 enabled: Boolean = true,
115 onClickLabel: String? = null,
116 role: Role? = null,
117 onClick: () -> Unit
118 ) =
119 composed(
120 inspectorInfo =
121 debugInspectorInfo {
122 name = "clickable"
123 properties["enabled"] = enabled
124 properties["onClickLabel"] = onClickLabel
125 properties["role"] = role
126 properties["onClick"] = onClick
127 }
<lambda>null128 ) {
129 val localIndication = LocalIndication.current
130 val interactionSource =
131 if (localIndication is IndicationNodeFactory) {
132 // We can fast path here as it will be created inside clickable lazily
133 null
134 } else {
135 // We need an interaction source to pass between the indication modifier and
136 // clickable, so
137 // by creating here we avoid another composed down the line
138 remember { MutableInteractionSource() }
139 }
140 Modifier.clickable(
141 enabled = enabled,
142 onClickLabel = onClickLabel,
143 onClick = onClick,
144 role = role,
145 indication = localIndication,
146 interactionSource = interactionSource
147 )
148 }
149
150 /**
151 * Configure component to receive clicks via input or accessibility "click" event.
152 *
153 * Add this modifier to the element to make it clickable within its bounds and show a default
154 * indication when it's pressed.
155 *
156 * This overload will use the [Indication] from [LocalIndication]. Use the other overload to
157 * explicitly provide an [Indication] instance. Note that this overload only supports
158 * [IndicationNodeFactory] instances provided through [LocalIndication] - it is strongly recommended
159 * to migrate to [IndicationNodeFactory], but you can use the other overload if you still need to
160 * support [Indication] instances that are not [IndicationNodeFactory].
161 *
162 * If [interactionSource] is `null`, an internal [MutableInteractionSource] will be lazily created
163 * only when needed. This reduces the performance cost of clickable during composition, as creating
164 * the [indication] can be delayed until there is an incoming
165 * [androidx.compose.foundation.interaction.Interaction]. If you are only passing a remembered
166 * [MutableInteractionSource] and you are never using it outside of clickable, it is recommended to
167 * instead provide `null` to enable lazy creation. If you need the [Indication] to be created
168 * eagerly, provide a remembered [MutableInteractionSource].
169 *
170 * If you need to support double click or long click alongside the single click, consider using
171 * [combinedClickable].
172 *
173 * ***Note*** Any removal operations on Android Views from `clickable` should wrap `onClick` in a
174 * `post { }` block to guarantee the event dispatch completes before executing the removal. (You do
175 * not need to do this when removing a composable because Compose guarantees it completes via the
176 * snapshot state system.)
177 *
178 * @sample androidx.compose.foundation.samples.ClickableSample
179 * @param enabled Controls the enabled state. When `false`, [onClick], and this modifier will appear
180 * disabled for accessibility services
181 * @param onClickLabel semantic / accessibility label for the [onClick] action
182 * @param role the type of user interface element. Accessibility services might use this to describe
183 * the element or do customizations
184 * @param interactionSource [MutableInteractionSource] that will be used to dispatch
185 * [PressInteraction.Press] when this clickable is pressed. If `null`, an internal
186 * [MutableInteractionSource] will be created if needed.
187 * @param onClick will be called when user clicks on the element
188 */
clickablenull189 fun Modifier.clickable(
190 enabled: Boolean = true,
191 onClickLabel: String? = null,
192 role: Role? = null,
193 interactionSource: MutableInteractionSource? = null,
194 onClick: () -> Unit
195 ): Modifier {
196 @OptIn(ExperimentalFoundationApi::class)
197 return if (ComposeFoundationFlags.isNonComposedClickableEnabled) {
198 this.then(
199 ClickableElement(
200 interactionSource = interactionSource,
201 indicationNodeFactory = null,
202 useLocalIndication = true,
203 enabled = enabled,
204 onClickLabel = onClickLabel,
205 role = role,
206 onClick = onClick
207 )
208 )
209 } else {
210 composed(
211 inspectorInfo =
212 debugInspectorInfo {
213 name = "clickable"
214 properties["enabled"] = enabled
215 properties["onClickLabel"] = onClickLabel
216 properties["role"] = role
217 properties["interactionSource"] = interactionSource
218 properties["onClick"] = onClick
219 }
220 ) {
221 val localIndication = LocalIndication.current
222 val intSource =
223 interactionSource
224 ?: if (localIndication is IndicationNodeFactory) {
225 // We can fast path here as it will be created inside clickable lazily
226 null
227 } else {
228 // We need an interaction source to pass between the indication modifier and
229 // clickable, so
230 // by creating here we avoid another composed down the line
231 remember { MutableInteractionSource() }
232 }
233 Modifier.clickable(
234 enabled = enabled,
235 onClickLabel = onClickLabel,
236 onClick = onClick,
237 role = role,
238 indication = localIndication,
239 interactionSource = intSource
240 )
241 }
242 }
243 }
244
245 /**
246 * Configure component to receive clicks via input or accessibility "click" event.
247 *
248 * Add this modifier to the element to make it clickable within its bounds and show an indication as
249 * specified in [indication] parameter.
250 *
251 * If [interactionSource] is `null`, and [indication] is an [IndicationNodeFactory], an internal
252 * [MutableInteractionSource] will be lazily created along with the [indication] only when needed.
253 * This reduces the performance cost of clickable during composition, as creating the [indication]
254 * can be delayed until there is an incoming [androidx.compose.foundation.interaction.Interaction].
255 * If you are only passing a remembered [MutableInteractionSource] and you are never using it
256 * outside of clickable, it is recommended to instead provide `null` to enable lazy creation. If you
257 * need [indication] to be created eagerly, provide a remembered [MutableInteractionSource].
258 *
259 * If [indication] is _not_ an [IndicationNodeFactory], and instead implements the deprecated
260 * [Indication.rememberUpdatedInstance] method, you should explicitly pass a remembered
261 * [MutableInteractionSource] as a parameter for [interactionSource] instead of `null`, as this
262 * cannot be lazily created inside clickable.
263 *
264 * If you need to support double click or long click alongside the single click, consider using
265 * [combinedClickable].
266 *
267 * ***Note*** Any removal operations on Android Views from `clickable` should wrap `onClick` in a
268 * `post { }` block to guarantee the event dispatch completes before executing the removal. (You do
269 * not need to do this when removing a composable because Compose guarantees it completes via the
270 * snapshot state system.)
271 *
272 * @sample androidx.compose.foundation.samples.ClickableSample
273 * @param interactionSource [MutableInteractionSource] that will be used to dispatch
274 * [PressInteraction.Press] when this clickable is pressed. If `null`, an internal
275 * [MutableInteractionSource] will be created if needed.
276 * @param indication indication to be shown when modified element is pressed. By default, indication
277 * from [LocalIndication] will be used. Pass `null` to show no indication, or current value from
278 * [LocalIndication] to show theme default
279 * @param enabled Controls the enabled state. When `false`, [onClick], and this modifier will appear
280 * disabled for accessibility services
281 * @param onClickLabel semantic / accessibility label for the [onClick] action
282 * @param role the type of user interface element. Accessibility services might use this to describe
283 * the element or do customizations
284 * @param onClick will be called when user clicks on the element
285 */
clickablenull286 fun Modifier.clickable(
287 interactionSource: MutableInteractionSource?,
288 indication: Indication?,
289 enabled: Boolean = true,
290 onClickLabel: String? = null,
291 role: Role? = null,
292 onClick: () -> Unit
293 ) =
294 clickableWithIndicationIfNeeded(
295 interactionSource = interactionSource,
296 indication = indication
297 ) { intSource, indicationNodeFactory ->
298 ClickableElement(
299 interactionSource = intSource,
300 indicationNodeFactory = indicationNodeFactory,
301 useLocalIndication = false,
302 enabled = enabled,
303 onClickLabel = onClickLabel,
304 role = role,
305 onClick = onClick
306 )
307 }
308
309 /**
310 * Configure component to receive clicks, double clicks and long clicks via input or accessibility
311 * "click" event.
312 *
313 * Add this modifier to the element to make it clickable within its bounds.
314 *
315 * If you need only click handling, and no double or long clicks, consider using [clickable]
316 *
317 * This version has no [MutableInteractionSource] or [Indication] parameters, the default indication
318 * from [LocalIndication] will be used. To specify [MutableInteractionSource] or [Indication], use
319 * the other overload.
320 *
321 * If you are only creating this combinedClickable modifier inside composition, consider using the
322 * other overload and explicitly passing `LocalIndication.current` for improved performance. For
323 * more information see the documentation on the other overload.
324 *
325 * Note, if the modifier instance gets re-used between a key down and key up events, the ongoing
326 * input will be aborted.
327 *
328 * ***Note*** Any removal operations on Android Views from `clickable` should wrap `onClick` in a
329 * `post { }` block to guarantee the event dispatch completes before executing the removal. (You do
330 * not need to do this when removing a composable because Compose guarantees it completes via the
331 * snapshot state system.)
332 *
333 * @sample androidx.compose.foundation.samples.ClickableSample
334 * @param enabled Controls the enabled state. When `false`, [onClick], [onLongClick] or
335 * [onDoubleClick] won't be invoked
336 * @param onClickLabel semantic / accessibility label for the [onClick] action
337 * @param role the type of user interface element. Accessibility services might use this to describe
338 * the element or do customizations
339 * @param onLongClickLabel semantic / accessibility label for the [onLongClick] action
340 * @param onLongClick will be called when user long presses on the element
341 * @param onDoubleClick will be called when user double clicks on the element
342 * @param hapticFeedbackEnabled whether to use the default [HapticFeedback] behavior
343 * @param onClick will be called when user clicks on the element
344 */
345 @Deprecated(
346 message =
347 "Replaced with new overload that only supports IndicationNodeFactory instances inside LocalIndication, and does not use composed",
348 level = DeprecationLevel.HIDDEN
349 )
combinedClickablenull350 fun Modifier.combinedClickable(
351 enabled: Boolean = true,
352 onClickLabel: String? = null,
353 role: Role? = null,
354 onLongClickLabel: String? = null,
355 onLongClick: (() -> Unit)? = null,
356 onDoubleClick: (() -> Unit)? = null,
357 hapticFeedbackEnabled: Boolean = true,
358 onClick: () -> Unit
359 ) =
360 composed(
361 inspectorInfo =
362 debugInspectorInfo {
363 name = "combinedClickable"
364 properties["enabled"] = enabled
365 properties["onClickLabel"] = onClickLabel
366 properties["role"] = role
367 properties["onClick"] = onClick
368 properties["onDoubleClick"] = onDoubleClick
369 properties["onLongClick"] = onLongClick
370 properties["onLongClickLabel"] = onLongClickLabel
371 properties["hapticFeedbackEnabled"] = hapticFeedbackEnabled
372 }
<lambda>null373 ) {
374 val localIndication = LocalIndication.current
375 val interactionSource =
376 if (localIndication is IndicationNodeFactory) {
377 // We can fast path here as it will be created inside clickable lazily
378 null
379 } else {
380 // We need an interaction source to pass between the indication modifier and
381 // clickable, so
382 // by creating here we avoid another composed down the line
383 remember { MutableInteractionSource() }
384 }
385 Modifier.combinedClickable(
386 enabled = enabled,
387 onClickLabel = onClickLabel,
388 onLongClickLabel = onLongClickLabel,
389 onLongClick = onLongClick,
390 onDoubleClick = onDoubleClick,
391 onClick = onClick,
392 role = role,
393 indication = localIndication,
394 interactionSource = interactionSource,
395 hapticFeedbackEnabled = hapticFeedbackEnabled
396 )
397 }
398
399 /**
400 * Configure component to receive clicks, double clicks and long clicks via input or accessibility
401 * "click" event.
402 *
403 * Add this modifier to the element to make it clickable within its bounds.
404 *
405 * If you need only click handling, and no double or long clicks, consider using [clickable]
406 *
407 * This overload will use the [Indication] from [LocalIndication]. Use the other overload to
408 * explicitly provide an [Indication] instance. Note that this overload only supports
409 * [IndicationNodeFactory] instances provided through [LocalIndication] - it is strongly recommended
410 * to migrate to [IndicationNodeFactory], but you can use the other overload if you still need to
411 * support [Indication] instances that are not [IndicationNodeFactory].
412 *
413 * If [interactionSource] is `null`, an internal [MutableInteractionSource] will be lazily created
414 * only when needed. This reduces the performance cost of combinedClickable during composition, as
415 * creating the [indication] can be delayed until there is an incoming
416 * [androidx.compose.foundation.interaction.Interaction]. If you are only passing a remembered
417 * [MutableInteractionSource] and you are never using it outside of combinedClickable, it is
418 * recommended to instead provide `null` to enable lazy creation. If you need the [Indication] to be
419 * created eagerly, provide a remembered [MutableInteractionSource].
420 *
421 * Note, if the modifier instance gets re-used between a key down and key up events, the ongoing
422 * input will be aborted.
423 *
424 * ***Note*** Any removal operations on Android Views from `clickable` should wrap `onClick` in a
425 * `post { }` block to guarantee the event dispatch completes before executing the removal. (You do
426 * not need to do this when removing a composable because Compose guarantees it completes via the
427 * snapshot state system.)
428 *
429 * @sample androidx.compose.foundation.samples.ClickableSample
430 * @param enabled Controls the enabled state. When `false`, [onClick], [onLongClick] or
431 * [onDoubleClick] won't be invoked
432 * @param onClickLabel semantic / accessibility label for the [onClick] action
433 * @param role the type of user interface element. Accessibility services might use this to describe
434 * the element or do customizations
435 * @param onLongClickLabel semantic / accessibility label for the [onLongClick] action
436 * @param onLongClick will be called when user long presses on the element
437 * @param onDoubleClick will be called when user double clicks on the element
438 * @param hapticFeedbackEnabled whether to use the default [HapticFeedback] behavior
439 * @param interactionSource [MutableInteractionSource] that will be used to dispatch
440 * [PressInteraction.Press] when this clickable is pressed. If `null`, an internal
441 * [MutableInteractionSource] will be created if needed.
442 * @param onClick will be called when user clicks on the element
443 */
combinedClickablenull444 fun Modifier.combinedClickable(
445 enabled: Boolean = true,
446 onClickLabel: String? = null,
447 role: Role? = null,
448 onLongClickLabel: String? = null,
449 onLongClick: (() -> Unit)? = null,
450 onDoubleClick: (() -> Unit)? = null,
451 hapticFeedbackEnabled: Boolean = true,
452 interactionSource: MutableInteractionSource? = null,
453 onClick: () -> Unit
454 ): Modifier {
455 @OptIn(ExperimentalFoundationApi::class)
456 return if (ComposeFoundationFlags.isNonComposedClickableEnabled) {
457 this.then(
458 CombinedClickableElement(
459 enabled = enabled,
460 onClickLabel = onClickLabel,
461 onLongClickLabel = onLongClickLabel,
462 onLongClick = onLongClick,
463 onDoubleClick = onDoubleClick,
464 onClick = onClick,
465 role = role,
466 interactionSource = interactionSource,
467 indicationNodeFactory = null,
468 useLocalIndication = true,
469 hapticFeedbackEnabled = hapticFeedbackEnabled
470 )
471 )
472 } else
473 composed(
474 inspectorInfo =
475 debugInspectorInfo {
476 name = "combinedClickable"
477 properties["enabled"] = enabled
478 properties["onClickLabel"] = onClickLabel
479 properties["role"] = role
480 properties["onClick"] = onClick
481 properties["onDoubleClick"] = onDoubleClick
482 properties["onLongClick"] = onLongClick
483 properties["onLongClickLabel"] = onLongClickLabel
484 properties["hapticFeedbackEnabled"] = hapticFeedbackEnabled
485 }
486 ) {
487 val localIndication = LocalIndication.current
488 val intSource =
489 interactionSource
490 ?: if (localIndication is IndicationNodeFactory) {
491 // We can fast path here as it will be created inside clickable lazily
492 null
493 } else {
494 // We need an interaction source to pass between the indication modifier and
495 // clickable, so
496 // by creating here we avoid another composed down the line
497 remember { MutableInteractionSource() }
498 }
499 Modifier.combinedClickable(
500 enabled = enabled,
501 onClickLabel = onClickLabel,
502 onLongClickLabel = onLongClickLabel,
503 onLongClick = onLongClick,
504 onDoubleClick = onDoubleClick,
505 onClick = onClick,
506 role = role,
507 indication = localIndication,
508 interactionSource = intSource,
509 hapticFeedbackEnabled = hapticFeedbackEnabled
510 )
511 }
512 }
513
514 @Deprecated(message = "Maintained for binary compatibility", level = DeprecationLevel.HIDDEN)
combinedClickablenull515 fun Modifier.combinedClickable(
516 enabled: Boolean = true,
517 onClickLabel: String? = null,
518 role: Role? = null,
519 onLongClickLabel: String? = null,
520 onLongClick: (() -> Unit)? = null,
521 onDoubleClick: (() -> Unit)? = null,
522 onClick: () -> Unit
523 ) =
524 composed(
525 inspectorInfo =
526 debugInspectorInfo {
527 name = "combinedClickable"
528 properties["enabled"] = enabled
529 properties["onClickLabel"] = onClickLabel
530 properties["role"] = role
531 properties["onClick"] = onClick
532 properties["onDoubleClick"] = onDoubleClick
533 properties["onLongClick"] = onLongClick
534 properties["onLongClickLabel"] = onLongClickLabel
535 }
<lambda>null536 ) {
537 val localIndication = LocalIndication.current
538 val interactionSource =
539 if (localIndication is IndicationNodeFactory) {
540 // We can fast path here as it will be created inside clickable lazily
541 null
542 } else {
543 // We need an interaction source to pass between the indication modifier and
544 // clickable, so
545 // by creating here we avoid another composed down the line
546 remember { MutableInteractionSource() }
547 }
548 Modifier.combinedClickable(
549 enabled = enabled,
550 onClickLabel = onClickLabel,
551 onLongClickLabel = onLongClickLabel,
552 onLongClick = onLongClick,
553 onDoubleClick = onDoubleClick,
554 onClick = onClick,
555 role = role,
556 indication = localIndication,
557 interactionSource = interactionSource,
558 hapticFeedbackEnabled = true
559 )
560 }
561
562 /**
563 * Configure component to receive clicks, double clicks and long clicks via input or accessibility
564 * "click" event.
565 *
566 * Add this modifier to the element to make it clickable within its bounds.
567 *
568 * If you need only click handling, and no double or long clicks, consider using [clickable].
569 *
570 * Add this modifier to the element to make it clickable within its bounds.
571 *
572 * If [interactionSource] is `null`, and [indication] is an [IndicationNodeFactory], an internal
573 * [MutableInteractionSource] will be lazily created along with the [indication] only when needed.
574 * This reduces the performance cost of clickable during composition, as creating the [indication]
575 * can be delayed until there is an incoming [androidx.compose.foundation.interaction.Interaction].
576 * If you are only passing a remembered [MutableInteractionSource] and you are never using it
577 * outside of clickable, it is recommended to instead provide `null` to enable lazy creation. If you
578 * need [indication] to be created eagerly, provide a remembered [MutableInteractionSource].
579 *
580 * If [indication] is _not_ an [IndicationNodeFactory], and instead implements the deprecated
581 * [Indication.rememberUpdatedInstance] method, you should explicitly pass a remembered
582 * [MutableInteractionSource] as a parameter for [interactionSource] instead of `null`, as this
583 * cannot be lazily created inside clickable.
584 *
585 * Note, if the modifier instance gets re-used between a key down and key up events, the ongoing
586 * input will be aborted.
587 *
588 * ***Note*** Any removal operations on Android Views from `clickable` should wrap `onClick` in a
589 * `post { }` block to guarantee the event dispatch completes before executing the removal. (You do
590 * not need to do this when removing a composable because Compose guarantees it completes via the
591 * snapshot state system.)
592 *
593 * @sample androidx.compose.foundation.samples.ClickableSample
594 * @param interactionSource [MutableInteractionSource] that will be used to emit
595 * [PressInteraction.Press] when this clickable is pressed. If `null`, an internal
596 * [MutableInteractionSource] will be created if needed.
597 * @param indication indication to be shown when modified element is pressed. By default, indication
598 * from [LocalIndication] will be used. Pass `null` to show no indication, or current value from
599 * [LocalIndication] to show theme default
600 * @param enabled Controls the enabled state. When `false`, [onClick], [onLongClick] or
601 * [onDoubleClick] won't be invoked
602 * @param onClickLabel semantic / accessibility label for the [onClick] action
603 * @param role the type of user interface element. Accessibility services might use this to describe
604 * the element or do customizations
605 * @param onLongClickLabel semantic / accessibility label for the [onLongClick] action
606 * @param onLongClick will be called when user long presses on the element
607 * @param onDoubleClick will be called when user double clicks on the element
608 * @param hapticFeedbackEnabled whether to use the default [HapticFeedback] behavior
609 * @param onClick will be called when user clicks on the element
610 */
combinedClickablenull611 fun Modifier.combinedClickable(
612 interactionSource: MutableInteractionSource?,
613 indication: Indication?,
614 enabled: Boolean = true,
615 onClickLabel: String? = null,
616 role: Role? = null,
617 onLongClickLabel: String? = null,
618 onLongClick: (() -> Unit)? = null,
619 onDoubleClick: (() -> Unit)? = null,
620 hapticFeedbackEnabled: Boolean = true,
621 onClick: () -> Unit
622 ) =
623 clickableWithIndicationIfNeeded(
624 interactionSource = interactionSource,
625 indication = indication
626 ) { intSource, indicationNodeFactory ->
627 CombinedClickableElement(
628 interactionSource = intSource,
629 indicationNodeFactory = indicationNodeFactory,
630 useLocalIndication = false,
631 enabled = enabled,
632 onClickLabel = onClickLabel,
633 role = role,
634 onClick = onClick,
635 onLongClickLabel = onLongClickLabel,
636 onLongClick = onLongClick,
637 onDoubleClick = onDoubleClick,
638 hapticFeedbackEnabled = hapticFeedbackEnabled
639 )
640 }
641
642 @Deprecated(message = "Maintained for binary compatibility", level = DeprecationLevel.HIDDEN)
combinedClickablenull643 fun Modifier.combinedClickable(
644 interactionSource: MutableInteractionSource?,
645 indication: Indication?,
646 enabled: Boolean = true,
647 onClickLabel: String? = null,
648 role: Role? = null,
649 onLongClickLabel: String? = null,
650 onLongClick: (() -> Unit)? = null,
651 onDoubleClick: (() -> Unit)? = null,
652 onClick: () -> Unit
653 ) =
654 clickableWithIndicationIfNeeded(
655 interactionSource = interactionSource,
656 indication = indication
657 ) { intSource, indicationNodeFactory ->
658 CombinedClickableElement(
659 interactionSource = intSource,
660 indicationNodeFactory = indicationNodeFactory,
661 useLocalIndication = false,
662 enabled = enabled,
663 onClickLabel = onClickLabel,
664 role = role,
665 onClick = onClick,
666 onLongClickLabel = onLongClickLabel,
667 onLongClick = onLongClick,
668 onDoubleClick = onDoubleClick,
669 hapticFeedbackEnabled = true
670 )
671 }
672
673 /**
674 * Utility Modifier factory that handles edge cases for [interactionSource], and [indication].
675 * [createClickable] is the lambda that creates the actual clickable element, which will be chained
676 * with [Modifier.indication] if needed.
677 */
clickableWithIndicationIfNeedednull678 internal inline fun Modifier.clickableWithIndicationIfNeeded(
679 interactionSource: MutableInteractionSource?,
680 indication: Indication?,
681 crossinline createClickable: (MutableInteractionSource?, IndicationNodeFactory?) -> Modifier
682 ): Modifier {
683 return this.then(
684 when {
685 // Fast path - indication is managed internally
686 indication is IndicationNodeFactory -> createClickable(interactionSource, indication)
687 // Fast path - no need for indication
688 indication == null -> createClickable(interactionSource, null)
689 // Non-null Indication (not IndicationNodeFactory) with a non-null InteractionSource
690 interactionSource != null ->
691 Modifier.indication(interactionSource, indication)
692 .then(createClickable(interactionSource, null))
693 // Non-null Indication (not IndicationNodeFactory) with a null InteractionSource, so we
694 // need
695 // to use composed to create an InteractionSource that can be shared. This should be a
696 // rare
697 // code path and can only be hit from new callers.
698 else ->
699 Modifier.composed {
700 val newInteractionSource = remember { MutableInteractionSource() }
701 Modifier.indication(newInteractionSource, indication)
702 .then(createClickable(newInteractionSource, null))
703 }
704 }
705 )
706 }
707
708 /**
709 * How long to wait before appearing 'pressed' (emitting [PressInteraction.Press]) - if a touch down
710 * will quickly become a drag / scroll, this timeout means that we don't show a press effect.
711 */
712 internal expect val TapIndicationDelay: Long
713
714 /**
715 * Returns whether the root Compose layout node is hosted in a scrollable container outside of
716 * Compose. On Android this will be whether the root View is in a scrollable ViewGroup, as even if
717 * nothing in the Compose part of the hierarchy is scrollable, if the View itself is in a scrollable
718 * container, we still want to delay presses in case presses in Compose convert to a scroll outside
719 * of Compose.
720 *
721 * Combine this with [hasScrollableContainer], which returns whether a [Modifier] is within a
722 * scrollable Compose layout, to calculate whether this modifier is within some form of scrollable
723 * container, and hence should delay presses.
724 */
isComposeRootInScrollableContainernull725 internal expect fun DelegatableNode.isComposeRootInScrollableContainer(): Boolean
726
727 /**
728 * Whether the specified [KeyEvent] should trigger a press for a clickable component, i.e. whether
729 * it is associated with a press of an enter key or dpad centre.
730 */
731 private val KeyEvent.isPress: Boolean
732 get() = type == KeyDown && isEnter
733
734 /**
735 * Whether the specified [KeyEvent] should trigger a click for a clickable component, i.e. whether
736 * it is associated with a release of an enter key or dpad centre.
737 */
738 private val KeyEvent.isClick: Boolean
739 get() = type == KeyUp && isEnter
740
741 private val KeyEvent.isEnter: Boolean
742 get() =
743 when (key) {
744 Key.DirectionCenter,
745 Key.Enter,
746 Key.NumPadEnter,
747 Key.Spacebar -> true
748 else -> false
749 }
750
751 private class ClickableElement(
752 private val interactionSource: MutableInteractionSource?,
753 private val indicationNodeFactory: IndicationNodeFactory?,
754 private val useLocalIndication: Boolean,
755 private val enabled: Boolean,
756 private val onClickLabel: String?,
757 private val role: Role?,
758 private val onClick: () -> Unit
759 ) : ModifierNodeElement<ClickableNode>() {
createnull760 override fun create() =
761 ClickableNode(
762 interactionSource = interactionSource,
763 indicationNodeFactory = indicationNodeFactory,
764 useLocalIndication = useLocalIndication,
765 enabled = enabled,
766 onClickLabel = onClickLabel,
767 role = role,
768 onClick = onClick
769 )
770
771 override fun update(node: ClickableNode) {
772 node.update(
773 interactionSource = interactionSource,
774 indicationNodeFactory = indicationNodeFactory,
775 useLocalIndication = useLocalIndication,
776 enabled = enabled,
777 onClickLabel = onClickLabel,
778 role = role,
779 onClick = onClick
780 )
781 }
782
inspectablePropertiesnull783 override fun InspectorInfo.inspectableProperties() {
784 name = "clickable"
785 properties["enabled"] = enabled
786 properties["onClick"] = onClick
787 properties["onClickLabel"] = onClickLabel
788 properties["role"] = role
789 properties["interactionSource"] = interactionSource
790 properties["indicationNodeFactory"] = indicationNodeFactory
791 }
792
equalsnull793 override fun equals(other: Any?): Boolean {
794 if (this === other) return true
795 if (other === null) return false
796 if (this::class != other::class) return false
797
798 other as ClickableElement
799
800 if (interactionSource != other.interactionSource) return false
801 if (indicationNodeFactory != other.indicationNodeFactory) return false
802 if (useLocalIndication != other.useLocalIndication) return false
803 if (enabled != other.enabled) return false
804 if (onClickLabel != other.onClickLabel) return false
805 if (role != other.role) return false
806 if (onClick !== other.onClick) return false
807
808 return true
809 }
810
hashCodenull811 override fun hashCode(): Int {
812 var result = (interactionSource?.hashCode() ?: 0)
813 result = 31 * result + (indicationNodeFactory?.hashCode() ?: 0)
814 result = 31 * result + useLocalIndication.hashCode()
815 result = 31 * result + enabled.hashCode()
816 result = 31 * result + (onClickLabel?.hashCode() ?: 0)
817 result = 31 * result + (role?.hashCode() ?: 0)
818 result = 31 * result + onClick.hashCode()
819 return result
820 }
821 }
822
823 private class CombinedClickableElement(
824 private val interactionSource: MutableInteractionSource?,
825 private val indicationNodeFactory: IndicationNodeFactory?,
826 private val useLocalIndication: Boolean,
827 private val enabled: Boolean,
828 private val onClickLabel: String?,
829 private val role: Role?,
830 private val onClick: () -> Unit,
831 private val onLongClickLabel: String?,
832 private val onLongClick: (() -> Unit)?,
833 private val onDoubleClick: (() -> Unit)?,
834 private val hapticFeedbackEnabled: Boolean,
835 ) : ModifierNodeElement<CombinedClickableNode>() {
createnull836 override fun create() =
837 CombinedClickableNode(
838 onClick = onClick,
839 onLongClickLabel = onLongClickLabel,
840 onLongClick = onLongClick,
841 onDoubleClick = onDoubleClick,
842 hapticFeedbackEnabled = hapticFeedbackEnabled,
843 interactionSource = interactionSource,
844 indicationNodeFactory = indicationNodeFactory,
845 useLocalIndication = useLocalIndication,
846 enabled = enabled,
847 onClickLabel = onClickLabel,
848 role = role,
849 )
850
851 override fun update(node: CombinedClickableNode) {
852 node.hapticFeedbackEnabled = hapticFeedbackEnabled
853 node.update(
854 onClick,
855 onLongClickLabel,
856 onLongClick,
857 onDoubleClick,
858 interactionSource,
859 indicationNodeFactory,
860 useLocalIndication,
861 enabled,
862 onClickLabel,
863 role
864 )
865 }
866
inspectablePropertiesnull867 override fun InspectorInfo.inspectableProperties() {
868 name = "combinedClickable"
869 properties["indicationNodeFactory"] = indicationNodeFactory
870 properties["interactionSource"] = interactionSource
871 properties["enabled"] = enabled
872 properties["onClickLabel"] = onClickLabel
873 properties["role"] = role
874 properties["onClick"] = onClick
875 properties["onDoubleClick"] = onDoubleClick
876 properties["onLongClick"] = onLongClick
877 properties["onLongClickLabel"] = onLongClickLabel
878 properties["hapticFeedbackEnabled"] = hapticFeedbackEnabled
879 }
880
equalsnull881 override fun equals(other: Any?): Boolean {
882 if (this === other) return true
883 if (other === null) return false
884 if (this::class != other::class) return false
885
886 other as CombinedClickableElement
887
888 if (interactionSource != other.interactionSource) return false
889 if (indicationNodeFactory != other.indicationNodeFactory) return false
890 if (useLocalIndication != other.useLocalIndication) return false
891 if (enabled != other.enabled) return false
892 if (onClickLabel != other.onClickLabel) return false
893 if (role != other.role) return false
894 if (onClick !== other.onClick) return false
895 if (onLongClickLabel != other.onLongClickLabel) return false
896 if (onLongClick !== other.onLongClick) return false
897 if (onDoubleClick !== other.onDoubleClick) return false
898 if (hapticFeedbackEnabled != other.hapticFeedbackEnabled) return false
899
900 return true
901 }
902
hashCodenull903 override fun hashCode(): Int {
904 var result = (interactionSource?.hashCode() ?: 0)
905 result = 31 * result + (indicationNodeFactory?.hashCode() ?: 0)
906 result = 31 * result + useLocalIndication.hashCode()
907 result = 31 * result + enabled.hashCode()
908 result = 31 * result + (onClickLabel?.hashCode() ?: 0)
909 result = 31 * result + (role?.hashCode() ?: 0)
910 result = 31 * result + onClick.hashCode()
911 result = 31 * result + (onLongClickLabel?.hashCode() ?: 0)
912 result = 31 * result + (onLongClick?.hashCode() ?: 0)
913 result = 31 * result + (onDoubleClick?.hashCode() ?: 0)
914 result = 31 * result + hapticFeedbackEnabled.hashCode()
915 return result
916 }
917 }
918
919 internal open class ClickableNode(
920 interactionSource: MutableInteractionSource?,
921 indicationNodeFactory: IndicationNodeFactory?,
922 useLocalIndication: Boolean,
923 enabled: Boolean,
924 onClickLabel: String?,
925 role: Role?,
926 onClick: () -> Unit
927 ) :
928 AbstractClickableNode(
929 interactionSource = interactionSource,
930 indicationNodeFactory = indicationNodeFactory,
931 useLocalIndication = useLocalIndication,
932 enabled = enabled,
933 onClickLabel = onClickLabel,
934 role = role,
935 onClick = onClick
936 ) {
clickPointerInputnull937 override suspend fun PointerInputScope.clickPointerInput() {
938 detectTapAndPress(
939 onPress = { offset ->
940 if (enabled) {
941 handlePressInteraction(offset)
942 }
943 },
944 onTap = { if (enabled) onClick() }
945 )
946 }
947
updatenull948 fun update(
949 interactionSource: MutableInteractionSource?,
950 indicationNodeFactory: IndicationNodeFactory?,
951 useLocalIndication: Boolean,
952 enabled: Boolean,
953 onClickLabel: String?,
954 role: Role?,
955 onClick: () -> Unit
956 ) {
957 // enabled and onClick are captured inside callbacks, not as an input to detectTapGestures,
958 // so no need need to reset pointer input handling when they change
959 updateCommon(
960 interactionSource = interactionSource,
961 indicationNodeFactory = indicationNodeFactory,
962 useLocalIndication = useLocalIndication,
963 enabled = enabled,
964 onClickLabel = onClickLabel,
965 role = role,
966 onClick = onClick
967 )
968 }
969
onClickKeyDownEventnull970 final override fun onClickKeyDownEvent(event: KeyEvent) = false
971
972 final override fun onClickKeyUpEvent(event: KeyEvent): Boolean {
973 onClick()
974 return true
975 }
976 }
977
978 private class CombinedClickableNode(
979 onClick: () -> Unit,
980 private var onLongClickLabel: String?,
981 private var onLongClick: (() -> Unit)?,
982 private var onDoubleClick: (() -> Unit)?,
983 var hapticFeedbackEnabled: Boolean,
984 interactionSource: MutableInteractionSource?,
985 indicationNodeFactory: IndicationNodeFactory?,
986 useLocalIndication: Boolean,
987 enabled: Boolean,
988 onClickLabel: String?,
989 role: Role?,
990 ) :
991 CompositionLocalConsumerModifierNode,
992 AbstractClickableNode(
993 interactionSource = interactionSource,
994 indicationNodeFactory = indicationNodeFactory,
995 useLocalIndication = useLocalIndication,
996 enabled = enabled,
997 onClickLabel = onClickLabel,
998 role = role,
999 onClick = onClick
1000 ) {
1001 class DoubleKeyClickState(val job: Job) {
1002 var doubleTapMinTimeMillisElapsed: Boolean = false
1003 }
1004
1005 private val longKeyPressJobs = mutableLongObjectMapOf<Job>()
1006 private val doubleKeyClickStates = mutableLongObjectMapOf<DoubleKeyClickState>()
1007
clickPointerInputnull1008 override suspend fun PointerInputScope.clickPointerInput() {
1009 detectTapGestures(
1010 onDoubleTap =
1011 if (enabled && onDoubleClick != null) {
1012 { onDoubleClick?.invoke() }
1013 } else null,
1014 onLongPress =
1015 if (enabled && onLongClick != null) {
1016 {
1017 onLongClick?.invoke()
1018 if (hapticFeedbackEnabled) {
1019 currentValueOf(LocalHapticFeedback)
1020 .performHapticFeedback(HapticFeedbackType.LongPress)
1021 }
1022 }
1023 } else null,
1024 onPress = { offset ->
1025 if (enabled) {
1026 handlePressInteraction(offset)
1027 }
1028 },
1029 onTap = {
1030 if (enabled) {
1031 onClick()
1032 }
1033 }
1034 )
1035 }
1036
updatenull1037 fun update(
1038 onClick: () -> Unit,
1039 onLongClickLabel: String?,
1040 onLongClick: (() -> Unit)?,
1041 onDoubleClick: (() -> Unit)?,
1042 interactionSource: MutableInteractionSource?,
1043 indicationNodeFactory: IndicationNodeFactory?,
1044 useLocalIndication: Boolean,
1045 enabled: Boolean,
1046 onClickLabel: String?,
1047 role: Role?
1048 ) {
1049 var resetPointerInputHandling = false
1050
1051 // onClick is captured inside a callback, not as an input to detectTapGestures,
1052 // so no need need to reset pointer input handling
1053
1054 if (this.onLongClickLabel != onLongClickLabel) {
1055 this.onLongClickLabel = onLongClickLabel
1056 invalidateSemantics()
1057 }
1058
1059 // We capture onLongClick and onDoubleClick inside the callback, so if the lambda changes
1060 // value we don't want to reset input handling - only reset if they go from not-defined to
1061 // defined, and vice-versa, as that is what is captured in the parameter to
1062 // detectTapGestures.
1063 if ((this.onLongClick == null) != (onLongClick == null)) {
1064 // Adding or removing longClick should cancel any existing press interactions
1065 disposeInteractions()
1066 // Adding or removing longClick should add / remove the corresponding property
1067 invalidateSemantics()
1068 resetPointerInputHandling = true
1069 }
1070
1071 this.onLongClick = onLongClick
1072
1073 if ((this.onDoubleClick == null) != (onDoubleClick == null)) {
1074 resetPointerInputHandling = true
1075 }
1076 this.onDoubleClick = onDoubleClick
1077
1078 // enabled is captured as a parameter to detectTapGestures, so we need to restart detecting
1079 // gestures if it changes.
1080 if (this.enabled != enabled) {
1081 resetPointerInputHandling = true
1082 // Updating is handled inside updateCommon
1083 }
1084
1085 updateCommon(
1086 interactionSource = interactionSource,
1087 indicationNodeFactory = indicationNodeFactory,
1088 useLocalIndication = useLocalIndication,
1089 enabled = enabled,
1090 onClickLabel = onClickLabel,
1091 role = role,
1092 onClick = onClick
1093 )
1094
1095 if (resetPointerInputHandling) resetPointerInputHandler()
1096 }
1097
applyAdditionalSemanticsnull1098 override fun SemanticsPropertyReceiver.applyAdditionalSemantics() {
1099 if (onLongClick != null) {
1100 onLongClick(
1101 action = {
1102 onLongClick?.invoke()
1103 true
1104 },
1105 label = onLongClickLabel
1106 )
1107 }
1108 }
1109
onClickKeyDownEventnull1110 override fun onClickKeyDownEvent(event: KeyEvent): Boolean {
1111 val keyCode = event.key.keyCode
1112 var handledByLongClick = false
1113 if (onLongClick != null) {
1114 if (longKeyPressJobs[keyCode] == null) {
1115 longKeyPressJobs[keyCode] =
1116 coroutineScope.launch {
1117 delay(currentValueOf(LocalViewConfiguration).longPressTimeoutMillis)
1118 onLongClick?.invoke()
1119 }
1120 handledByLongClick = true
1121 }
1122 }
1123 val doubleClickState = doubleKeyClickStates[keyCode]
1124 // This is the second down event, so it might be a double click
1125 if (doubleClickState != null) {
1126 // Within the allowed timeout, so check if this is above the minimum time needed for
1127 // a double click
1128 if (doubleClickState.job.isActive) {
1129 doubleClickState.job.cancel()
1130 // If the second down was before the minimum double tap time, don't track this as
1131 // a double click. Instead, we need to invoke onClick for the previous click, since
1132 // that is now counted as a standalone click instead of the first of a double click.
1133 if (!doubleClickState.doubleTapMinTimeMillisElapsed) {
1134 onClick()
1135 doubleKeyClickStates.remove(keyCode)
1136 }
1137 } else {
1138 // We already invoked onClick because we passed the timeout, so stop tracking this
1139 // as a double click
1140 doubleKeyClickStates.remove(keyCode)
1141 }
1142 }
1143 return handledByLongClick
1144 }
1145
onClickKeyUpEventnull1146 override fun onClickKeyUpEvent(event: KeyEvent): Boolean {
1147 val keyCode = event.key.keyCode
1148 var longClickInvoked = false
1149 if (longKeyPressJobs[keyCode] != null) {
1150 longKeyPressJobs[keyCode]?.let {
1151 if (it.isActive) {
1152 it.cancel()
1153 } else {
1154 // If we already passed the timeout, we invoked long click already, and so
1155 // we shouldn't invoke onClick in this case
1156 longClickInvoked = true
1157 }
1158 }
1159 longKeyPressJobs.remove(keyCode)
1160 }
1161 if (onDoubleClick != null) {
1162 when {
1163 // First click
1164 doubleKeyClickStates[keyCode] == null -> {
1165 // We only track the second click if the first click was not a long click
1166 if (!longClickInvoked) {
1167 doubleKeyClickStates[keyCode] =
1168 DoubleKeyClickState(
1169 coroutineScope.launch {
1170 val configuration = currentValueOf(LocalViewConfiguration)
1171 val minTime = configuration.doubleTapMinTimeMillis
1172 val timeout = configuration.doubleTapTimeoutMillis
1173 delay(minTime)
1174 doubleKeyClickStates[keyCode]?.doubleTapMinTimeMillisElapsed =
1175 true
1176 // Delay the remainder until we are at timeout
1177 delay(timeout - minTime)
1178 // If there was no second key press after the timeout, invoke
1179 // onClick as normal
1180 onClick()
1181 }
1182 )
1183 }
1184 }
1185 // Second click
1186 else -> {
1187 // Invoke onDoubleClick if the second click was not a long click
1188 if (!longClickInvoked) {
1189 onDoubleClick?.invoke()
1190 }
1191 doubleKeyClickStates.remove(keyCode)
1192 }
1193 }
1194 } else {
1195 if (!longClickInvoked) {
1196 onClick()
1197 }
1198 }
1199 return true
1200 }
1201
onCancelKeyInputnull1202 override fun onCancelKeyInput() {
1203 resetKeyPressState()
1204 }
1205
onResetnull1206 override fun onReset() {
1207 super.onReset()
1208 resetKeyPressState()
1209 }
1210
resetKeyPressStatenull1211 private fun resetKeyPressState() {
1212 longKeyPressJobs.apply {
1213 forEachValue { it.cancel() }
1214 clear()
1215 }
1216 doubleKeyClickStates.apply {
1217 forEachValue { it.job.cancel() }
1218 clear()
1219 }
1220 }
1221 }
1222
1223 internal abstract class AbstractClickableNode(
1224 private var interactionSource: MutableInteractionSource?,
1225 private var indicationNodeFactory: IndicationNodeFactory?,
1226 private var useLocalIndication: Boolean,
1227 enabled: Boolean,
1228 private var onClickLabel: String?,
1229 private var role: Role?,
1230 onClick: () -> Unit
1231 ) :
1232 DelegatingNode(),
1233 PointerInputModifierNode,
1234 KeyInputModifierNode,
1235 SemanticsModifierNode,
1236 TraversableNode,
1237 CompositionLocalConsumerModifierNode,
1238 ObserverModifierNode {
1239 protected var enabled = enabled
1240 private set
1241
1242 protected var onClick = onClick
1243 private set
1244
1245 final override val shouldAutoInvalidate: Boolean = false
1246
1247 private val focusableNode: FocusableNode =
1248 FocusableNode(
1249 interactionSource,
1250 focusability = Focusability.SystemDefined,
1251 onFocusChange = ::onFocusChange
1252 )
1253
1254 private var localIndicationNodeFactory: IndicationNodeFactory? = null
1255
1256 private var pointerInputNode: SuspendingPointerInputModifierNode? = null
1257 private var indicationNode: DelegatableNode? = null
1258
1259 private var pressInteraction: PressInteraction.Press? = null
1260 private var hoverInteraction: HoverInteraction.Enter? = null
1261 private val currentKeyPressInteractions = mutableLongObjectMapOf<PressInteraction.Press>()
1262 private var centerOffset: Offset = Offset.Zero
1263
1264 // Track separately from interactionSource, as we will create our own internal
1265 // InteractionSource if needed
1266 private var userProvidedInteractionSource: MutableInteractionSource? = interactionSource
1267
1268 private var lazilyCreateIndication = shouldLazilyCreateIndication()
1269
shouldLazilyCreateIndicationnull1270 private fun shouldLazilyCreateIndication() = userProvidedInteractionSource == null
1271
1272 /**
1273 * Handles subclass-specific click related pointer input logic. Hover is already handled
1274 * elsewhere, so this should only handle clicks.
1275 */
1276 abstract suspend fun PointerInputScope.clickPointerInput()
1277
1278 open fun SemanticsPropertyReceiver.applyAdditionalSemantics() {}
1279
updateCommonnull1280 protected fun updateCommon(
1281 interactionSource: MutableInteractionSource?,
1282 indicationNodeFactory: IndicationNodeFactory?,
1283 useLocalIndication: Boolean,
1284 enabled: Boolean,
1285 onClickLabel: String?,
1286 role: Role?,
1287 onClick: () -> Unit
1288 ) {
1289 var isIndicationNodeDirty = false
1290 // Compare against userProvidedInteractionSource, as we will create a new InteractionSource
1291 // lazily if the userProvidedInteractionSource is null, and assign it to interactionSource
1292 if (userProvidedInteractionSource != interactionSource) {
1293 disposeInteractions()
1294 userProvidedInteractionSource = interactionSource
1295 this.interactionSource = interactionSource
1296 isIndicationNodeDirty = true
1297 }
1298 if (this.indicationNodeFactory != indicationNodeFactory) {
1299 this.indicationNodeFactory = indicationNodeFactory
1300 isIndicationNodeDirty = true
1301 }
1302 if (this.useLocalIndication != useLocalIndication) {
1303 this.useLocalIndication = useLocalIndication
1304 if (useLocalIndication) {
1305 // Need to update localIndicationNodeFactory, and start observing changes
1306 onObservedReadsChanged()
1307 }
1308 isIndicationNodeDirty = true
1309 }
1310 if (this.enabled != enabled) {
1311 if (enabled) {
1312 delegate(focusableNode)
1313 } else {
1314 // TODO: Should we remove indicationNode? Previously we always emitted indication
1315 undelegate(focusableNode)
1316 disposeInteractions()
1317 }
1318 invalidateSemantics()
1319 this.enabled = enabled
1320 }
1321 if (this.onClickLabel != onClickLabel) {
1322 this.onClickLabel = onClickLabel
1323 invalidateSemantics()
1324 }
1325 if (this.role != role) {
1326 this.role = role
1327 invalidateSemantics()
1328 }
1329 this.onClick = onClick
1330 if (lazilyCreateIndication != shouldLazilyCreateIndication()) {
1331 lazilyCreateIndication = shouldLazilyCreateIndication()
1332 // If we are no longer lazily creating the node, and we haven't created the node yet,
1333 // create it
1334 if (!lazilyCreateIndication && indicationNode == null) isIndicationNodeDirty = true
1335 }
1336 // Create / recreate indication node
1337 if (isIndicationNodeDirty) {
1338 recreateIndicationIfNeeded()
1339 }
1340 focusableNode.update(this.interactionSource)
1341 }
1342
onAttachnull1343 final override fun onAttach() {
1344 onObservedReadsChanged()
1345 if (!lazilyCreateIndication) {
1346 initializeIndicationAndInteractionSourceIfNeeded()
1347 }
1348 if (enabled) {
1349 delegate(focusableNode)
1350 }
1351 }
1352
onObservedReadsChangednull1353 override fun onObservedReadsChanged() {
1354 if (useLocalIndication) {
1355 observeReads {
1356 val indication = currentValueOf(LocalIndication)
1357 requirePrecondition(indication is IndicationNodeFactory) {
1358 unsupportedIndicationExceptionMessage(indication)
1359 }
1360 val previousFactory = localIndicationNodeFactory
1361 localIndicationNodeFactory = indication
1362 // If we are changing from a non-null factory to a different factory, recreate
1363 // indication if needed
1364 if (previousFactory != null && localIndicationNodeFactory != previousFactory) {
1365 recreateIndicationIfNeeded()
1366 }
1367 }
1368 }
1369 }
1370
onDetachnull1371 final override fun onDetach() {
1372 disposeInteractions()
1373 // If we lazily created an interaction source, reset it in case we are reused / moved. Note
1374 // that we need to do it here instead of onReset() - since onReset won't be called in the
1375 // movableContent case but we still want to dispose for that case
1376 if (userProvidedInteractionSource == null) {
1377 interactionSource = null
1378 }
1379 // Remove indication in case we are reused / moved - we will create a new node when needed
1380 indicationNode?.let { undelegate(it) }
1381 indicationNode = null
1382 }
1383
disposeInteractionsnull1384 protected fun disposeInteractions() {
1385 interactionSource?.let { interactionSource ->
1386 pressInteraction?.let { oldValue ->
1387 val interaction = PressInteraction.Cancel(oldValue)
1388 interactionSource.tryEmit(interaction)
1389 }
1390 hoverInteraction?.let { oldValue ->
1391 val interaction = HoverInteraction.Exit(oldValue)
1392 interactionSource.tryEmit(interaction)
1393 }
1394 currentKeyPressInteractions.forEachValue {
1395 interactionSource.tryEmit(PressInteraction.Cancel(it))
1396 }
1397 }
1398 pressInteraction = null
1399 hoverInteraction = null
1400 currentKeyPressInteractions.clear()
1401 }
1402
onFocusChangenull1403 private fun onFocusChange(isFocused: Boolean) {
1404 if (isFocused) {
1405 initializeIndicationAndInteractionSourceIfNeeded()
1406 } else {
1407 // If we are no longer focused while we are tracking existing key presses, we need to
1408 // clear them and cancel the presses.
1409 if (interactionSource != null) {
1410 currentKeyPressInteractions.forEachValue {
1411 coroutineScope.launch { interactionSource?.emit(PressInteraction.Cancel(it)) }
1412 }
1413 }
1414 currentKeyPressInteractions.clear()
1415 onCancelKeyInput()
1416 }
1417 }
1418
recreateIndicationIfNeedednull1419 private fun recreateIndicationIfNeeded() {
1420 // If we already created a node lazily, or we are not lazily creating the node, create
1421 if (indicationNode != null || !lazilyCreateIndication) {
1422 indicationNode?.let { undelegate(it) }
1423 indicationNode = null
1424 initializeIndicationAndInteractionSourceIfNeeded()
1425 }
1426 }
1427
initializeIndicationAndInteractionSourceIfNeedednull1428 private fun initializeIndicationAndInteractionSourceIfNeeded() {
1429 // We have already created the node, no need to do any work
1430 if (indicationNode != null) return
1431 val indicationFactory =
1432 if (useLocalIndication) localIndicationNodeFactory else indicationNodeFactory
1433 indicationFactory?.let { factory ->
1434 if (interactionSource == null) {
1435 interactionSource = MutableInteractionSource()
1436 }
1437 focusableNode.update(interactionSource)
1438 val node = factory.create(interactionSource!!)
1439 delegate(node)
1440 indicationNode = node
1441 }
1442 }
1443
onPointerEventnull1444 final override fun onPointerEvent(
1445 pointerEvent: PointerEvent,
1446 pass: PointerEventPass,
1447 bounds: IntSize
1448 ) {
1449 centerOffset = bounds.center.toOffset()
1450 initializeIndicationAndInteractionSourceIfNeeded()
1451 if (enabled) {
1452 if (pass == PointerEventPass.Main) {
1453 when (pointerEvent.type) {
1454 PointerEventType.Enter -> coroutineScope.launch { emitHoverEnter() }
1455 PointerEventType.Exit -> coroutineScope.launch { emitHoverExit() }
1456 }
1457 }
1458 }
1459 if (pointerInputNode == null) {
1460 pointerInputNode = delegate(SuspendingPointerInputModifierNode { clickPointerInput() })
1461 }
1462 pointerInputNode?.onPointerEvent(pointerEvent, pass, bounds)
1463 }
1464
onCancelPointerInputnull1465 final override fun onCancelPointerInput() {
1466 // Press cancellation is handled as part of detecting presses
1467 interactionSource?.let { interactionSource ->
1468 hoverInteraction?.let { oldValue ->
1469 val interaction = HoverInteraction.Exit(oldValue)
1470 interactionSource.tryEmit(interaction)
1471 }
1472 }
1473 hoverInteraction = null
1474 pointerInputNode?.onCancelPointerInput()
1475 }
1476
onKeyEventnull1477 final override fun onKeyEvent(event: KeyEvent): Boolean {
1478 // Key events usually require focus, but if a focused child does not handle the KeyEvent,
1479 // the event can bubble up without this clickable ever being focused, and hence without
1480 // this being initialized through the focus path
1481 initializeIndicationAndInteractionSourceIfNeeded()
1482 val keyCode = event.key.keyCode
1483 return when {
1484 enabled && event.isPress -> {
1485 // If the key already exists in the map, keyEvent is a repeat event.
1486 // We ignore it as we only want to emit an interaction for the initial key press.
1487 var wasInteractionHandled = false
1488 if (!currentKeyPressInteractions.containsKey(keyCode)) {
1489 val press = PressInteraction.Press(centerOffset)
1490 currentKeyPressInteractions[keyCode] = press
1491 // Even if the interactionSource is null, we still want to intercept the presses
1492 // so we always track them above, and return true
1493 if (interactionSource != null) {
1494 coroutineScope.launch { interactionSource?.emit(press) }
1495 }
1496 wasInteractionHandled = true
1497 }
1498 onClickKeyDownEvent(event) || wasInteractionHandled
1499 }
1500 enabled && event.isClick -> {
1501 val press = currentKeyPressInteractions.remove(keyCode)
1502 if (press != null) {
1503 if (interactionSource != null) {
1504 coroutineScope.launch {
1505 interactionSource?.emit(PressInteraction.Release(press))
1506 }
1507 }
1508 // Don't invoke onClick if we were not pressed - this could happen if we became
1509 // focused after the down event, or if the node was reused after the down event.
1510 onClickKeyUpEvent(event)
1511 }
1512 // Only consume if we were previously pressed for this key event
1513 press != null
1514 }
1515 else -> false
1516 }
1517 }
1518
onClickKeyDownEventnull1519 protected abstract fun onClickKeyDownEvent(event: KeyEvent): Boolean
1520
1521 protected abstract fun onClickKeyUpEvent(event: KeyEvent): Boolean
1522
1523 /**
1524 * Called when focus is lost, to allow cleaning up and resetting the state for ongoing key
1525 * presses
1526 */
1527 protected open fun onCancelKeyInput() {}
1528
onPreKeyEventnull1529 final override fun onPreKeyEvent(event: KeyEvent) = false
1530
1531 final override val shouldMergeDescendantSemantics: Boolean
1532 get() = true
1533
1534 final override fun SemanticsPropertyReceiver.applySemantics() {
1535 if (this@AbstractClickableNode.role != null) {
1536 role = this@AbstractClickableNode.role!!
1537 }
1538 onClick(
1539 action = {
1540 onClick()
1541 true
1542 },
1543 label = onClickLabel
1544 )
1545 if (enabled) {
1546 with(focusableNode) { applySemantics() }
1547 } else {
1548 disabled()
1549 }
1550 applyAdditionalSemantics()
1551 }
1552
resetPointerInputHandlernull1553 protected fun resetPointerInputHandler() = pointerInputNode?.resetPointerInputHandler()
1554
1555 protected suspend fun PressGestureScope.handlePressInteraction(offset: Offset) {
1556 interactionSource?.let { interactionSource ->
1557 coroutineScope {
1558 val delayJob = launch {
1559 if (delayPressInteraction()) {
1560 delay(TapIndicationDelay)
1561 }
1562 val press = PressInteraction.Press(offset)
1563 interactionSource.emit(press)
1564 pressInteraction = press
1565 }
1566 val success = tryAwaitRelease()
1567 if (delayJob.isActive) {
1568 delayJob.cancelAndJoin()
1569 // The press released successfully, before the timeout duration - emit the press
1570 // interaction instantly. No else branch - if the press was cancelled before the
1571 // timeout, we don't want to emit a press interaction.
1572 if (success) {
1573 val press = PressInteraction.Press(offset)
1574 val release = PressInteraction.Release(press)
1575 interactionSource.emit(press)
1576 interactionSource.emit(release)
1577 }
1578 } else {
1579 pressInteraction?.let { pressInteraction ->
1580 val endInteraction =
1581 if (success) {
1582 PressInteraction.Release(pressInteraction)
1583 } else {
1584 PressInteraction.Cancel(pressInteraction)
1585 }
1586 interactionSource.emit(endInteraction)
1587 }
1588 }
1589 pressInteraction = null
1590 }
1591 }
1592 }
1593
delayPressInteractionnull1594 private fun delayPressInteraction(): Boolean =
1595 hasScrollableContainer() || isComposeRootInScrollableContainer()
1596
1597 private fun emitHoverEnter() {
1598 if (hoverInteraction == null) {
1599 val interaction = HoverInteraction.Enter()
1600 interactionSource?.let { interactionSource ->
1601 coroutineScope.launch { interactionSource.emit(interaction) }
1602 }
1603 hoverInteraction = interaction
1604 }
1605 }
1606
emitHoverExitnull1607 private fun emitHoverExit() {
1608 hoverInteraction?.let { oldValue ->
1609 val interaction = HoverInteraction.Exit(oldValue)
1610 interactionSource?.let { interactionSource ->
1611 coroutineScope.launch { interactionSource.emit(interaction) }
1612 }
1613 hoverInteraction = null
1614 }
1615 }
1616
1617 override val traverseKey: Any = TraverseKey
1618
1619 companion object TraverseKey
1620 }
1621
hasScrollableContainernull1622 internal fun TraversableNode.hasScrollableContainer(): Boolean {
1623 var hasScrollable = false
1624 traverseAncestors(ScrollableContainerNode.TraverseKey) { node ->
1625 hasScrollable = hasScrollable || (node as ScrollableContainerNode).enabled
1626 !hasScrollable
1627 }
1628 return hasScrollable
1629 }
1630
unsupportedIndicationExceptionMessagenull1631 private fun unsupportedIndicationExceptionMessage(indication: Indication): String {
1632 return "clickable only supports IndicationNodeFactory instances provided to LocalIndication, " +
1633 "but Indication was provided instead. Either migrate the Indication implementation to " +
1634 "implement IndicationNodeFactory, or use the other clickable overload that takes an " +
1635 "Indication parameter, and explicitly pass LocalIndication.current there. You can also " +
1636 "use ComposeFoundationFlags.isNonComposedClickableEnabled to temporarily opt-out; note " +
1637 "that this flag will be removed in a future release and is only intended to be a " +
1638 "temporary migration aid. The Indication instance provided here was: $indication"
1639 }
1640