Closed EgorBron closed 6 months ago
Update: it is incompatible with Modifier.clickable
too
Hello @EgorBron ,
Using clickable
or combinedClickable
consumes all events, preventing LazySwipeCards
from tracking pointer inputs effectively.
It's a natural constraint in Compose that multiple sources cannot consume a single pointer input simultaneously, which leads to conflicts when attempting to use multiple pointerInput
instances.
I've created a custom pointerInput
for detecting taps without consuming the movement. Instead of consuming the movements, we validate that the tap region does not exceed the touchSlop threshold, ensuring compatibility with LazySwipeCards' swipe gestures.
This custom function integrates seamlessly with the LazySwipeCards library and allows for the detection of additional gestures without compromising the functionality of the swipe gestures provided by the library.
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.ViewConfiguration
import kotlinx.coroutines.coroutineScope
fun Modifier.lazySwipeCardsCombinedClickable(
key: Any? = Unit,
pass: PointerEventPass = PointerEventPass.Initial,
configurationProvider: (ViewConfiguration) -> ViewConfiguration = { it },
onLongClick: (() -> Unit)? = null,
onDoubleClick: (() -> Unit)? = null,
onClick: (() -> Unit)? = null,
) = this.pointerInput(key) {
coroutineScope {
val configuration = configurationProvider(viewConfiguration)
awaitEachGesture {
suspend fun AwaitPointerEventScope.consumeLongClick(
down: PointerInputChange,
change: PointerInputChange?
) {
if (change != null) {
if (change.id == down.id && !change.pressed) {
change.consume()
return
}
}
do {
val consumeEvent = awaitPointerEvent(pass)
val consumeUp = consumeEvent.changes.firstOrNull {
it.id == down.id && !it.pressed
}
if (consumeUp != null) {
consumeUp.consume()
break
}
} while (consumeEvent.changes.any { it.id == down.id && it.pressed })
}
suspend fun AwaitPointerEventScope.listen(
down: PointerInputChange,
downPosition: Offset,
isSecondDown: Boolean
) {
val downTime = System.currentTimeMillis()
do {
val longPressTimeout = onLongClick?.let {
configuration.longPressTimeoutMillis
} ?: (Long.MAX_VALUE / 2)
val event = withTimeoutOrNull(longPressTimeout) {
awaitPointerEvent(pass)
}
if (event == null) {
onLongClick?.invoke()
consumeLongClick(down, null)
break
}
// ignore multiple taps
if (event.changes.size != 1) {
break
}
val currentTime = System.currentTimeMillis()
val up = event.changes.first()
// ignore if it has moved more than touchSlap
if ((up.position - downPosition).getDistance() > configuration.touchSlop) {
break
}
val isLongClick = currentTime - downTime >= configuration.longPressTimeoutMillis
if (isLongClick && onLongClick != null) {
onLongClick.invoke()
consumeLongClick(down, up)
break
}
// a tap might accrue but we don't listen to taps!
if (isLongClick && onClick == null && onDoubleClick == null) {
break
}
if (up.id != down.id || up.pressed) {
continue
}
up.consume()
if (isSecondDown) {
onDoubleClick?.invoke()
} else if (onDoubleClick == null) {
onClick?.invoke()
} else {
val secondDown = withTimeoutOrNull(configuration.doubleTapTimeoutMillis) {
val minUptime = up.uptimeMillis + configuration.doubleTapMinTimeMillis
var change: PointerInputChange
do {
change = awaitFirstDown()
} while (change.uptimeMillis < minUptime)
change
}
if (secondDown == null) {
onClick?.invoke()
} else {
listen(secondDown, downPosition, isSecondDown = true)
}
}
} while (event != null && event.changes.any { it.id == down.id && it.pressed })
}
val down = awaitFirstDown(pass = pass)
listen(down, down.position, isSecondDown = false)
}
}
}
Usage:
@Composable
fun SwipeCards() {
val itemList = /* list with contents */;
LazySwipeCards(
modifier = Modifier.fillMaxSize(0.8f),
cardModifier = Modifier
.lazySwipeCardsCombinedClickable(
onDoubleClick = {
Toast.makeText(context, "Double Click!", Toast.LENGTH_SHORT).show()
},
onLongClick = {
Toast.makeText(context, "Long Click!", Toast.LENGTH_SHORT).show()
},
onClick = {
Toast.makeText(context, "Click!", Toast.LENGTH_SHORT).show()
}
),
) {
/* contents */
}
}
You can also customize the configurations, for example, adjusting the touchSlop
parameter if you want a larger area for tap detection.
Modifier.lazySwipeCardsCombinedClickable(
configurationProvider = { default ->
object : ViewConfiguration {
override val doubleTapMinTimeMillis = default.doubleTapMinTimeMillis
override val doubleTapTimeoutMillis = default.doubleTapTimeoutMillis
override val longPressTimeoutMillis = default.longPressTimeoutMillis
override val touchSlop = default.touchSlop * 2f
}
},
...
)
Happy coding, and may your gestures always be as smooth as LazySwipeCards! 😃
@Aghajari thank you so much for help! Your library saved me a lot of time learning Compose's gesture modifiers and some other messy stuff in it.
While using
LazySwipeCards
and applyingModifier.combinedClickable
to content, swipe gestures not always detects by the library.Code to reproduce:
Without
Modifier.combinedClickable
it works fine:I don't sure, is this a Compose related issue, or it can be fixed here.