Skip to content

Commit

Permalink
Version 1.3.0 (#16)
Browse files Browse the repository at this point in the history
* Fix inverted zoomed in ZoomableState

* Add features As suggested in #15 
- onTap Callback
- replace onDoubleTap with doubleTapBehaviour and add DoubleTapBehaviour SAM and DefaultDoubleTapBehaviour

* Bump version
  • Loading branch information
Mr-Pine authored Mar 31, 2023
1 parent 1882e97 commit b09cc45
Show file tree
Hide file tree
Showing 5 changed files with 91 additions and 43 deletions.
2 changes: 1 addition & 1 deletion buildSrc/src/main/kotlin/artifact.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
val artifact = Artifact(
group = "de.mr-pine.utils",
id = "zoomables",
version = "1.2.2"
version = "1.3.0"
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package de.mr_pine.zoomables

import androidx.compose.ui.geometry.Offset

public fun interface DoubleTapBehaviour {
public fun onDoubleTap(offset: Offset)
}
24 changes: 15 additions & 9 deletions zoomables/src/main/kotlin/de/mr_pine/zoomables/ZoomableImage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import kotlinx.coroutines.CoroutineScope
* @param onSwipeLeft Optional function to run when user swipes from right to left - does nothing by default
* @param onSwipeRight Optional function to run when user swipes from left to right - does nothing by default
* @param dragGestureMode A function with a [ZoomableState] scope that returns a [DragGestureMode] value that signals which drag gesture should currently be active. By default panning is enabled when zoomed, else swipe gestures are enabled.
* @param onDoubleTap Optional function to run when user double taps. Zooms in by 2x when scale is currently 1 and zooms out to scale = 1 when zoomed in when null (default)
* @param doubleTapBehaviour A [DoubleTapBehaviour] providing a [DoubleTapBehaviour.onDoubleTap]. As [DoubleTapBehaviour] is a [functional Interface](https://kotlinlang.org/docs/fun-interfaces.html) you can just provide a lambda as a `onDoubleTap`
*/
@Composable
public fun ZoomableImage(
Expand All @@ -36,15 +36,17 @@ public fun ZoomableImage(
onSwipeLeft: () -> Unit = {},
onSwipeRight: () -> Unit = {},
dragGestureMode: ZoomableState.() -> DragGestureMode = { if (zoomed) DragGestureMode.SWIPE_GESTURES else DragGestureMode.PAN },
onDoubleTap: ((Offset) -> Unit)? = null
onTap: ((Offset) -> Unit)? = null,
doubleTapBehaviour: DoubleTapBehaviour? = zoomableState.DefaultDoubleTapBehaviour(coroutineScope = coroutineScope)
) {
Zoomable(
coroutineScope = coroutineScope,
zoomableState = zoomableState,
onSwipeLeft = onSwipeLeft,
onSwipeRight = onSwipeRight,
dragGestureMode = dragGestureMode,
onDoubleTap = onDoubleTap
onTap = onTap,
doubleTapBehaviour = doubleTapBehaviour
) {
Image(bitmap = bitmap, contentDescription = contentDescription, modifier = modifier)
}
Expand All @@ -61,7 +63,7 @@ public fun ZoomableImage(
* @param onSwipeLeft Optional function to run when user swipes from right to left - does nothing by default
* @param onSwipeRight Optional function to run when user swipes from left to right - does nothing by default
* @param dragGestureMode A function with a [ZoomableState] scope that returns a [DragGestureMode] value that signals which drag gesture should currently be active. By default panning is enabled when zoomed, else swipe gestures are enabled.
* @param onDoubleTap Optional function to run when user double taps. Zooms in by 2x when scale is currently 1 and zooms out to scale = 1 when zoomed in when null (default)
* @param doubleTapBehaviour A [DoubleTapBehaviour] providing a [DoubleTapBehaviour.onDoubleTap]. As [DoubleTapBehaviour] is a [functional Interface](https://kotlinlang.org/docs/fun-interfaces.html) you can just provide a lambda as a `onDoubleTap`
*/
@Composable
public fun ZoomableImage(
Expand All @@ -73,15 +75,17 @@ public fun ZoomableImage(
onSwipeLeft: () -> Unit = {},
onSwipeRight: () -> Unit = {},
dragGestureMode: ZoomableState.() -> DragGestureMode = { if (zoomed) DragGestureMode.SWIPE_GESTURES else DragGestureMode.PAN },
onDoubleTap: ((Offset) -> Unit)? = null
onTap: ((Offset) -> Unit)? = null,
doubleTapBehaviour: DoubleTapBehaviour? = zoomableState.DefaultDoubleTapBehaviour(coroutineScope = coroutineScope)
) {
Zoomable(
coroutineScope = coroutineScope,
zoomableState = zoomableState,
onSwipeLeft = onSwipeLeft,
onSwipeRight = onSwipeRight,
dragGestureMode = dragGestureMode,
onDoubleTap = onDoubleTap
onTap = onTap,
doubleTapBehaviour = doubleTapBehaviour,
) {
Image(
imageVector = imageVector,
Expand All @@ -102,7 +106,7 @@ public fun ZoomableImage(
* @param onSwipeLeft Optional function to run when user swipes from right to left - does nothing by default
* @param onSwipeRight Optional function to run when user swipes from left to right - does nothing by default
* @param dragGestureMode A function with a [ZoomableState] scope that returns a [DragGestureMode] value that signals which drag gesture should currently be active. By default panning is enabled when zoomed, else swipe gestures are enabled.
* @param onDoubleTap Optional function to run when user double taps. Zooms in by 2x when scale is currently 1 and zooms out to scale = 1 when zoomed in when null (default)
* @param doubleTapBehaviour A [DoubleTapBehaviour] providing a [DoubleTapBehaviour.onDoubleTap]. As [DoubleTapBehaviour] is a [functional Interface](https://kotlinlang.org/docs/fun-interfaces.html) you can just provide a lambda as a `onDoubleTap`
*/
@Composable
public fun ZoomableImage(
Expand All @@ -114,15 +118,17 @@ public fun ZoomableImage(
onSwipeLeft: () -> Unit = {},
onSwipeRight: () -> Unit = {},
dragGestureMode: ZoomableState.() -> DragGestureMode = { if (zoomed) DragGestureMode.SWIPE_GESTURES else DragGestureMode.PAN },
onDoubleTap: ((Offset) -> Unit)? = null
onTap: ((Offset) -> Unit)? = null,
doubleTapBehaviour: DoubleTapBehaviour? = zoomableState.DefaultDoubleTapBehaviour(coroutineScope = coroutineScope)
) {
Zoomable(
coroutineScope = coroutineScope,
zoomableState = zoomableState,
onSwipeLeft = onSwipeLeft,
onSwipeRight = onSwipeRight,
dragGestureMode = dragGestureMode,
onDoubleTap = onDoubleTap
onTap = onTap,
doubleTapBehaviour = doubleTapBehaviour
) {
Image(painter = painter, contentDescription = contentDescription, modifier = modifier)
}
Expand Down
59 changes: 53 additions & 6 deletions zoomables/src/main/kotlin/de/mr_pine/zoomables/ZoomableState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import androidx.compose.foundation.gestures.rememberTransformableState
import androidx.compose.runtime.*
import androidx.compose.ui.geometry.Offset
import de.mr_pine.zoomables.ZoomableState.Rotation.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlin.math.atan
import kotlin.math.cos
import kotlin.math.sin
Expand All @@ -38,17 +40,18 @@ private const val zoomedThreshold = 1.0E-3f
* @property rotation The current rotation in degrees as [MutableState]<[Float]>
* @property transformed `false` if [scale] is `1`, [offset] is [Offset.Zero] and [rotation] is `0`
* @property zoomed Whether the content is zoomed (in or out)
* @property composableCenter The position of the content's center relative to the [Zoomable] Container when [offset] is [Offset.Zero]. Might break when this State is used for multiple [Zoomable]s
*/
public class ZoomableState(
public var scale: MutableState<Float>,
public var offset: MutableState<Offset>,
public var rotation: MutableState<Float>,
public val scale: MutableState<Float>,
public val offset: MutableState<Offset>,
public val rotation: MutableState<Float>,
public val rotationBehavior: Rotation,
onTransformation: ZoomableState.(zoomChange: Float, panChange: Offset, rotationChange: Float) -> Unit
) : TransformableState {

public val zoomed: Boolean
get() = scale.value in (1 - zoomedThreshold)..(1 + zoomedThreshold)
get() = scale.value !in (1 - zoomedThreshold)..(1 + zoomedThreshold)

public val transformed: Boolean
get() = zoomed || offset.value.getDistanceSquared() !in -1.0E-6f..1.0E-6f || rotation.value !in -1.0E-3f..1.0E-3f
Expand All @@ -62,6 +65,8 @@ public class ZoomableState(

private val isTransformingState = mutableStateOf(false)

public var composableCenter: Offset by mutableStateOf(Offset.Zero)


override suspend fun transform(
transformPriority: MutatePriority,
Expand Down Expand Up @@ -99,10 +104,18 @@ public class ZoomableState(
}
}


/**
* Animates a zoom towards the specified position
* @param zoomChange The zoom factor
* @param position The position to zoom towards
* @param currentComposableCenter The current center of the composable relative to the [Zoomable] container. Default might break if this state is used by multiple [Zoomable]s
*/
public suspend fun animateZoomToPosition(
zoomChange: Float,
position: Offset,
currentComposableCenter: Offset = Offset.Zero
currentComposableCenter: Offset = composableCenter + offset.value,
animationSpec: AnimationSpec<Float> = SpringSpec(stiffness = Spring.StiffnessLow)
) {
val offsetBuffer = offset.value

Expand All @@ -124,7 +137,12 @@ public class ZoomableState(
val transformOffset =
position - (currentComposableCenter - offsetBuffer) - Offset(x1, y1)

animateBy(zoomChange = zoomChange, panChange = transformOffset, rotationChange = 0f)
animateBy(
zoomChange = zoomChange,
panChange = transformOffset,
rotationChange = 0f,
animationSpec
)
}

/**
Expand All @@ -148,6 +166,35 @@ public class ZoomableState(
*/
DISABLED
}


public inner class DefaultDoubleTapBehaviour(
private val zoomScale: Float = 2f,
private val animationSpec: AnimationSpec<Float> = SpringSpec(stiffness = Spring.StiffnessLow),
private val coroutineScope: CoroutineScope
) : DoubleTapBehaviour {
override fun onDoubleTap(offset: Offset) {
if (scale.value != 1f) {
coroutineScope.launch {
animateBy(
zoomChange = 1 / scale.value,
panChange = -this@ZoomableState.offset.value,
rotationChange = -rotation.value,
animationSpec = animationSpec
)
}
} else {
coroutineScope.launch {
animateZoomToPosition(
zoomScale,
position = offset,
composableCenter,
animationSpec = animationSpec
)
}
}
}
}
}

/**
Expand Down
42 changes: 15 additions & 27 deletions zoomables/src/main/kotlin/de/mr_pine/zoomables/Zoomables.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastForEach
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.lang.Math.pow
import kotlin.math.*

/**
Expand All @@ -38,7 +39,8 @@ import kotlin.math.*
* @param onSwipeLeft Optional function to run when user swipes from right to left - does nothing by default
* @param onSwipeRight Optional function to run when user swipes from left to right - does nothing by default
* @param minimumSwipeDistance Minimum distance the user has to travel on the screen for it to count as swiping
* @param onDoubleTap Optional function to run when user double taps. Zooms in by 2x to the touch point when scale is currently 1 and zooms out to scale = 1 when zoomed in when `null` (default)
* @param onTap Optional function to run when the user taps. `null` by default
* @param doubleTapBehaviour A [DoubleTapBehaviour] providing a [DoubleTapBehaviour.onDoubleTap]. As [DoubleTapBehaviour] is a [functional Interface](https://kotlinlang.org/docs/fun-interfaces.html) you can just provide a lambda as a `onDoubleTap`
*/
@Composable
public fun Zoomable(
Expand All @@ -48,32 +50,14 @@ public fun Zoomable(
onSwipeLeft: () -> Unit = {},
onSwipeRight: () -> Unit = {},
minimumSwipeDistance: Int = 0,
onDoubleTap: ((Offset) -> Unit)? = null,
onTap: ((Offset) -> Unit)? = null,
doubleTapBehaviour: DoubleTapBehaviour? = zoomableState.DefaultDoubleTapBehaviour(coroutineScope = coroutineScope),
Content: @Composable (BoxScope.() -> Unit),
) {

var dragOffset by remember { mutableStateOf(Offset.Zero) }
var composableCenter by remember { mutableStateOf(Offset.Zero) }
var transformOffset by remember { mutableStateOf(Offset.Zero) }

val doubleTapFunction = onDoubleTap ?: {
if (zoomableState.scale.value != 1f) {
coroutineScope.launch {
zoomableState.animateBy(
zoomChange = 1 / zoomableState.scale.value,
panChange = -zoomableState.offset.value,
rotationChange = -zoomableState.rotation.value
)
}
Unit
} else {
coroutineScope.launch {
zoomableState.animateZoomToPosition(2f, position = it, composableCenter)
}
Unit
}
}

fun onTransformGesture(
centroid: Offset, pan: Offset, zoom: Float, transformRotation: Float
) {
Expand All @@ -82,8 +66,8 @@ public fun Zoomable(

val tempOffset = zoomableState.offset.value + pan

val x0 = centroid.x - composableCenter.x
val y0 = centroid.y - composableCenter.y
val x0 = centroid.x - zoomableState.composableCenter.x
val y0 = centroid.y - zoomableState.composableCenter.y

val hyp0 = sqrt(x0 * x0 + y0 * y0)
val hyp1 = zoom * hyp0 * (if (x0 > 0) {
Expand All @@ -100,7 +84,10 @@ public fun Zoomable(
val y1 = sin(alpha1) * hyp1

transformOffset =
centroid - (composableCenter - tempOffset) - Offset(x1.toFloat(), y1.toFloat())
centroid - (zoomableState.composableCenter - tempOffset) - Offset(
x1.toFloat(),
y1.toFloat()
)

coroutineScope.launch {
zoomableState.transform {
Expand All @@ -118,7 +105,8 @@ public fun Zoomable(
Modifier
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = doubleTapFunction
onTap = onTap,
onDoubleTap = doubleTapBehaviour?.let { it::onDoubleTap }
)
}
.pointerInput(Unit) {
Expand Down Expand Up @@ -244,7 +232,7 @@ public fun Zoomable(
}
}
}
} while (currentEvent.changes.any { !it.isConsumed && !it.changedToUp()})
} while (currentEvent.changes.any { !it.isConsumed && !it.changedToUp() })
}
}) {
Box(
Expand All @@ -266,7 +254,7 @@ public fun Zoomable(
coordinates.size.width.toFloat() / 2, coordinates.size.height.toFloat() / 2
)
val windowOffset = coordinates.localToWindow(localOffset)
composableCenter =
zoomableState.composableCenter =
coordinates.parentLayoutCoordinates?.windowToLocal(windowOffset)
?: Offset.Zero
},
Expand Down

0 comments on commit b09cc45

Please sign in to comment.