Skip to content

Commit

Permalink
Transfer the logic of enabling/disabling pan and zoom to the gesture …
Browse files Browse the repository at this point in the history
…handler
  • Loading branch information
asynchaizer committed Feb 2, 2025
1 parent 48504b5 commit 5ed060a
Show file tree
Hide file tree
Showing 19 changed files with 525 additions and 329 deletions.
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,18 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Added

- Support for StartAnimationUseCase in bar-chart (GroupedVerticalBarPlot)
- Ability to disable the consumption of gesture events

### Changed

- Move the logic of enabling/disabling pan and zoom to the gesture handler
- A separate object has been created for the gesture configuration GestureConfig
- Removing the gesture logic "pastTouchSlop", in practice it turned out to be inconsistent when capturing panning
from the parent container

### Fixed

- Discrete panning in X and Y axes (#104)

## [0.8.0]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.IntSize
import io.github.koalaplot.core.gestures.DefaultTransformGesturesHandler
import io.github.koalaplot.core.gestures.GestureConfig
import io.github.koalaplot.core.gestures.TransformGesturesHandlerWithLockZoomRatio

/**
Expand All @@ -14,21 +15,18 @@ import io.github.koalaplot.core.gestures.TransformGesturesHandlerWithLockZoomRat
internal actual fun Modifier.onGestureInput(
key1: Any?,
key2: Any?,
panLock: Boolean,
zoomLock: Boolean,
lockZoomRatio: Boolean,
onZoomChange: (size: IntSize, centroid: Offset, zoomX: Float, zoomY: Float) -> Unit,
onPanChange: (size: IntSize, pan: Offset) -> Unit,
): Modifier = this then Modifier.pointerInput(key1, key2, panLock, zoomLock, lockZoomRatio) {
val gesturesHandler = if (lockZoomRatio) {
TransformGesturesHandlerWithLockZoomRatio()
} else {
gestureConfig: GestureConfig,
onZoomChange: (size: IntSize, centroid: Offset, zoom: ZoomFactor) -> Unit,
onPanChange: (size: IntSize, pan: Offset) -> Boolean,
): Modifier = this then Modifier.pointerInput(key1, key2, gestureConfig) {
val gesturesHandler = if (gestureConfig.independentZoomEnabled) {
DefaultTransformGesturesHandler()
} else {
TransformGesturesHandlerWithLockZoomRatio()
}
gesturesHandler.detectTransformGestures(
scope = this,
panLock = panLock,
zoomLock = zoomLock,
gestureConfig = gestureConfig,
onZoomChange = onZoomChange,
onPanChange = onPanChange
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastForEach
import io.github.koalaplot.core.util.ZoomFactor
import io.github.koalaplot.core.util.max
import kotlin.math.abs

/**
Expand All @@ -21,17 +20,12 @@ internal class DefaultTransformGesturesHandler : TransformGesturesHandler {

override suspend fun detectTransformGestures(
scope: PointerInputScope,
panLock: Boolean,
zoomLock: Boolean,
onZoomChange: (size: IntSize, centroid: Offset, zoomX: Float, zoomY: Float) -> Unit,
onPanChange: (size: IntSize, pan: Offset) -> Unit,
gestureConfig: GestureConfig,
onZoomChange: (size: IntSize, centroid: Offset, zoomX: ZoomFactor) -> Unit,
onPanChange: (size: IntSize, pan: Offset) -> Boolean,
) = scope.awaitEachGesture {
var pan = Offset.Zero
var pastTouchSlop = false
val touchSlop = viewConfiguration.touchSlop
val minTouchesDistance = viewConfiguration.minimumTouchTargetSize.width.toPx()

var (zoomX, zoomY) = ZoomFactor.Neutral
var isHorizontalZoom = false
var isZoomDirectionSet = false

Expand All @@ -40,46 +34,39 @@ internal class DefaultTransformGesturesHandler : TransformGesturesHandler {
val event = awaitPointerEvent()
val canceled = event.changes.fastAny { it.isConsumed }
if (!canceled) {
var zoomXYChange = event.calculateZoomXY()
val panChange = event.calculatePan()
var zoomChange = event
.calculateZoomXY()
.applyZoomLocks(gestureConfig.zoomXEnabled, gestureConfig.zoomYEnabled)
val panChange = event
.calculatePan()
.applyPanLocks(gestureConfig.panXEnabled, gestureConfig.panYEnabled)

if (!isZoomDirectionSet) {
event.isHorizontalZoom()?.also {
isHorizontalZoom = it
isZoomDirectionSet = true
}
} else {
zoomXYChange = zoomXYChange.resetOrthogonalAxis(isHorizontalZoom)
zoomChange = zoomChange.resetOrthogonalAxis(isHorizontalZoom)
}

if (!pastTouchSlop) {
zoomX *= zoomXYChange.x
zoomY *= zoomXYChange.y
pan += panChange
var allowConsumption = false

val (centroidSizeX, centroidSizeY) = event.calculateCentroidSizeXY(useCurrent = false)
val zoomMotion = max(
calculateZoomMotion(zoomX, centroidSizeX),
calculateZoomMotion(zoomY, centroidSizeY),
)
val zoomAllowed = gestureConfig.zoomEnabled && zoomChange != ZoomFactor.Neutral &&
event.zoomGestureIsCorrect(minTouchesDistance, isHorizontalZoom)

val panMotion = pan.getDistance()
if (zoomAllowed) {
val centroid = event.calculateCentroid(useCurrent = false)
onZoomChange(size, centroid, zoomChange)
allowConsumption = true
}

if (zoomMotion > touchSlop || panMotion > touchSlop) {
pastTouchSlop = true
}
} else {
if (!zoomLock && zoomXYChange != ZoomFactor.Neutral &&
event.zoomGestureIsCorrect(minTouchesDistance, isHorizontalZoom)
) {
val centroid = event.calculateCentroid(useCurrent = false)
onZoomChange(size, centroid, zoomXYChange.x, zoomXYChange.y)
}
if (!panLock && panChange != Offset.Zero) {
onPanChange(size, panChange)
}
event.consumeChangedPositions()
val panAllowed = gestureConfig.panEnabled && panChange != Offset.Zero

if (panAllowed) {
allowConsumption = allowConsumption || onPanChange(size, panChange)
}
if (allowConsumption) event.consumeChangedPositions()
}
} while (!canceled && event.changes.fastAny { it.pressed })
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package io.github.koalaplot.core.gestures

import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable

/**
* Configuration for gesture handling, including settings for panning and zooming and their use
*
* @property panXEnabled Whether the pan is enabled for the X-axis
* @property panYEnabled Whether the pan is enabled for the Y-axis
* @property panXConsumptionEnabled Whether the pan on the X-axis should be consumed.
* Has no effect for `js` and `wasmJs`
* @property panYConsumptionEnabled Whether the pan on the Y-axis should be consumed.
* Has no effect for `js` and `wasmJs`
* @property zoomXEnabled Whether the zoom is enabled for the X-axis
* @property zoomYEnabled Whether the zoom is enabled for the Y-axis
* @property independentZoomEnabled Whether independent zoom (zooming on X and Y axes separately) is allowed
*/
@Immutable
public data class GestureConfig(

/**
* Whether the pan gesture is enabled for the X-axis
* If `true`, pan gestures along the X-axis will be processed
* If `false`, no pan gestures will be handled for the X-axis
*/
val panXEnabled: Boolean = false,

/**
* Whether the pan gesture is enabled for the Y-axis
* If `true`, pan gestures along the Y-axis will be processed
* If `false`, no pan gestures will be handled for the Y-axis
*/
val panYEnabled: Boolean = false,

/**
* Whether the pan gesture on the X-axis should be consumed. Has no effect for `js` and `wasmJs`
* If `true`, the pan gesture will be consumed and will not propagate further
* If `false`, the pan gesture will not be consumed and may propagate. However, the gesture will still be
* processed but `PointerInputChange#consume` will not be called, allowing parent containers to process
* the gesture if needed
*/
val panXConsumptionEnabled: Boolean = true,

/**
* Whether the pan gesture on the Y-axis should be consumed. Has no effect for `js` and `wasmJs`
* If `true`, the pan gesture will be consumed and will not propagate further
* If `false`, the pan gesture will not be consumed and may propagate. However, the gesture will still be
* processed but `PointerInputChange#consume` will not be called, allowing parent containers to process
* the gesture if needed
*/
val panYConsumptionEnabled: Boolean = true,

/**
* Whether the zoom gesture is enabled for the X-axis
* If `true`, zoom gestures along the X-axis will be processed
* If `false`, no zoom gestures will be handled for the X-axis
*/
val zoomXEnabled: Boolean = false,

/**
* Whether the zoom gesture is enabled for the Y-axis
* If `true`, zoom gestures along the Y-axis will be processed
* If `false`, no zoom gestures will be handled for the Y-axis
*/
val zoomYEnabled: Boolean = false,

/**
* Whether independent zoom (zooming on X and Y axes separately) is allowed
*
* If `true`, the zoom can be either only on the X axis, or only on the Y axis,
* or independently on the X and Y axes at the same time (the behavior depends on the target platform),
* False if the total zoom factor must be used.
*
* If `false`, zooming will be locked to a single ratio (X and Y zoom together)
*
* Behavior for Android and iOS:
* True does not mean getting independent zoom coefficients simultaneously for each axis,
* if the zoom was initiated:
* - horizontally - the zoom coefficient will change only for the X axis,
* - vertically - the zoom coefficient will change only for the Y axis.
*
* Behavior for Desktop platforms:
* True means getting independent zoom coefficients simultaneously or separately for each axis,
* if the zoom was initiated:
* - horizontally - the zoom coefficient will change only for the X axis,
* - vertically - the zoom coefficient will change only for the Y axis,
* - diagonally - the zoom coefficient will change along the axes X and Y at the same time
*
* Behavior for JS and wasmJS:
* True means getting independent zoom coefficients simultaneously or separately for each axis,
* if the zoom was initiated:
* - horizontally - the zoom coefficient will change only for the X axis,
* - vertically - the zoom coefficient will change only for the Y axis,
* - diagonally - the zoom coefficient will change along the axes X and Y at the same time.
*
* JS and wasmJS have slight differences in response behavior (for example, zoom coefficients for the same gesture
* will be interpreted with a difference of several tenths or hundredths), and zoom handling with the mouse wheel
* scroll while pressing Ctrl/Cmd is not supported (a problem with browser scaling)
*/
val independentZoomEnabled: Boolean = false,
) {
@Stable
val gesturesEnabled: Boolean
get() = panXEnabled || panYEnabled || zoomXEnabled || zoomYEnabled

@Stable
val panEnabled: Boolean
get() = panXEnabled || panYEnabled

@Stable
val zoomEnabled: Boolean = zoomXEnabled || zoomYEnabled
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import androidx.compose.ui.util.fastForEach
import io.github.koalaplot.core.util.ZoomFactor
import kotlin.math.abs

internal const val DefaultPanValue = 0f

internal fun PointerEvent.consumeChangedPositions() {
changes.fastForEach { change ->
if (change.positionChanged()) change.consume()
Expand All @@ -17,13 +19,6 @@ internal fun Offset.getDistanceX(): Float = abs(x)

internal fun Offset.getDistanceY(): Float = abs(y)

/**
* Returns the largest zoom value of the two axes
* @param zoom The current zoom value on the X or Y axes
* @param size The width of the area for the [zoom] axis
*/
internal fun calculateZoomMotion(zoom: Float, size: Float): Float = abs(ZoomFactor.NeutralPoint - zoom) * size

/**
* Returns the largest zoom value of the two axes
* @param zoomX The current zoom value on the X axis
Expand All @@ -34,3 +29,13 @@ internal fun getMaxZoomDeviation(zoomX: Float, zoomY: Float): Float {
val deviationY = abs(zoomY - ZoomFactor.NeutralPoint)
return if (deviationX > deviationY) zoomX else zoomY
}

internal fun Offset.applyPanLocks(panXEnabled: Boolean, panYEnabled: Boolean): Offset = this.copy(
x = if (panXEnabled) this.x else DefaultPanValue,
y = if (panYEnabled) this.y else DefaultPanValue,
)

internal fun ZoomFactor.applyZoomLocks(zoomXEnabled: Boolean, zoomYEnabled: Boolean): ZoomFactor = this.copy(
x = if (zoomXEnabled) this.x else ZoomFactor.NeutralPoint,
y = if (zoomYEnabled) this.y else ZoomFactor.NeutralPoint,
)
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package io.github.koalaplot.core.gestures
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.unit.IntSize
import io.github.koalaplot.core.util.ZoomFactor

/**
* Interface for handling touch-based transformation gestures, such as zoom and pan
Expand All @@ -11,9 +12,8 @@ internal interface TransformGesturesHandler {

suspend fun detectTransformGestures(
scope: PointerInputScope,
panLock: Boolean,
zoomLock: Boolean,
onZoomChange: (size: IntSize, centroid: Offset, zoomX: Float, zoomY: Float) -> Unit,
onPanChange: (size: IntSize, pan: Offset) -> Unit,
gestureConfig: GestureConfig,
onZoomChange: (size: IntSize, centroid: Offset, zoom: ZoomFactor) -> Unit,
onPanChange: (size: IntSize, pan: Offset) -> Boolean,
)
}
Loading

0 comments on commit 5ed060a

Please sign in to comment.