commonMain.me.saket.telephoto.zoomable.internal.transformable.kt Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of zoomable-desktop Show documentation
Show all versions of zoomable-desktop Show documentation
A Modifier for making anything zoomable.
The newest version!
@file:Suppress("NAME_SHADOWING")
package me.saket.telephoto.zoomable.internal
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.calculateCentroid
import androidx.compose.foundation.gestures.calculateCentroidSize
import androidx.compose.foundation.gestures.calculatePan
import androidx.compose.foundation.gestures.calculateRotation
import androidx.compose.foundation.gestures.calculateZoom
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.PointerType
import androidx.compose.ui.input.pointer.SuspendingPointerInputModifierNode
import androidx.compose.ui.input.pointer.positionChanged
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.input.pointer.util.addPointerInputChange
import androidx.compose.ui.node.CompositionLocalConsumerModifierNode
import androidx.compose.ui.node.DelegatingNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.currentValueOf
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastForEach
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import me.saket.telephoto.zoomable.internal.TransformEvent.TransformDelta
import me.saket.telephoto.zoomable.internal.TransformEvent.TransformStarted
import me.saket.telephoto.zoomable.internal.TransformEvent.TransformStopped
import kotlin.math.PI
import kotlin.math.abs
// TODO: This fork of transformable() can be deleted when these are resolved:
// - https://issuetracker.google.com/issues/278713689
// - https://issuetracker.google.com/issues/266829800
// - https://issuetracker.google.com/issues/266829790
/**
* Enable transformation gestures of the modified UI element.
*
* Users should update their state themselves using default [TransformableState] and its
* `onTransformation` callback or by implementing [TransformableState] interface manually and
* reflect their own state in UI when using this component.
*
* This overload of transformable modifier provides [canPan] parameter, which allows the caller to
* control when the pan can start. making pan gesture to not to start when the scale is 1f makes
* transformable modifiers to work well within the scrollable container. See example:
* @sample androidx.compose.foundation.samples.TransformableSampleInsideScroll
*
* @param state [TransformableState] of the transformable. Defines how transformation events will be
* interpreted by the user land logic, contains useful information about on-going events and
* provides animation capabilities.
* @param canPan whether the pan gesture can be performed or not given the pan offset
* @param lockRotationOnZoomPan If `true`, rotation is allowed only if touch slop is detected for
* rotation before pan or zoom motions. If not, pan and zoom gestures will be detected, but rotation
* gestures will not be. If `false`, once touch slop is reached, all three gestures are detected.
* @param enabled whether zooming by gestures is enabled or not
*/
@ExperimentalFoundationApi
internal data class TransformableElement(
private val state: TransformableState,
private val canPan: (Offset) -> Boolean,
private val lockRotationOnZoomPan: Boolean,
private val enabled: Boolean,
private val onTransformStopped: (velocity: Velocity) -> Unit = {},
) : ModifierNodeElement() {
override fun create(): TransformableNode = TransformableNode(
state, canPan, lockRotationOnZoomPan, enabled, onTransformStopped
)
override fun update(node: TransformableNode) {
node.update(state, canPan, lockRotationOnZoomPan, enabled, onTransformStopped)
}
override fun InspectorInfo.inspectableProperties() {
name = "transformable"
properties["state"] = state
properties["canPan"] = canPan
properties["enabled"] = enabled
properties["lockRotationOnZoomPan"] = lockRotationOnZoomPan
properties["onTransformStopped"] = onTransformStopped
}
}
internal class TransformableNode(
private var state: TransformableState,
private var canPan: (Offset) -> Boolean,
private var lockRotationOnZoomPan: Boolean,
private var enabled: Boolean,
private var onTransformStopped: (velocity: Velocity) -> Unit = {},
) : DelegatingNode(), CompositionLocalConsumerModifierNode {
private val updatedCanPan: (Offset) -> Boolean = { canPan.invoke(it) }
private val updatedOnTransformStopped: (Velocity) -> Unit = { onTransformStopped.invoke(it) }
private val channel = Channel(capacity = Channel.UNLIMITED)
private val pointerInputNode = delegate(SuspendingPointerInputModifierNode {
if (!enabled) return@SuspendingPointerInputModifierNode
coroutineScope {
launch(start = CoroutineStart.UNDISPATCHED) {
while (isActive) {
var event = channel.receive()
if (event !is TransformStarted) continue
try {
state.transform(MutatePriority.UserInput) {
while (event !is TransformStopped) {
(event as? TransformDelta)?.let {
transformBy(it.zoomChange, it.panChange, it.rotationChange, it.centroid)
}
event = channel.receive()
}
}
(event as? TransformStopped)?.let { event ->
updatedOnTransformStopped(event.velocity)
}
} catch (_: CancellationException) {
// ignore the cancellation and start over again.
}
}
}
awaitEachGesture {
val velocityTracker = VelocityTracker()
var wasCancelled = false
try {
detectZoom(lockRotationOnZoomPan, channel, updatedCanPan, velocityTracker)
} catch (exception: CancellationException) {
wasCancelled = true
if (!isActive) throw exception
} finally {
val maximumVelocity = currentValueOf(LocalViewConfiguration).let {
Velocity(it.maximumFlingVelocity, it.maximumFlingVelocity)
}
val velocity = if (wasCancelled) Velocity.Zero else velocityTracker.calculateFiniteVelocity(maximumVelocity)
channel.trySend(TransformStopped(velocity))
}
}
}
})
fun update(
state: TransformableState,
canPan: (Offset) -> Boolean,
lockRotationOnZoomPan: Boolean,
enabled: Boolean,
onTransformStopped: (velocity: Velocity) -> Unit,
) {
this.canPan = canPan
this.onTransformStopped = onTransformStopped
val needsReset = this.state != state ||
this.enabled != enabled ||
this.lockRotationOnZoomPan != lockRotationOnZoomPan
if (needsReset) {
this.state = state
this.enabled = enabled
this.lockRotationOnZoomPan = lockRotationOnZoomPan
pointerInputNode.resetPointerInputHandler()
}
}
}
private suspend fun AwaitPointerEventScope.detectZoom(
panZoomLock: Boolean,
channel: Channel,
canPan: (Offset) -> Boolean,
velocityTracker: VelocityTracker,
) {
var rotation = 0f
var zoom = 1f
var pan = Offset.Zero
var pastTouchSlop = false
var lockedToPanZoom = false
val trackingPointerId = awaitFirstDown(requireUnconsumed = false).id
do {
val event = awaitPointerEvent()
val canceled = event.changes.fastAny { it.isConsumed }
if (!canceled) {
event.changes.fastForEach {
if (it.id == trackingPointerId) {
velocityTracker.addPointerInputChange(it)
}
}
val zoomChange = event.calculateZoom()
val rotationChange = event.calculateRotation()
val panChange = event.calculatePan()
if (!pastTouchSlop) {
zoom *= zoomChange
rotation += rotationChange
pan += panChange
val centroidSize = event.calculateCentroidSize(useCurrent = false)
val zoomMotion = abs(1 - zoom) * centroidSize
val rotationMotion = abs(rotation * PI.toFloat() * centroidSize / 180f)
val panMotion = pan.getDistance()
val touchSlop = viewConfiguration.pointerSlop(event.changes[0].type)
if (event.changes.size > 1 ||
zoomMotion > touchSlop ||
rotationMotion > touchSlop ||
(panMotion > touchSlop && canPan.invoke(panChange))
) {
pastTouchSlop = true
lockedToPanZoom = panZoomLock && rotationMotion < touchSlop
channel.trySend(TransformStarted)
}
}
if (pastTouchSlop) {
val centroid = event.calculateCentroid(useCurrent = false)
val effectiveRotation = if (lockedToPanZoom) 0f else rotationChange
if (effectiveRotation != 0f ||
zoomChange != 1f ||
(panChange != Offset.Zero && canPan.invoke(panChange))
) {
channel.trySend(TransformDelta(zoomChange, panChange, effectiveRotation, centroid))
}
event.changes.fastForEach {
if (it.positionChanged()) {
it.consume()
}
}
}
} else {
channel.trySend(TransformStopped(Velocity.Zero))
}
val finalEvent = awaitPointerEvent(pass = PointerEventPass.Final)
// someone consumed while we were waiting for touch slop
val finallyCanceled = finalEvent.changes.fastAny { it.isConsumed } && !pastTouchSlop
} while (!canceled && !finallyCanceled && event.changes.fastAny { it.pressed })
}
private sealed class TransformEvent {
data object TransformStarted : TransformEvent()
class TransformStopped(val velocity: Velocity) : TransformEvent()
class TransformDelta(
val zoomChange: Float,
val panChange: Offset,
val rotationChange: Float,
val centroid: Offset,
) : TransformEvent()
}
/**
* Copied from compose.foundation to solve incompatibility with FlickToDismiss.
*
* [More details](https://issuetracker.google.com/issues/348923065).
*
* [Original code](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/DragGestureDetector.kt;l=940;drc=a18f72ab3de68971fb30d894d41f4441aa09fd4f).
*/
private fun ViewConfiguration.pointerSlop(pointerType: PointerType): Float {
return when (pointerType) {
PointerType.Mouse -> {
val mouseSlop = 0.125.dp
val defaultTouchSlop = 18.dp // The default touch slop on Android devices
touchSlop * (mouseSlop / defaultTouchSlop)
}
else -> touchSlop
}
}
// Workaround for https://github.com/saket/telephoto/issues/97
// (https://issuetracker.google.com/issues/309841148#comment7)
private fun VelocityTracker.calculateFiniteVelocity(maximumVelocity: Velocity): Velocity {
val calculated = calculateVelocity(maximumVelocity = maximumVelocity)
return if (calculated.x.isNaN() || calculated.y.isNaN()) {
maximumVelocity
} else {
calculated
}
}
© 2015 - 2024 Weber Informatics LLC | Privacy Policy