Add double click navigation
This commit is contained in:
parent
ce25690dfb
commit
3219e13fa7
@ -3,6 +3,7 @@ package center.sciprog.maps.features
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.PointerMatcher
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.input.pointer.PointerEvent
|
||||
import androidx.compose.ui.input.pointer.PointerKeyboardModifiers
|
||||
import center.sciprog.attributes.Attribute
|
||||
@ -35,7 +36,6 @@ public object ZoomRangeAttribute : Attribute<FloatRange>
|
||||
|
||||
public object AlphaAttribute : Attribute<Float>
|
||||
|
||||
|
||||
public fun <T : Any, F : Feature<T>> FeatureRef<T, F>.modifyAttributes(modify: AttributesBuilder.() -> Unit): FeatureRef<T, F> {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
parent.feature(
|
||||
@ -152,3 +152,10 @@ public fun <T: Any, F : Feature<T>> FeatureRef<T, F>.color(color: Color): Featur
|
||||
|
||||
public fun <T: Any, F : Feature<T>> FeatureRef<T, F>.zoomRange(range: FloatRange): FeatureRef<T, F> =
|
||||
modifyAttribute(ZoomRangeAttribute, range)
|
||||
|
||||
|
||||
|
||||
public object PathEffectAttribute: Attribute<PathEffect>
|
||||
|
||||
public fun <T: Any> FeatureRef<T, PointsFeature<T>>.pathEffect(effect: PathEffect): FeatureRef<T, PointsFeature<T>> =
|
||||
modifyAttribute(PathEffectAttribute, effect)
|
@ -0,0 +1,309 @@
|
||||
package center.sciprog.maps.compose
|
||||
|
||||
import androidx.compose.foundation.gestures.GestureCancellationException
|
||||
import androidx.compose.foundation.gestures.PressGestureScope
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.forEachGesture
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.pointer.*
|
||||
import androidx.compose.ui.platform.ViewConfiguration
|
||||
import androidx.compose.ui.unit.Density
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
|
||||
/*
|
||||
* Clone of tap gestures for mouse
|
||||
*/
|
||||
|
||||
private val NoPressGesture: suspend PressGestureScope.(event: PointerEvent) -> Unit = { }
|
||||
|
||||
internal fun PointerEvent.consume() = changes.forEach { it.consume() }
|
||||
|
||||
internal val PointerEvent.firstChange get() = changes.first()
|
||||
|
||||
public val PointerEvent.position: Offset get() = firstChange.position
|
||||
|
||||
|
||||
/**
|
||||
* Detects tap, double-tap, and long press gestures and calls [onClick], [onDoubleClick], and
|
||||
* [onLongClick], respectively, when detected. [onPress] is called when the press is detected
|
||||
* and the [PressGestureScope.tryAwaitRelease] and [PressGestureScope.awaitRelease] can be
|
||||
* used to detect when pointers have released or the gesture was canceled.
|
||||
* The first pointer down and final pointer up are consumed, and in the
|
||||
* case of long press, all changes after the long press is detected are consumed.
|
||||
*
|
||||
* Each function parameter receives an [Offset] representing the position relative to the containing
|
||||
* element. The [Offset] can be outside the actual bounds of the element itself meaning the numbers
|
||||
* can be negative or larger than the element bounds if the touch target is smaller than the
|
||||
* [ViewConfiguration.minimumTouchTargetSize].
|
||||
*
|
||||
* When [onDoubleClick] is provided, the tap gesture is detected only after
|
||||
* the [ViewConfiguration.doubleTapMinTimeMillis] has passed and [onDoubleClick] is called if the
|
||||
* second tap is started before [ViewConfiguration.doubleTapTimeoutMillis]. If [onDoubleClick] is not
|
||||
* provided, then [onClick] is called when the pointer up has been received.
|
||||
*
|
||||
* After the initial [onPress], if the pointer moves out of the input area, the position change
|
||||
* is consumed, or another gesture consumes the down or up events, the gestures are considered
|
||||
* canceled. That means [onDoubleClick], [onLongClick], and [onClick] will not be called after a
|
||||
* gesture has been canceled.
|
||||
*
|
||||
* If the first down event is consumed somewhere else, the entire gesture will be skipped,
|
||||
* including [onPress].
|
||||
*/
|
||||
public suspend fun PointerInputScope.detectClicks(
|
||||
onDoubleClick: (Density.(PointerEvent) -> Unit)? = null,
|
||||
onLongClick: (Density.(PointerEvent) -> Unit)? = null,
|
||||
onPress: suspend PressGestureScope.(event: PointerEvent) -> Unit = NoPressGesture,
|
||||
onClick: (Density.(PointerEvent) -> Unit)? = null,
|
||||
): Unit = coroutineScope {
|
||||
// special signal to indicate to the sending side that it shouldn't intercept and consume
|
||||
// cancel/up events as we're only require down events
|
||||
val pressScope = PressGestureScopeImpl(this@detectClicks)
|
||||
|
||||
forEachGesture {
|
||||
awaitPointerEventScope {
|
||||
val down = awaitFirstDownEvent()
|
||||
down.consume()
|
||||
|
||||
pressScope.reset()
|
||||
if (onPress !== NoPressGesture) launch {
|
||||
pressScope.onPress(down)
|
||||
}
|
||||
val longPressTimeout = onLongClick?.let {
|
||||
viewConfiguration.longPressTimeoutMillis
|
||||
} ?: (Long.MAX_VALUE / 2)
|
||||
var upOrCancel: PointerEvent? = null
|
||||
try {
|
||||
// wait for first tap up or long press
|
||||
upOrCancel = withTimeout(longPressTimeout) {
|
||||
waitForUpOrCancellation()
|
||||
}
|
||||
if (upOrCancel == null) {
|
||||
pressScope.cancel() // tap-up was canceled
|
||||
} else {
|
||||
upOrCancel.consume()
|
||||
pressScope.release()
|
||||
}
|
||||
} catch (_: PointerEventTimeoutCancellationException) {
|
||||
onLongClick?.invoke(this, down)
|
||||
consumeUntilUp()
|
||||
pressScope.release()
|
||||
}
|
||||
|
||||
if (upOrCancel != null) {
|
||||
// tap was successful.
|
||||
if (onDoubleClick == null) {
|
||||
onClick?.invoke(this, upOrCancel) // no need to check for double-tap.
|
||||
} else {
|
||||
// check for second tap
|
||||
val secondDown = awaitSecondDown(upOrCancel.firstChange)
|
||||
|
||||
if (secondDown == null) {
|
||||
onClick?.invoke(this, upOrCancel) // no valid second tap started
|
||||
} else {
|
||||
// Second tap down detected
|
||||
pressScope.reset()
|
||||
if (onPress !== NoPressGesture) {
|
||||
launch { pressScope.onPress(secondDown) }
|
||||
}
|
||||
|
||||
try {
|
||||
// Might have a long second press as the second tap
|
||||
withTimeout(longPressTimeout) {
|
||||
val secondUp = waitForUpOrCancellation()
|
||||
if (secondUp != null) {
|
||||
secondUp.consume()
|
||||
pressScope.release()
|
||||
onDoubleClick(down)
|
||||
} else {
|
||||
pressScope.cancel()
|
||||
onClick?.invoke(this, upOrCancel)
|
||||
}
|
||||
}
|
||||
} catch (e: PointerEventTimeoutCancellationException) {
|
||||
// The first tap was valid, but the second tap is a long press.
|
||||
// notify for the first tap
|
||||
onClick?.invoke(this, upOrCancel)
|
||||
|
||||
// notify for the long press
|
||||
onLongClick?.invoke(this, secondDown)
|
||||
consumeUntilUp()
|
||||
pressScope.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.consume()
|
||||
} while (event.changes.any { it.pressed })
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
): PointerEvent? = withTimeoutOrNull(viewConfiguration.doubleTapTimeoutMillis) {
|
||||
val minUptime = firstUp.uptimeMillis + viewConfiguration.doubleTapMinTimeMillis
|
||||
var event: PointerEvent
|
||||
// The second tap doesn't count if it happens before DoubleTapMinTime of the first tap
|
||||
do {
|
||||
event = awaitFirstDownEvent()
|
||||
} while (event.firstChange.uptimeMillis < minUptime)
|
||||
event
|
||||
}
|
||||
|
||||
///**
|
||||
// * Shortcut for cases when we only need to get press/click logic, as for cases without long press
|
||||
// * and double click we don't require channelling or any other complications.
|
||||
// *
|
||||
// * Each function parameter receives an [Offset] representing the position relative to the containing
|
||||
// * element. The [Offset] can be outside the actual bounds of the element itself meaning the numbers
|
||||
// * can be negative or larger than the element bounds if the touch target is smaller than the
|
||||
// * [ViewConfiguration.minimumTouchTargetSize].
|
||||
// */
|
||||
//internal suspend fun PointerInputScope.detectTapAndPress(
|
||||
// onPress: suspend PressGestureScope.(event: PointerEvent, Offset) -> Unit = NoPressGesture,
|
||||
// onTap: ((Offset) -> Unit)? = null,
|
||||
//) {
|
||||
// val pressScope = PressGestureScopeImpl(this)
|
||||
// forEachGesture {
|
||||
// coroutineScope {
|
||||
// pressScope.reset()
|
||||
// awaitPointerEventScope {
|
||||
//
|
||||
// val down = awaitFirstDownEvent()
|
||||
//
|
||||
// down.consume()
|
||||
//
|
||||
// if (onPress !== NoPressGesture) {
|
||||
// launch { pressScope.onPress(down, down.position) }
|
||||
// }
|
||||
//
|
||||
// val up = waitForUpOrCancellation()
|
||||
// if (up == null) {
|
||||
// pressScope.cancel() // tap-up was canceled
|
||||
// } else {
|
||||
// up.consume()
|
||||
// pressScope.release()
|
||||
// onTap?.invoke(up.position)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
||||
/**
|
||||
* Reads events until the first down is received. If [requireUnconsumed] is `true` and the first
|
||||
* down is consumed in the [PointerEventPass.Main] pass, that gesture is ignored.
|
||||
* If it was down caused by [PointerType.Mouse], this function reacts only on primary button.
|
||||
*/
|
||||
internal suspend fun AwaitPointerEventScope.awaitFirstDownEvent(
|
||||
requireUnconsumed: Boolean = true,
|
||||
): PointerEvent = awaitFirstDownEventOnPass(pass = PointerEventPass.Main, requireUnconsumed = requireUnconsumed)
|
||||
|
||||
internal suspend fun AwaitPointerEventScope.awaitFirstDownEventOnPass(
|
||||
pass: PointerEventPass,
|
||||
requireUnconsumed: Boolean,
|
||||
): PointerEvent {
|
||||
var event: PointerEvent
|
||||
do {
|
||||
event = awaitPointerEvent(pass)
|
||||
} while (!event.changes.all {
|
||||
if (requireUnconsumed) it.changedToDown() else it.changedToDownIgnoreConsumed()
|
||||
})
|
||||
return event
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads events until all pointers are up or the gesture was canceled. The gesture
|
||||
* is considered canceled when a pointer leaves the event region, a position change
|
||||
* has been consumed or a pointer down change event was consumed in the [PointerEventPass.Main]
|
||||
* pass. If the gesture was not canceled, the final up change is returned or `null` if the
|
||||
* event was canceled.
|
||||
*/
|
||||
internal suspend fun AwaitPointerEventScope.waitForUpOrCancellation(): PointerEvent? {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Main)
|
||||
if (event.changes.all { it.changedToUp() }) {
|
||||
// All pointers are up
|
||||
return event
|
||||
}
|
||||
|
||||
if (event.changes.any {
|
||||
it.isConsumed || it.isOutOfBounds(size, extendedTouchPadding)
|
||||
}
|
||||
) {
|
||||
return null // Canceled
|
||||
}
|
||||
|
||||
// Check for cancel by position consumption. We can look on the Final pass of the
|
||||
// existing pointer event because it comes after the Main pass we checked above.
|
||||
val consumeCheck = awaitPointerEvent(PointerEventPass.Final)
|
||||
if (consumeCheck.changes.any { it.isConsumed }) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [detectTapGestures]'s implementation of [PressGestureScope].
|
||||
*/
|
||||
internal class PressGestureScopeImpl(
|
||||
density: Density,
|
||||
) : PressGestureScope, Density by density {
|
||||
private var isReleased = false
|
||||
private var isCanceled = false
|
||||
private val mutex = Mutex(locked = false)
|
||||
|
||||
/**
|
||||
* Called when a gesture has been canceled.
|
||||
*/
|
||||
fun cancel() {
|
||||
isCanceled = true
|
||||
mutex.unlock()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when all pointers are up.
|
||||
*/
|
||||
fun release() {
|
||||
isReleased = true
|
||||
mutex.unlock()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a new gesture has started.
|
||||
*/
|
||||
fun reset() {
|
||||
mutex.tryLock() // If tryAwaitRelease wasn't called, this will be unlocked.
|
||||
isReleased = false
|
||||
isCanceled = false
|
||||
}
|
||||
|
||||
override suspend fun awaitRelease() {
|
||||
if (!tryAwaitRelease()) {
|
||||
throw GestureCancellationException("The press gesture was canceled.")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun tryAwaitRelease(): Boolean {
|
||||
if (!isReleased && !isCanceled) {
|
||||
mutex.lock()
|
||||
}
|
||||
return isReleased
|
||||
}
|
||||
}
|
@ -19,6 +19,7 @@ import kotlin.math.min
|
||||
public fun <T : Any> Modifier.mapControls(
|
||||
state: CoordinateViewScope<T>,
|
||||
features: FeatureGroup<T>,
|
||||
zoomOnDoubleClick: Boolean = true,
|
||||
): Modifier = with(state) {
|
||||
|
||||
// //selecting all tapabales ahead of time
|
||||
@ -43,30 +44,40 @@ public fun <T : Any> Modifier.mapControls(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.pointerInput(Unit) {
|
||||
detectClicks(
|
||||
onDoubleClick = { event ->
|
||||
if (zoomOnDoubleClick) {
|
||||
val invariant = event.position.toCoordinates(this)
|
||||
viewPoint = with(space) {
|
||||
viewPoint.zoomBy(
|
||||
if (event.buttons.isPrimaryPressed) 1f else if (event.buttons.isSecondaryPressed) -1f else 0f,
|
||||
invariant
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onClick = { event ->
|
||||
val coordinates = event.position.toCoordinates(this)
|
||||
val point = space.ViewPoint(coordinates, zoom)
|
||||
|
||||
if (event.type == PointerEventType.Press) {
|
||||
withTimeoutOrNull(500) {
|
||||
while (true) {
|
||||
if (awaitPointerEvent().type == PointerEventType.Release) {
|
||||
config.onClick?.handle(
|
||||
event,
|
||||
point
|
||||
)
|
||||
features.forEachWithAttributeUntil(ClickListenerAttribute) { _, feature, listeners ->
|
||||
if (point in (feature as DomainFeature)) {
|
||||
listeners.forEach { it.handle(event, point) }
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
config.onClick?.handle(
|
||||
event,
|
||||
point
|
||||
)
|
||||
|
||||
features.forEachWithAttributeUntil(ClickListenerAttribute) { _, feature, listeners ->
|
||||
if (point in (feature as DomainFeature)) {
|
||||
listeners.forEach { it.handle(event, point) }
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
|
@ -121,6 +121,7 @@ public fun <T : Any> DrawScope.drawFeature(
|
||||
color = color,
|
||||
strokeWidth = feature.stroke,
|
||||
pointMode = feature.pointMode,
|
||||
pathEffect = feature.attributes[PathEffectAttribute],
|
||||
alpha = alpha
|
||||
)
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user