DoubleTapGestureFilter.kt
/*
* 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.
*/
@file:OptIn(ExperimentalPointerInput::class)
package androidx.compose.ui.gesture
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.gesture.customevents.DelayUpEvent
import androidx.compose.ui.gesture.customevents.DelayUpMessage
import androidx.compose.ui.input.pointer.CustomEventDispatcher
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerId
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerInputFilter
import androidx.compose.ui.input.pointer.anyPositionChangeConsumed
import androidx.compose.ui.input.pointer.changedToDown
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.consumeDownChange
import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.inMilliseconds
import androidx.compose.ui.util.annotation.VisibleForTesting
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastForEach
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
// TODO(b/138754591): The behavior of this gesture detector needs to be finalized.
// TODO(b/139020678): Probably has shared functionality with other press based detectors.
/**
* Responds to pointers going down and up (tap) and then down and up again (another tap)
* with minimal gap of time between the first up and the second down.
*
* Note: This is a temporary implementation to unblock dependents. Once the underlying API that
* allows double tap to temporarily block tap from firing is complete, this gesture detector will
* not block tap when the first "up" occurs. It will however block the 2nd up from causing tap to
* fire.
*
* Also, given that this gesture detector is so temporary, opting to not write substantial tests.
*/
fun Modifier.doubleTapGestureFilter(
onDoubleTap: (Offset) -> Unit
): Modifier = composed(
inspectorInfo = debugInspectorInfo {
name = "doubleTapGestureFilter"
value = onDoubleTap
}
) {
val scope = rememberCoroutineScope()
val filter = remember { DoubleTapGestureFilter(scope) }
filter.onDoubleTap = onDoubleTap
PointerInputModifierImpl(filter)
}
internal class DoubleTapGestureFilter(
val coroutineScope: CoroutineScope
) : PointerInputFilter() {
lateinit var onDoubleTap: (Offset) -> Unit
private enum class State {
Idle, Down, Up, SecondDown
}
@VisibleForTesting
internal var doubleTapTimeout = DoubleTapTimeout
private var state = State.Idle
private var job: Job? = null
private lateinit var delayUpDispatcher: DelayUpDispatcher
override fun onInit(customEventDispatcher: CustomEventDispatcher) {
delayUpDispatcher = DelayUpDispatcher(customEventDispatcher)
}
override fun onPointerEvent(
pointerEvent: PointerEvent,
pass: PointerEventPass,
bounds: IntSize
) {
val changes = pointerEvent.changes
if (pass == PointerEventPass.Main) {
if (state == State.Idle && changes.all { it.changedToDown() }) {
state = State.Down
return
}
if (state == State.Down && changes.all { it.changedToUp() }) {
state = State.Up
delayUpDispatcher.delayUp(changes)
job = coroutineScope.launch {
delay(doubleTapTimeout.inMilliseconds())
state = State.Idle
delayUpDispatcher.allowUp()
}
return
}
if (state == State.Up && changes.all { it.changedToDown() }) {
state = State.SecondDown
job?.cancel()
delayUpDispatcher.disallowUp()
return
}
if (state == State.SecondDown && changes.all { it.changedToUp() }) {
state = State.Idle
onDoubleTap.invoke(changes[0].previous.position)
changes.fastForEach {
it.consumeDownChange()
}
}
}
if (pass == PointerEventPass.Final) {
val noPointersAreInBoundsAndNotUpState =
(state != State.Up && !changes.anyPointersInBounds(bounds))
val anyPositionChangeConsumed = changes.fastAny { it.anyPositionChangeConsumed() }
if (noPointersAreInBoundsAndNotUpState || anyPositionChangeConsumed) {
// A pointers movement was consumed or all of our pointers are out of bounds, so
// reset to idle.
fullReset()
}
}
}
override fun onCancel() {
fullReset()
}
private fun fullReset() {
delayUpDispatcher.disallowUp()
job?.cancel()
state = State.Idle
}
private class DelayUpDispatcher(val customEventDispatcher: CustomEventDispatcher) {
// Non-writeable because we send this to customEventDispatcher and we don't want to ever
// accidentally mutate what we have sent.
private var blockedUpEvents: Set<PointerId>? = null
fun delayUp(changes: List<PointerInputChange>) {
blockedUpEvents =
changes
.mapTo(mutableSetOf()) { it.id }
.also {
customEventDispatcher.retainHitPaths(it)
customEventDispatcher.dispatchCustomEvent(
DelayUpEvent(DelayUpMessage.DelayUp, it)
)
}
}
fun disallowUp() {
unBlockUpEvents(true)
}
fun allowUp() {
unBlockUpEvents(false)
}
private fun unBlockUpEvents(upIsConsumed: Boolean) {
blockedUpEvents?.let {
val message =
if (upIsConsumed) {
DelayUpMessage.DelayedUpConsumed
} else {
DelayUpMessage.DelayedUpNotConsumed
}
customEventDispatcher.dispatchCustomEvent(
DelayUpEvent(message, it)
)
customEventDispatcher.releaseHitPaths(it)
}
blockedUpEvents = null
}
}
}