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.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.PointerMatcher
|
import androidx.compose.foundation.PointerMatcher
|
||||||
import androidx.compose.ui.graphics.Color
|
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.PointerEvent
|
||||||
import androidx.compose.ui.input.pointer.PointerKeyboardModifiers
|
import androidx.compose.ui.input.pointer.PointerKeyboardModifiers
|
||||||
import center.sciprog.attributes.Attribute
|
import center.sciprog.attributes.Attribute
|
||||||
@ -35,7 +36,6 @@ public object ZoomRangeAttribute : Attribute<FloatRange>
|
|||||||
|
|
||||||
public object AlphaAttribute : Attribute<Float>
|
public object AlphaAttribute : Attribute<Float>
|
||||||
|
|
||||||
|
|
||||||
public fun <T : Any, F : Feature<T>> FeatureRef<T, F>.modifyAttributes(modify: AttributesBuilder.() -> Unit): FeatureRef<T, F> {
|
public fun <T : Any, F : Feature<T>> FeatureRef<T, F>.modifyAttributes(modify: AttributesBuilder.() -> Unit): FeatureRef<T, F> {
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
parent.feature(
|
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> =
|
public fun <T: Any, F : Feature<T>> FeatureRef<T, F>.zoomRange(range: FloatRange): FeatureRef<T, F> =
|
||||||
modifyAttribute(ZoomRangeAttribute, range)
|
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(
|
public fun <T : Any> Modifier.mapControls(
|
||||||
state: CoordinateViewScope<T>,
|
state: CoordinateViewScope<T>,
|
||||||
features: FeatureGroup<T>,
|
features: FeatureGroup<T>,
|
||||||
|
zoomOnDoubleClick: Boolean = true,
|
||||||
): Modifier = with(state) {
|
): Modifier = with(state) {
|
||||||
|
|
||||||
// //selecting all tapabales ahead of time
|
// //selecting all tapabales ahead of time
|
||||||
@ -43,15 +44,30 @@ 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(
|
config.onClick?.handle(
|
||||||
event,
|
event,
|
||||||
point
|
point
|
||||||
)
|
)
|
||||||
|
|
||||||
features.forEachWithAttributeUntil(ClickListenerAttribute) { _, feature, listeners ->
|
features.forEachWithAttributeUntil(ClickListenerAttribute) { _, feature, listeners ->
|
||||||
if (point in (feature as DomainFeature)) {
|
if (point in (feature as DomainFeature)) {
|
||||||
listeners.forEach { it.handle(event, point) }
|
listeners.forEach { it.handle(event, point) }
|
||||||
@ -60,13 +76,8 @@ public fun <T : Any> Modifier.mapControls(
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
)
|
||||||
}.pointerInput(Unit) {
|
}.pointerInput(Unit) {
|
||||||
awaitPointerEventScope {
|
awaitPointerEventScope {
|
||||||
while (true) {
|
while (true) {
|
||||||
|
@ -121,6 +121,7 @@ public fun <T : Any> DrawScope.drawFeature(
|
|||||||
color = color,
|
color = color,
|
||||||
strokeWidth = feature.stroke,
|
strokeWidth = feature.stroke,
|
||||||
pointMode = feature.pointMode,
|
pointMode = feature.pointMode,
|
||||||
|
pathEffect = feature.attributes[PathEffectAttribute],
|
||||||
alpha = alpha
|
alpha = alpha
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user