Aghajari / LazySwipeCards

LazySwipeCards is a card swiping Jetpack Compose library
Apache License 2.0
44 stars 3 forks source link

Bug: incompatible with Modifier.combinedClickable #3

Closed EgorBron closed 6 months ago

EgorBron commented 6 months ago

While using LazySwipeCards and applying Modifier.combinedClickable to content, swipe gestures not always detects by the library.

Code to reproduce:

@Composable
fun SwipeCards() {
  val itemList = /* list with contents */;
  val interactionSource = remember { MutableInteractionSource() }

  LazySwipeCards(
    modifier =
      Modifier
        .fillMaxSize(0.8f)
        .combinedClickable(
          interactionSource = interactionSource,
          indication = null,
          onLongClick = { /* do something */ }
        )
  ) {
    items(itemList) { item ->
      Card(modifier = Modifier.fillMaxSize()) {
        Text(item.toString()) // I used text for example
      }
    }
  }
}

Issue demonstration GIF

Without Modifier.combinedClickable it works fine:

Normal behavior GIF

I don't sure, is this a Compose related issue, or it can be fixed here.

EgorBron commented 6 months ago

Update: it is incompatible with Modifier.clickable too

Aghajari commented 6 months ago

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! 😃

EgorBron commented 6 months ago

@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.