| /* |
| * Copyright 2019 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package androidx.compose.foundation |
| |
| import androidx.compose.foundation.gestures.PressGestureScope |
| import androidx.compose.foundation.gestures.detectTapAndPress |
| import androidx.compose.foundation.gestures.detectTapGestures |
| import androidx.compose.foundation.interaction.MutableInteractionSource |
| import androidx.compose.foundation.interaction.PressInteraction |
| import androidx.compose.runtime.Composable |
| import androidx.compose.runtime.DisposableEffect |
| import androidx.compose.runtime.MutableState |
| import androidx.compose.runtime.mutableStateOf |
| import androidx.compose.runtime.remember |
| import androidx.compose.runtime.rememberUpdatedState |
| import androidx.compose.ui.Modifier |
| import androidx.compose.ui.composed |
| import androidx.compose.ui.geometry.Offset |
| import androidx.compose.ui.input.pointer.pointerInput |
| import androidx.compose.ui.platform.debugInspectorInfo |
| import androidx.compose.ui.semantics.Role |
| import androidx.compose.ui.semantics.disabled |
| import androidx.compose.ui.semantics.onClick |
| import androidx.compose.ui.semantics.onLongClick |
| import androidx.compose.ui.semantics.role |
| import androidx.compose.ui.semantics.semantics |
| import kotlinx.coroutines.cancelAndJoin |
| import kotlinx.coroutines.coroutineScope |
| import kotlinx.coroutines.delay |
| import kotlinx.coroutines.launch |
| |
| /** |
| * Configure component to receive clicks via input or accessibility "click" event. |
| * |
| * Add this modifier to the element to make it clickable within its bounds and show a default |
| * indication when it's pressed. |
| * |
| * This version has no [MutableInteractionSource] or [Indication] parameters, default indication from |
| * [LocalIndication] will be used. To specify [MutableInteractionSource] or [Indication], use another |
| * overload. |
| * |
| * If you need to support double click or long click alongside the single click, consider |
| * using [combinedClickable]. |
| * |
| * @sample androidx.compose.foundation.samples.ClickableSample |
| * |
| * @param enabled Controls the enabled state. When `false`, [onClick], and this modifier will |
| * appear disabled for accessibility services |
| * @param onClickLabel semantic / accessibility label for the [onClick] action |
| * @param role the type of user interface element. Accessibility services might use this |
| * to describe the element or do customizations |
| * @param onClick will be called when user clicks on the element |
| */ |
| fun Modifier.clickable( |
| enabled: Boolean = true, |
| onClickLabel: String? = null, |
| role: Role? = null, |
| onClick: () -> Unit |
| ) = composed( |
| inspectorInfo = debugInspectorInfo { |
| name = "clickable" |
| properties["enabled"] = enabled |
| properties["onClickLabel"] = onClickLabel |
| properties["role"] = role |
| properties["onClick"] = onClick |
| } |
| ) { |
| Modifier.clickable( |
| enabled = enabled, |
| onClickLabel = onClickLabel, |
| onClick = onClick, |
| role = role, |
| indication = LocalIndication.current, |
| interactionSource = remember { MutableInteractionSource() } |
| ) |
| } |
| |
| /** |
| * Configure component to receive clicks via input or accessibility "click" event. |
| * |
| * Add this modifier to the element to make it clickable within its bounds and show an indication |
| * as specified in [indication] parameter. |
| * |
| * If you need to support double click or long click alongside the single click, consider |
| * using [combinedClickable]. |
| * |
| * @sample androidx.compose.foundation.samples.ClickableSample |
| * |
| * @param interactionSource [MutableInteractionSource] that will be used to dispatch |
| * [PressInteraction.Press] when this clickable is pressed. Only the initial (first) press will be |
| * recorded and dispatched with [MutableInteractionSource]. |
| * @param indication indication to be shown when modified element is pressed. Be default, |
| * indication from [LocalIndication] will be used. Pass `null` to show no indication, or |
| * current value from [LocalIndication] to show theme default |
| * @param enabled Controls the enabled state. When `false`, [onClick], and this modifier will |
| * appear disabled for accessibility services |
| * @param onClickLabel semantic / accessibility label for the [onClick] action |
| * @param role the type of user interface element. Accessibility services might use this |
| * to describe the element or do customizations |
| * @param onClick will be called when user clicks on the element |
| */ |
| fun Modifier.clickable( |
| interactionSource: MutableInteractionSource, |
| indication: Indication?, |
| enabled: Boolean = true, |
| onClickLabel: String? = null, |
| role: Role? = null, |
| onClick: () -> Unit |
| ) = composed( |
| factory = { |
| val onClickState = rememberUpdatedState(onClick) |
| val pressedInteraction = remember { mutableStateOf<PressInteraction.Press?>(null) } |
| if (enabled) { |
| PressedInteractionSourceDisposableEffect(interactionSource, pressedInteraction) |
| } |
| val gesture = Modifier.pointerInput(interactionSource, enabled) { |
| detectTapAndPress( |
| onPress = { offset -> |
| if (enabled) { |
| handlePressInteraction(offset, interactionSource, pressedInteraction) |
| } |
| }, |
| onTap = { if (enabled) onClickState.value.invoke() } |
| ) |
| } |
| Modifier |
| .genericClickableWithoutGesture( |
| gestureModifiers = gesture, |
| interactionSource = interactionSource, |
| indication = indication, |
| enabled = enabled, |
| onClickLabel = onClickLabel, |
| role = role, |
| onLongClickLabel = null, |
| onLongClick = null, |
| onClick = onClick |
| ) |
| }, |
| inspectorInfo = debugInspectorInfo { |
| name = "clickable" |
| properties["enabled"] = enabled |
| properties["onClickLabel"] = onClickLabel |
| properties["role"] = role |
| properties["onClick"] = onClick |
| properties["indication"] = indication |
| properties["interactionSource"] = interactionSource |
| } |
| ) |
| |
| /** |
| * Configure component to receive clicks, double clicks and long clicks via input or accessibility |
| * "click" event. |
| * |
| * Add this modifier to the element to make it clickable within its bounds. |
| * |
| * If you need only click handling, and no double or long clicks, consider using [clickable] |
| * |
| * This version has no [MutableInteractionSource] or [Indication] parameters, default indication |
| * from [LocalIndication] will be used. To specify [MutableInteractionSource] or [Indication], |
| * use another overload. |
| * |
| * @sample androidx.compose.foundation.samples.ClickableSample |
| * |
| * @param enabled Controls the enabled state. When `false`, [onClick], [onLongClick] or |
| * [onDoubleClick] won't be invoked |
| * @param onClickLabel semantic / accessibility label for the [onClick] action |
| * @param role the type of user interface element. Accessibility services might use this |
| * to describe the element or do customizations |
| * @param onLongClickLabel semantic / accessibility label for the [onLongClick] action |
| * @param onLongClick will be called when user long presses on the element |
| * @param onDoubleClick will be called when user double clicks on the element |
| * @param onClick will be called when user clicks on the element |
| */ |
| @ExperimentalFoundationApi |
| fun Modifier.combinedClickable( |
| enabled: Boolean = true, |
| onClickLabel: String? = null, |
| role: Role? = null, |
| onLongClickLabel: String? = null, |
| onLongClick: (() -> Unit)? = null, |
| onDoubleClick: (() -> Unit)? = null, |
| onClick: () -> Unit |
| ) = composed( |
| inspectorInfo = debugInspectorInfo { |
| name = "combinedClickable" |
| properties["enabled"] = enabled |
| properties["onClickLabel"] = onClickLabel |
| properties["role"] = role |
| properties["onClick"] = onClick |
| properties["onDoubleClick"] = onDoubleClick |
| properties["onLongClick"] = onLongClick |
| properties["onLongClickLabel"] = onLongClickLabel |
| } |
| ) { |
| Modifier.combinedClickable( |
| enabled = enabled, |
| onClickLabel = onClickLabel, |
| onLongClickLabel = onLongClickLabel, |
| onLongClick = onLongClick, |
| onDoubleClick = onDoubleClick, |
| onClick = onClick, |
| role = role, |
| indication = LocalIndication.current, |
| interactionSource = remember { MutableInteractionSource() } |
| ) |
| } |
| |
| /** |
| * Configure component to receive clicks, double clicks and long clicks via input or accessibility |
| * "click" event. |
| * |
| * Add this modifier to the element to make it clickable within its bounds. |
| * |
| * If you need only click handling, and no double or long clicks, consider using [clickable]. |
| * |
| * Add this modifier to the element to make it clickable within its bounds. |
| * |
| * @sample androidx.compose.foundation.samples.ClickableSample |
| * |
| * @param interactionSource [MutableInteractionSource] that will be used to emit |
| * [PressInteraction.Press] when this clickable is pressed. Only the initial (first) press will be |
| * recorded and emitted with [MutableInteractionSource]. |
| * @param indication indication to be shown when modified element is pressed. Be default, |
| * indication from [LocalIndication] will be used. Pass `null` to show no indication, or |
| * current value from [LocalIndication] to show theme default |
| * @param enabled Controls the enabled state. When `false`, [onClick], [onLongClick] or |
| * [onDoubleClick] won't be invoked |
| * @param onClickLabel semantic / accessibility label for the [onClick] action |
| * @param role the type of user interface element. Accessibility services might use this |
| * to describe the element or do customizations |
| * @param onLongClickLabel semantic / accessibility label for the [onLongClick] action |
| * @param onLongClick will be called when user long presses on the element |
| * @param onDoubleClick will be called when user double clicks on the element |
| * @param onClick will be called when user clicks on the element |
| */ |
| @ExperimentalFoundationApi |
| fun Modifier.combinedClickable( |
| interactionSource: MutableInteractionSource, |
| indication: Indication?, |
| enabled: Boolean = true, |
| onClickLabel: String? = null, |
| role: Role? = null, |
| onLongClickLabel: String? = null, |
| onLongClick: (() -> Unit)? = null, |
| onDoubleClick: (() -> Unit)? = null, |
| onClick: () -> Unit |
| ) = composed( |
| factory = { |
| val onClickState = rememberUpdatedState(onClick) |
| val onLongClickState = rememberUpdatedState(onLongClick) |
| val onDoubleClickState = rememberUpdatedState(onDoubleClick) |
| val hasLongClick = onLongClick != null |
| val hasDoubleClick = onDoubleClick != null |
| val pressedInteraction = remember { mutableStateOf<PressInteraction.Press?>(null) } |
| if (enabled) { |
| // Handles the case where a long click causes a null onLongClick lambda to be passed, |
| // so we can cancel the existing press. |
| DisposableEffect(hasLongClick) { |
| onDispose { |
| pressedInteraction.value?.let { oldValue -> |
| val interaction = PressInteraction.Cancel(oldValue) |
| interactionSource.tryEmit(interaction) |
| pressedInteraction.value = null |
| } |
| } |
| } |
| PressedInteractionSourceDisposableEffect(interactionSource, pressedInteraction) |
| } |
| val gesture = |
| Modifier.pointerInput(interactionSource, hasLongClick, hasDoubleClick, enabled) { |
| detectTapGestures( |
| onDoubleTap = if (hasDoubleClick && enabled) { |
| { onDoubleClickState.value?.invoke() } |
| } else { |
| null |
| }, |
| onLongPress = if (hasLongClick && enabled) { |
| { onLongClickState.value?.invoke() } |
| } else { |
| null |
| }, |
| onPress = { offset -> |
| if (enabled) { |
| handlePressInteraction(offset, interactionSource, pressedInteraction) |
| } |
| }, |
| onTap = { if (enabled) onClickState.value.invoke() } |
| ) |
| } |
| Modifier |
| .genericClickableWithoutGesture( |
| gestureModifiers = gesture, |
| interactionSource = interactionSource, |
| indication = indication, |
| enabled = enabled, |
| onClickLabel = onClickLabel, |
| role = role, |
| onLongClickLabel = onLongClickLabel, |
| onLongClick = onLongClick, |
| onClick = onClick |
| ) |
| }, |
| inspectorInfo = debugInspectorInfo { |
| name = "combinedClickable" |
| properties["enabled"] = enabled |
| properties["onClickLabel"] = onClickLabel |
| properties["role"] = role |
| properties["onClick"] = onClick |
| properties["onDoubleClick"] = onDoubleClick |
| properties["onLongClick"] = onLongClick |
| properties["onLongClickLabel"] = onLongClickLabel |
| properties["indication"] = indication |
| properties["interactionSource"] = interactionSource |
| } |
| ) |
| |
| @Composable |
| internal fun PressedInteractionSourceDisposableEffect( |
| interactionSource: MutableInteractionSource, |
| pressedInteraction: MutableState<PressInteraction.Press?> |
| ) { |
| DisposableEffect(interactionSource) { |
| onDispose { |
| pressedInteraction.value?.let { oldValue -> |
| val interaction = PressInteraction.Cancel(oldValue) |
| interactionSource.tryEmit(interaction) |
| pressedInteraction.value = null |
| } |
| } |
| } |
| } |
| |
| internal suspend fun PressGestureScope.handlePressInteraction( |
| pressPoint: Offset, |
| interactionSource: MutableInteractionSource, |
| pressedInteraction: MutableState<PressInteraction.Press?> |
| ) { |
| coroutineScope { |
| val delayJob = launch { |
| delay(TapIndicationDelay) |
| val pressInteraction = PressInteraction.Press(pressPoint) |
| interactionSource.emit(pressInteraction) |
| pressedInteraction.value = pressInteraction |
| } |
| val success = tryAwaitRelease() |
| if (delayJob.isActive) { |
| delayJob.cancelAndJoin() |
| // The press released successfully, before the timeout duration - emit the press |
| // interaction instantly. No else branch - if the press was cancelled before the |
| // timeout, we don't want to emit a press interaction. |
| if (success) { |
| val pressInteraction = PressInteraction.Press(pressPoint) |
| val releaseInteraction = PressInteraction.Release(pressInteraction) |
| interactionSource.emit(pressInteraction) |
| interactionSource.emit(releaseInteraction) |
| } |
| } else { |
| pressedInteraction.value?.let { pressInteraction -> |
| val endInteraction = if (success) { |
| PressInteraction.Release(pressInteraction) |
| } else { |
| PressInteraction.Cancel(pressInteraction) |
| } |
| interactionSource.emit(endInteraction) |
| } |
| } |
| pressedInteraction.value = null |
| } |
| } |
| |
| /** |
| * How long to wait before appearing 'pressed' (emitting [PressInteraction.Press]) - if a touch |
| * down will quickly become a drag / scroll, this timeout means that we don't show a press effect. |
| * |
| * TODO: b/168524931 currently this delay is always used since we will require API changes to |
| * allow clickable to know whether it 'is in a scrollable container' - ideally this delay should |
| * only be used if there is the possibility of a scroll / drag. We should also expose this / |
| * [handlePressInteraction] in some similar form, to make it easy for developers to also use this |
| * delay when handling presses. |
| */ |
| internal expect val TapIndicationDelay: Long |
| |
| @Composable |
| @Suppress("ComposableModifierFactory") |
| internal fun Modifier.genericClickableWithoutGesture( |
| gestureModifiers: Modifier, |
| interactionSource: MutableInteractionSource, |
| indication: Indication?, |
| enabled: Boolean = true, |
| onClickLabel: String? = null, |
| role: Role? = null, |
| onLongClickLabel: String? = null, |
| onLongClick: (() -> Unit)? = null, |
| onClick: () -> Unit |
| ): Modifier { |
| val semanticModifier = Modifier.semantics(mergeDescendants = true) { |
| if (role != null) { |
| this.role = role |
| } |
| // b/156468846: add long click semantics and double click if needed |
| onClick( |
| action = { onClick(); true }, |
| label = onClickLabel |
| ) |
| if (onLongClick != null) { |
| onLongClick(action = { onLongClick(); true }, label = onLongClickLabel) |
| } |
| if (!enabled) { |
| disabled() |
| } |
| } |
| return this |
| .then(semanticModifier) |
| .indication(interactionSource, indication) |
| .hoverable(interactionSource = interactionSource) |
| .then(gestureModifiers) |
| } |