| /* |
| * 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.ModifierLocalScrollableContainer |
| 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.DisposableEffect |
| import androidx.compose.runtime.MutableState |
| import androidx.compose.runtime.State |
| import androidx.compose.runtime.mutableStateOf |
| import androidx.compose.runtime.remember |
| import androidx.compose.runtime.rememberCoroutineScope |
| import androidx.compose.ui.Modifier |
| import androidx.compose.ui.composed |
| import androidx.compose.ui.geometry.Offset |
| import androidx.compose.ui.input.key.Key |
| import androidx.compose.ui.input.key.KeyEvent |
| import androidx.compose.ui.input.key.key |
| import androidx.compose.ui.input.key.onKeyEvent |
| import androidx.compose.ui.input.pointer.PointerEvent |
| import androidx.compose.ui.input.pointer.PointerEventPass |
| import androidx.compose.ui.input.pointer.PointerInputScope |
| import androidx.compose.ui.input.pointer.SuspendingPointerInputModifierNode |
| import androidx.compose.ui.modifier.ModifierLocalNode |
| import androidx.compose.ui.node.CompositionLocalConsumerModifierNode |
| import androidx.compose.ui.node.DelegatingNode |
| import androidx.compose.ui.node.ModifierNodeElement |
| import androidx.compose.ui.node.PointerInputModifierNode |
| import androidx.compose.ui.node.SemanticsModifierNode |
| import androidx.compose.ui.platform.InspectorInfo |
| import androidx.compose.ui.platform.debugInspectorInfo |
| import androidx.compose.ui.semantics.Role |
| import androidx.compose.ui.semantics.SemanticsConfiguration |
| 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.unit.IntSize |
| import androidx.compose.ui.unit.center |
| import androidx.compose.ui.unit.toOffset |
| import kotlinx.coroutines.CoroutineScope |
| 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. By 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 pressInteraction = remember { mutableStateOf<PressInteraction.Press?>(null) } |
| val currentKeyPressInteractions = remember { mutableMapOf<Key, PressInteraction.Press>() } |
| val centreOffset = remember { mutableStateOf(Offset.Zero) } |
| |
| val interactionModifier = if (enabled) { |
| ClickableInteractionElement( |
| interactionSource, |
| pressInteraction, |
| currentKeyPressInteractions |
| ) |
| } else Modifier |
| |
| val pointerInputModifier = ClickablePointerInputElement( |
| enabled, |
| interactionSource, |
| onClick, |
| centreOffset, |
| pressInteraction |
| ) |
| |
| Modifier |
| .genericClickableWithoutGesture( |
| interactionSource = interactionSource, |
| indication = indication, |
| indicationScope = rememberCoroutineScope(), |
| currentKeyPressInteractions = currentKeyPressInteractions, |
| keyClickOffset = centreOffset, |
| enabled = enabled, |
| onClickLabel = onClickLabel, |
| role = role, |
| onLongClickLabel = null, |
| onLongClick = null, |
| onClick = onClick |
| ) |
| .then(pointerInputModifier) |
| .then(interactionModifier) |
| }, |
| 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. By 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 hasLongClick = onLongClick != null |
| val pressInteraction = remember { mutableStateOf<PressInteraction.Press?>(null) } |
| val currentKeyPressInteractions = remember { mutableMapOf<Key, PressInteraction.Press>() } |
| 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 { |
| pressInteraction.value?.let { oldValue -> |
| val interaction = PressInteraction.Cancel(oldValue) |
| interactionSource.tryEmit(interaction) |
| pressInteraction.value = null |
| } |
| } |
| } |
| } |
| val centreOffset = remember { mutableStateOf(Offset.Zero) } |
| val interactionModifier = if (enabled) { |
| ClickableInteractionElement( |
| interactionSource, |
| pressInteraction, |
| currentKeyPressInteractions |
| ) |
| } else Modifier |
| |
| val pointerInputModifier = CombinedClickablePointerInputElement( |
| enabled, |
| interactionSource, |
| onClick, |
| centreOffset, |
| pressInteraction, |
| onLongClick, |
| onDoubleClick |
| ) |
| |
| Modifier |
| .genericClickableWithoutGesture( |
| interactionSource = interactionSource, |
| indication = indication, |
| indicationScope = rememberCoroutineScope(), |
| currentKeyPressInteractions = currentKeyPressInteractions, |
| keyClickOffset = centreOffset, |
| enabled = enabled, |
| onClickLabel = onClickLabel, |
| role = role, |
| onLongClickLabel = onLongClickLabel, |
| onLongClick = onLongClick, |
| onClick = onClick |
| ) |
| .then(pointerInputModifier) |
| .then(interactionModifier) |
| }, |
| 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 |
| } |
| ) |
| |
| internal suspend fun PressGestureScope.handlePressInteraction( |
| pressPoint: Offset, |
| interactionSource: MutableInteractionSource, |
| pressInteraction: MutableState<PressInteraction.Press?>, |
| delayPressInteraction: () -> Boolean |
| ) { |
| coroutineScope { |
| val delayJob = launch { |
| if (delayPressInteraction()) { |
| delay(TapIndicationDelay) |
| } |
| val press = PressInteraction.Press(pressPoint) |
| interactionSource.emit(press) |
| pressInteraction.value = press |
| } |
| 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 press = PressInteraction.Press(pressPoint) |
| val release = PressInteraction.Release(press) |
| interactionSource.emit(press) |
| interactionSource.emit(release) |
| } |
| } else { |
| pressInteraction.value?.let { pressInteraction -> |
| val endInteraction = if (success) { |
| PressInteraction.Release(pressInteraction) |
| } else { |
| PressInteraction.Cancel(pressInteraction) |
| } |
| interactionSource.emit(endInteraction) |
| } |
| } |
| pressInteraction.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. |
| */ |
| internal expect val TapIndicationDelay: Long |
| |
| /** |
| * Returns whether the root Compose layout node is hosted in a scrollable container outside of |
| * Compose. On Android this will be whether the root View is in a scrollable ViewGroup, as even if |
| * nothing in the Compose part of the hierarchy is scrollable, if the View itself is in a scrollable |
| * container, we still want to delay presses in case presses in Compose convert to a scroll outside |
| * of Compose. |
| * |
| * Combine this with [ModifierLocalScrollableContainer], which returns whether a [Modifier] is |
| * within a scrollable Compose layout, to calculate whether this modifier is within some form of |
| * scrollable container, and hence should delay presses. |
| */ |
| internal expect fun CompositionLocalConsumerModifierNode |
| .isComposeRootInScrollableContainer(): Boolean |
| |
| /** |
| * Whether the specified [KeyEvent] should trigger a press for a clickable component. |
| */ |
| internal expect val KeyEvent.isPress: Boolean |
| |
| /** |
| * Whether the specified [KeyEvent] should trigger a click for a clickable component. |
| */ |
| internal expect val KeyEvent.isClick: Boolean |
| |
| internal fun Modifier.genericClickableWithoutGesture( |
| interactionSource: MutableInteractionSource, |
| indication: Indication?, |
| indicationScope: CoroutineScope, |
| currentKeyPressInteractions: MutableMap<Key, PressInteraction.Press>, |
| keyClickOffset: State<Offset>, |
| enabled: Boolean = true, |
| onClickLabel: String? = null, |
| role: Role? = null, |
| onLongClickLabel: String? = null, |
| onLongClick: (() -> Unit)? = null, |
| onClick: () -> Unit |
| ): Modifier { |
| fun Modifier.detectPressAndClickFromKey() = this.onKeyEvent { keyEvent -> |
| when { |
| enabled && keyEvent.isPress -> { |
| // If the key already exists in the map, keyEvent is a repeat event. |
| // We ignore it as we only want to emit an interaction for the initial key press. |
| if (!currentKeyPressInteractions.containsKey(keyEvent.key)) { |
| val press = PressInteraction.Press(keyClickOffset.value) |
| currentKeyPressInteractions[keyEvent.key] = press |
| indicationScope.launch { interactionSource.emit(press) } |
| true |
| } else { |
| false |
| } |
| } |
| enabled && keyEvent.isClick -> { |
| currentKeyPressInteractions.remove(keyEvent.key)?.let { |
| indicationScope.launch { |
| interactionSource.emit(PressInteraction.Release(it)) |
| } |
| } |
| onClick() |
| true |
| } |
| else -> false |
| } |
| } |
| return this then |
| ClickableSemanticsElement( |
| enabled = enabled, |
| role = role, |
| onLongClickLabel = onLongClickLabel, |
| onLongClick = onLongClick, |
| onClickLabel = onClickLabel, |
| onClick = onClick |
| ) |
| .detectPressAndClickFromKey() |
| .indication(interactionSource, indication) |
| .hoverable(enabled = enabled, interactionSource = interactionSource) |
| .focusableInNonTouchMode(enabled = enabled, interactionSource = interactionSource) |
| } |
| |
| private class ClickableSemanticsElement( |
| private val enabled: Boolean, |
| private val role: Role?, |
| private val onLongClickLabel: String?, |
| private val onLongClick: (() -> Unit)?, |
| private val onClickLabel: String?, |
| private val onClick: () -> Unit |
| ) : ModifierNodeElement<ClickableSemanticsNode>() { |
| override fun create() = ClickableSemanticsNode( |
| enabled = enabled, |
| role = role, |
| onLongClickLabel = onLongClickLabel, |
| onLongClick = onLongClick, |
| onClickLabel = onClickLabel, |
| onClick = onClick |
| ) |
| |
| override fun update(node: ClickableSemanticsNode) = node.also { |
| it.enabled = enabled |
| it.role = role |
| it.onLongClickLabel = onLongClickLabel |
| it.onLongClick = onLongClick |
| it.onClickLabel = onClickLabel |
| it.onClick = onClick |
| } |
| |
| override fun InspectorInfo.inspectableProperties() = Unit |
| |
| override fun hashCode(): Int { |
| var result = enabled.hashCode() |
| result = 31 * result + role.hashCode() |
| result = 31 * result + onLongClickLabel.hashCode() |
| result = 31 * result + onLongClick.hashCode() |
| result = 31 * result + onClickLabel.hashCode() |
| result = 31 * result + onClick.hashCode() |
| return result |
| } |
| |
| override fun equals(other: Any?): Boolean { |
| if (this === other) return true |
| if (other !is ClickableSemanticsElement) return false |
| |
| if (enabled != other.enabled) return false |
| if (role != other.role) return false |
| if (onLongClickLabel != other.onLongClickLabel) return false |
| if (onLongClick != other.onLongClick) return false |
| if (onClickLabel != other.onClickLabel) return false |
| if (onClick != other.onClick) return false |
| |
| return true |
| } |
| } |
| |
| private class ClickableSemanticsNode( |
| var enabled: Boolean, |
| var role: Role?, |
| var onLongClickLabel: String?, |
| var onLongClick: (() -> Unit)?, |
| var onClickLabel: String?, |
| var onClick: () -> Unit, |
| ) : SemanticsModifierNode, Modifier.Node() { |
| override val semanticsConfiguration |
| get() = SemanticsConfiguration().apply { |
| isMergingSemanticsOfDescendants = true |
| if (this@ClickableSemanticsNode.role != null) { |
| role = this@ClickableSemanticsNode.role!! |
| } |
| onClick( |
| action = { onClick(); true }, |
| label = onClickLabel |
| ) |
| if (onLongClick != null) { |
| onLongClick( |
| action = { onLongClick?.invoke(); true }, |
| label = onLongClickLabel |
| ) |
| } |
| if (!enabled) { |
| disabled() |
| } |
| } |
| } |
| |
| // Only interactionSource should ever change - the rest must be remembered with no keys. |
| private class ClickableInteractionElement( |
| private val interactionSource: MutableInteractionSource, |
| private val pressInteraction: MutableState<PressInteraction.Press?>, |
| private val currentKeyPressInteractions: MutableMap<Key, PressInteraction.Press> |
| ) : ModifierNodeElement<ClickableInteractionNode>() { |
| override fun create(): ClickableInteractionNode = ClickableInteractionNode( |
| interactionSource, |
| pressInteraction, |
| currentKeyPressInteractions |
| ) |
| |
| override fun update(node: ClickableInteractionNode) = node.also { |
| it.updateInteractionSource(interactionSource) |
| } |
| |
| override fun equals(other: Any?): Boolean { |
| if (this === other) return true |
| if (other !is ClickableInteractionElement) return false |
| |
| if (interactionSource != other.interactionSource) return false |
| |
| return true |
| } |
| |
| override fun hashCode(): Int { |
| return interactionSource.hashCode() |
| } |
| |
| override fun InspectorInfo.inspectableProperties() = Unit |
| } |
| |
| private class ClickableInteractionNode( |
| private var interactionSource: MutableInteractionSource, |
| private val pressInteraction: MutableState<PressInteraction.Press?>, |
| private val currentKeyPressInteractions: MutableMap<Key, PressInteraction.Press> |
| ) : Modifier.Node() { |
| fun updateInteractionSource(interactionSource: MutableInteractionSource) { |
| if (this.interactionSource != interactionSource) { |
| disposeInteractionSource() |
| this.interactionSource = interactionSource |
| } |
| } |
| |
| override fun onDetach() { |
| disposeInteractionSource() |
| } |
| |
| private fun disposeInteractionSource() { |
| pressInteraction.value?.let { oldValue -> |
| val interaction = PressInteraction.Cancel(oldValue) |
| interactionSource.tryEmit(interaction) |
| } |
| currentKeyPressInteractions.values.forEach { |
| interactionSource.tryEmit(PressInteraction.Cancel(it)) |
| } |
| pressInteraction.value = null |
| currentKeyPressInteractions.clear() |
| } |
| } |
| |
| private class ClickablePointerInputElement( |
| private val enabled: Boolean, |
| private val interactionSource: MutableInteractionSource, |
| private val onClick: () -> Unit, |
| private val centreOffset: MutableState<Offset>, |
| private val pressInteraction: MutableState<PressInteraction.Press?> |
| ) : ModifierNodeElement<ClickablePointerInputNode>() { |
| override fun create(): ClickablePointerInputNode = ClickablePointerInputNode( |
| enabled, |
| interactionSource, |
| onClick, |
| centreOffset, |
| pressInteraction |
| ) |
| |
| override fun update(node: ClickablePointerInputNode) = node.also { |
| it.updateParameters(enabled, interactionSource, onClick) |
| } |
| |
| override fun equals(other: Any?): Boolean { |
| if (this === other) return true |
| if (other !is ClickablePointerInputElement) return false |
| |
| if (enabled != other.enabled) return false |
| if (interactionSource != other.interactionSource) return false |
| if (onClick != other.onClick) return false |
| |
| return true |
| } |
| |
| override fun InspectorInfo.inspectableProperties() = Unit |
| |
| override fun hashCode(): Int { |
| var result = enabled.hashCode() |
| result = 31 * result + interactionSource.hashCode() |
| result = 31 * result + onClick.hashCode() |
| return result |
| } |
| } |
| |
| private class CombinedClickablePointerInputElement( |
| private val enabled: Boolean, |
| private val interactionSource: MutableInteractionSource, |
| private val onClick: () -> Unit, |
| private val centreOffset: MutableState<Offset>, |
| private val pressInteraction: MutableState<PressInteraction.Press?>, |
| private val onLongClick: (() -> Unit)?, |
| private val onDoubleClick: (() -> Unit)? |
| ) : ModifierNodeElement<CombinedClickablePointerInputNode>() { |
| override fun create(): CombinedClickablePointerInputNode = CombinedClickablePointerInputNode( |
| enabled, |
| interactionSource, |
| onClick, |
| centreOffset, |
| pressInteraction, |
| onLongClick, |
| onDoubleClick |
| ) |
| |
| override fun update(node: CombinedClickablePointerInputNode) = node.also { |
| it.updateParameters(enabled, interactionSource, onClick, onLongClick, onDoubleClick) |
| } |
| |
| override fun equals(other: Any?): Boolean { |
| if (this === other) return true |
| if (other !is CombinedClickablePointerInputElement) return false |
| |
| if (enabled != other.enabled) return false |
| if (interactionSource != other.interactionSource) return false |
| if (onClick != other.onClick) return false |
| if (onLongClick != other.onLongClick) return false |
| if (onDoubleClick != other.onDoubleClick) return false |
| |
| return true |
| } |
| |
| override fun InspectorInfo.inspectableProperties() = Unit |
| |
| override fun hashCode(): Int { |
| var result = enabled.hashCode() |
| result = 31 * result + interactionSource.hashCode() |
| result = 31 * result + onClick.hashCode() |
| result = 31 * result + (onLongClick?.hashCode() ?: 0) |
| result = 31 * result + (onDoubleClick?.hashCode() ?: 0) |
| return result |
| } |
| } |
| |
| private sealed class AbstractClickablePointerInputNode( |
| protected var enabled: Boolean, |
| protected var interactionSource: MutableInteractionSource?, |
| protected var onClick: () -> Unit, |
| protected val centreOffset: MutableState<Offset>, |
| private val pressInteraction: MutableState<PressInteraction.Press?>, |
| ) : DelegatingNode(), ModifierLocalNode, CompositionLocalConsumerModifierNode, |
| PointerInputModifierNode { |
| |
| private val delayPressInteraction = { |
| ModifierLocalScrollableContainer.current || isComposeRootInScrollableContainer() |
| } |
| |
| private val pointerInputNode = SuspendingPointerInputModifierNode { pointerInput() } |
| // TODO: remove `.node` after aosp/2462416 lands and merge everything into one delegated |
| // block |
| .also { delegated { it.node } } |
| |
| protected abstract suspend fun PointerInputScope.pointerInput() |
| |
| override fun onPointerEvent( |
| pointerEvent: PointerEvent, |
| pass: PointerEventPass, |
| bounds: IntSize |
| ) { |
| pointerInputNode.onPointerEvent(pointerEvent, pass, bounds) |
| } |
| |
| override fun onCancelPointerInput() { |
| pointerInputNode.onCancelPointerInput() |
| } |
| |
| protected suspend fun PressGestureScope.handlePressInteraction(offset: Offset) { |
| interactionSource?.let { interactionSource -> |
| handlePressInteraction( |
| offset, |
| interactionSource, |
| pressInteraction, |
| delayPressInteraction |
| ) |
| } |
| } |
| |
| protected fun resetPointerInputHandler() = pointerInputNode.resetPointerInputHandler() |
| } |
| |
| private class ClickablePointerInputNode( |
| enabled: Boolean, |
| interactionSource: MutableInteractionSource, |
| onClick: () -> Unit, |
| centreOffset: MutableState<Offset>, |
| pressInteraction: MutableState<PressInteraction.Press?> |
| ) : AbstractClickablePointerInputNode( |
| enabled, |
| interactionSource, |
| onClick, |
| centreOffset, |
| pressInteraction |
| ) { |
| override suspend fun PointerInputScope.pointerInput() { |
| centreOffset.value = size.center.toOffset() |
| detectTapAndPress( |
| onPress = { offset -> |
| if (enabled) { |
| handlePressInteraction(offset) |
| } |
| }, |
| onTap = { if (enabled) onClick() } |
| ) |
| } |
| |
| fun updateParameters( |
| enabled: Boolean, |
| interactionSource: MutableInteractionSource, |
| onClick: () -> Unit, |
| ) { |
| // These are captured inside callbacks, not as an input to detectTapGestures, |
| // so no need need to reset pointer input handling |
| this.enabled = enabled |
| this.onClick = onClick |
| this.interactionSource = interactionSource |
| } |
| } |
| |
| private class CombinedClickablePointerInputNode( |
| enabled: Boolean, |
| interactionSource: MutableInteractionSource, |
| onClick: () -> Unit, |
| centreOffset: MutableState<Offset>, |
| pressInteraction: MutableState<PressInteraction.Press?>, |
| private var onLongClick: (() -> Unit)?, |
| private var onDoubleClick: (() -> Unit)? |
| ) : AbstractClickablePointerInputNode( |
| enabled, |
| interactionSource, |
| onClick, |
| centreOffset, |
| pressInteraction |
| ) { |
| override suspend fun PointerInputScope.pointerInput() { |
| centreOffset.value = size.center.toOffset() |
| detectTapGestures( |
| onDoubleTap = if (enabled && onDoubleClick != null) { |
| { onDoubleClick?.invoke() } |
| } else null, |
| onLongPress = if (enabled && onLongClick != null) { |
| { onLongClick?.invoke() } |
| } else null, |
| onPress = { offset -> |
| if (enabled) { |
| handlePressInteraction(offset) |
| } |
| }, |
| onTap = { if (enabled) onClick() } |
| ) |
| } |
| |
| fun updateParameters( |
| enabled: Boolean, |
| interactionSource: MutableInteractionSource, |
| onClick: () -> Unit, |
| onLongClick: (() -> Unit)?, |
| onDoubleClick: (() -> Unit)? |
| ) { |
| // These are captured inside callbacks, not as an input to detectTapGestures, |
| // so no need need to reset pointer input handling |
| this.onClick = onClick |
| this.interactionSource = interactionSource |
| |
| var changed = false |
| |
| // This is captured as a parameter to detectTapGestures, so we need to restart detecting |
| // gestures if it changes. |
| if (this.enabled != enabled) { |
| this.enabled = enabled |
| changed = true |
| } |
| |
| // We capture these inside the callback, so if the lambda changes value we don't want to |
| // reset input handling - only reset if they go from not-defined to defined, and vice-versa, |
| // as that is what is captured in the parameter to detectTapGestures. |
| if ((this.onLongClick == null) != (onLongClick == null)) { |
| changed = true |
| } |
| this.onLongClick = onLongClick |
| if ((this.onDoubleClick == null) != (onDoubleClick == null)) { |
| changed = true |
| } |
| this.onDoubleClick = onDoubleClick |
| if (changed) resetPointerInputHandler() |
| } |
| } |