All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.me.saket.telephoto.zoomable.internal.tappableAndQuickZoomable.kt Maven / Gradle / Ivy

There is a newer version: 0.14.0
Show newest version
@file:Suppress("NAME_SHADOWING")

package me.saket.telephoto.zoomable.internal

import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.verticalDrag
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
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.AwaitPointerEventScope
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastForEach
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.isActive
import me.saket.telephoto.zoomable.internal.QuickZoomEvent.QuickZoomStopped
import me.saket.telephoto.zoomable.internal.QuickZoomEvent.Zooming
import kotlin.math.abs

/**
 * Detects tap and quick zoom gestures.
 *
 * In a previous version, this used to only detect quick zoom gestures because taps were handled
 * separately using [detectTapGestures]. That was removed because preventing [detectTapGestures]
 * from consuming all events was proving to be messy and slightly difficult to follow.
 */
internal fun Modifier.tappableAndQuickZoomable(
  onPress: (Offset) -> Unit,
  onTap: ((Offset) -> Unit)?,
  onLongPress: ((Offset) -> Unit)?,
  onDoubleTap: (centroid: Offset) -> Unit,
  onQuickZoomStopped: () -> Unit,
  transformable: TransformableState,
  gesturesEnabled: Boolean,
): Modifier {
  return composed {
    val onPress by rememberUpdatedState(onPress)
    val onTap by rememberUpdatedState(onTap)
    val onLongPress by rememberUpdatedState(onLongPress)
    val onDoubleTap by rememberUpdatedState(onDoubleTap)
    val onQuickZoomStopped by rememberUpdatedState(onQuickZoomStopped)

    val quickZoomEvents = remember { Channel(capacity = Channel.UNLIMITED) }
    LaunchedEffect(Unit) {
      while (isActive) {
        var event: QuickZoomEvent = quickZoomEvents.receive()

        try {
          transformable.transform(MutatePriority.UserInput) {
            while (event is Zooming) {
              (event as? Zooming)?.let { event ->
                transformBy(
                  centroid = event.centroid,
                  zoomChange = event.zoomDelta,
                )
              }
              event = quickZoomEvents.receive()
            }
          }
          (event as? QuickZoomStopped)?.let {
            onQuickZoomStopped()
          }
        } catch (e: CancellationException) {
          // Ignore the cancellation and start over again.
        }
      }
    }

    return@composed pointerInput(gesturesEnabled) {
      detectTapAndQuickZoomGestures(
        onPress = onPress,
        onTap = onTap,
        onLongPress = onLongPress,
        onDoubleTap = {
          if (gesturesEnabled) {
            onDoubleTap(it)
          }
        },
        onQuickZoom = {
          if (gesturesEnabled) {
            quickZoomEvents.trySend(it)
          }
        },
      )
    }
  }
}

private suspend fun PointerInputScope.detectTapAndQuickZoomGestures(
  onPress: (Offset) -> Unit,
  onTap: ((Offset) -> Unit)?,
  onLongPress: ((Offset) -> Unit)?,
  onDoubleTap: (centroid: Offset) -> Unit,
  onQuickZoom: (QuickZoomEvent) -> Unit,
) {
  awaitEachGesture {
    val firstDown = awaitFirstDown()
    firstDown.consume()
    onPress(firstDown.position)

    val longPressTimeout = onLongPress?.let { viewConfiguration.longPressTimeoutMillis } ?: (Long.MAX_VALUE / 2)

    var firstUp: PointerInputChange? = null
    try {
      // Wait for first tap up or long press.
      firstUp = withTimeout(longPressTimeout) {
        waitForUpOrCancellation()
      }
      firstUp?.consume()

    } catch (_: PointerEventTimeoutCancellationException) {
      onLongPress?.invoke(firstDown.position)
      consumeUntilUp()
    }

    if (firstUp != null) {
      val secondDown = awaitSecondDown(firstUp = firstUp)
      secondDown?.consume()

      if (secondDown == null) {
        // No valid second tap started.
        onTap?.invoke(firstUp.position)

      } else if (areWithinTouchTargetSize(firstUp, secondDown)) {
        var dragged = false
        verticalDrag(secondDown.id) { drag ->
          dragged = true
          val dragDelta = drag.positionChange()
          val zoomDelta = 1f + (dragDelta.y * 0.004f) // Formula copied from https://github.com/usuiat/Zoomable.
          onQuickZoom(Zooming(secondDown.position, zoomDelta))
          drag.consume()
        }

        if (dragged) {
          onQuickZoom(QuickZoomStopped)
        } else {
          onDoubleTap(secondDown.position)
        }
      }
    }
  }
}

private fun PointerInputScope.areWithinTouchTargetSize(
  first: PointerInputChange,
  second: PointerInputChange
): Boolean {
  val allowedDistance = viewConfiguration.minimumTouchTargetSize.toSize()
  return (second.position - first.position).let { difference ->
    abs(difference.x) < allowedDistance.width && abs(difference.y) < allowedDistance.height
  }
}

private sealed interface QuickZoomEvent {
  data class Zooming(
    val centroid: Offset,
    val zoomDelta: Float,
  ) : QuickZoomEvent

  object QuickZoomStopped : QuickZoomEvent
}

/**
 * Copied from TapGestureDetector.kt. Can be deleted once
 * [it is made public](https://issuetracker.google.com/u/issues/279780929).
 *
 * Waits for [ViewConfiguration.doubleTapTimeoutMillis] for a second press event. If a
 * second press event is received before the time out, it is returned or `null` is returned
 * if no second press is received.
 */
private suspend fun AwaitPointerEventScope.awaitSecondDown(
  firstUp: PointerInputChange
): PointerInputChange? = withTimeoutOrNull(viewConfiguration.doubleTapTimeoutMillis) {
  val minUptime = firstUp.uptimeMillis + viewConfiguration.doubleTapMinTimeMillis
  var change: PointerInputChange
  // The second tap doesn't count if it happens before DoubleTapMinTime of the first tap
  do {
    change = awaitFirstDown(requireUnconsumed = true, pass = PointerEventPass.Main)
  } while (change.uptimeMillis < minUptime)
  change
}

/**
 * Copied from TapGestureDetector.kt.
 *
 * Consumes all pointer events until nothing is pressed and then returns. This method assumes
 * that something is currently pressed.
 */
private suspend fun AwaitPointerEventScope.consumeUntilUp() {
  do {
    val event = awaitPointerEvent()
    event.changes.fastForEach { it.consume() }
  } while (event.changes.fastAny { it.pressed })
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy