Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support double tap zoom #60

Merged
merged 9 commits into from
May 28, 2023
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.test.doubleClick
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.performTouchInput
Expand Down Expand Up @@ -77,4 +78,82 @@ class ZoomableTest {
println("bounds=$boundsAfter")
assert(boundsAfter.width > boundsBefore.width && boundsAfter.height > boundsBefore.height)
}

@Test
fun zoomable_doubleTapZoomScale_zoomed() {
composeTestRule.setContent {
val painter = painterResource(id = android.R.drawable.ic_dialog_info)
val zoomState = rememberZoomState(contentSize = painter.intrinsicSize)
Image(
painter = painter,
contentDescription = "image",
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxSize()
.zoomable(
zoomState = zoomState,
doubleTapZoomSpec = DoubleTapZoomScale(2f),
)
)
}

val node = composeTestRule.onNodeWithContentDescription("image")
val bounds0 = node.fetchSemanticsNode().boundsInRoot

node.performTouchInput {
doubleClick(center)
}
val bounds1 = node.fetchSemanticsNode().boundsInRoot
assert((bounds1.width / bounds0.width) == 2f)
assert((bounds1.height / bounds0.height) == 2f)

node.performTouchInput {
doubleClick(center)
}
val bounds2 = node.fetchSemanticsNode().boundsInRoot
assert(bounds2.size == bounds0.size)
}

@Test
fun zoomable_doubleTapZoomScaleList_zoomed() {
composeTestRule.setContent {
val painter = painterResource(id = android.R.drawable.ic_dialog_info)
val zoomState = rememberZoomState(contentSize = painter.intrinsicSize)
Image(
painter = painter,
contentDescription = "image",
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxSize()
.zoomable(
zoomState = zoomState,
doubleTapZoomSpec = DoubleTapZoomScaleList(listOf(1f, 2f, 3f))
)
)
}

val node = composeTestRule.onNodeWithContentDescription("image")
val bounds0 = node.fetchSemanticsNode().boundsInRoot

node.performTouchInput {
doubleClick(center)
}
val bounds1 = node.fetchSemanticsNode().boundsInRoot
assert((bounds1.width / bounds0.width) == 2f)
assert((bounds1.height / bounds0.height) == 2f)

node.performTouchInput {
doubleClick(center)
}
val bounds2 = node.fetchSemanticsNode().boundsInRoot
assert((bounds2.width / bounds0.width) == 3f)
assert((bounds2.height / bounds0.height) == 3f)

node.performTouchInput {
doubleClick(center)
}
val bounds3 = node.fetchSemanticsNode().boundsInRoot
assert(bounds3.size == bounds0.size)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/*
* Copyright 2023 usuiat
*
* 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.
*/

package net.engawapg.lib.zoomable

/**
* [DoubleTapZoomSpec] defines a specification of zooming when double tap is detected.
*/
interface DoubleTapZoomSpec {

/**
* Determines the next scale value from the current scale value.
*
* The [ZoomState] object will call this function and pass the current scale value to
* [currentScale] when a double tap gesture is detected.
*
* @param currentScale The current scale value.
* @return The next scale value. The [ZoomState] object will change the scale to this value.
*/
fun nextScale(currentScale: Float): Float

companion object {
/**
* Disable double tap gesture.
*/
val Disable: DoubleTapZoomSpec = object : DoubleTapZoomSpec {
override fun nextScale(currentScale: Float) = currentScale
}
}
}

/**
* Toggle the scale between 1.0 and [zoomScale] every time when double tap gesture is detected.
*
* When double tap gesture is detected,
* - if the scale is 1.0, the scale will change to the [zoomScale].
* - if the scale is not 1.0, the scale will change to 1.0.
*
* @param zoomScale The zoom scale value after double tap gesture is detected.
*/
class DoubleTapZoomScale(private val zoomScale: Float): DoubleTapZoomSpec {
override fun nextScale(currentScale: Float): Float {
return if (currentScale == 1f) zoomScale else 1f
}
}

/**
* Adopt the scale defined in the [scaleList] every time when double tap gesture is detected.
*
* @param scaleList The zoom scale values. The values must be defined in order of ascending.
*/
class DoubleTapZoomScaleList(private val scaleList: List<Float>): DoubleTapZoomSpec {
override fun nextScale(currentScale: Float): Float {
if (scaleList.isEmpty()) {
return currentScale
}

for (scale in scaleList) {
if (currentScale < scale) {
return scale
}
}

return scaleList[0]
}
}
88 changes: 71 additions & 17 deletions zoomable/src/main/java/net/engawapg/lib/zoomable/ZoomState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,16 @@ package net.engawapg.lib.zoomable

import androidx.annotation.FloatRange
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.DecayAnimationSpec
import androidx.compose.animation.core.exponentialDecay
import androidx.compose.animation.core.spring
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.input.pointer.util.VelocityTracker
import kotlinx.coroutines.coroutineScope
Expand Down Expand Up @@ -179,29 +182,18 @@ class ZoomState(
position: Offset,
timeMillis: Long
) = coroutineScope {
val size = fitContentSize * scale
val newScale = (scale * zoom).coerceIn(0.9f, maxScale)
val newSize = fitContentSize * newScale
val deltaWidth = newSize.width - size.width
val deltaHeight = newSize.height - size.height
val newOffset = calculateNewOffset(newScale, position, pan)
val newBounds = calculateNewBounds(newScale)

// Position with the origin at the left top corner of the content.
val xInContent = position.x - offsetX + (size.width - layoutSize.width) * 0.5f
val yInContent = position.y - offsetY + (size.height - layoutSize.height) * 0.5f
// Offset to zoom the content around the pinch gesture position.
val newOffsetX = (deltaWidth * 0.5f) - (deltaWidth * xInContent / size.width)
val newOffsetY = (deltaHeight * 0.5f) - (deltaHeight * yInContent / size.height)

val boundX = max((newSize.width - layoutSize.width), 0f) * 0.5f
_offsetX.updateBounds(-boundX, boundX)
_offsetX.updateBounds(newBounds.left, newBounds.right)
launch {
_offsetX.snapTo(offsetX + pan.x + newOffsetX)
_offsetX.snapTo(newOffset.x)
}

val boundY = max((newSize.height - layoutSize.height), 0f) * 0.5f
_offsetY.updateBounds(-boundY, boundY)
_offsetY.updateBounds(newBounds.top, newBounds.bottom)
launch {
_offsetY.snapTo(offsetY + pan.y + newOffsetY)
_offsetY.snapTo(newOffset.y)
}

launch {
Expand All @@ -215,6 +207,68 @@ class ZoomState(
}
}

internal suspend fun changeScale(
targetScale: Float,
position: Offset,
animationSpec: AnimationSpec<Float> = spring(),
) = coroutineScope {
val newScale = targetScale.coerceIn(1f, maxScale)
val newOffset = calculateNewOffset(newScale, position, Offset.Zero)
val newBounds = calculateNewBounds(newScale)

val x = newOffset.x.coerceIn(newBounds.left, newBounds.right)
launch {
_offsetX.updateBounds(null, null)
_offsetX.animateTo(x, animationSpec)
_offsetX.updateBounds(newBounds.left, newBounds.right)
}

val y = newOffset.y.coerceIn(newBounds.top, newBounds.bottom)
launch {
_offsetY.updateBounds(null, null)
_offsetY.animateTo(y, animationSpec)
_offsetY.updateBounds(newBounds.top, newBounds.bottom)
}

launch {
_scale.animateTo(newScale, animationSpec)
}

shouldFling = false
}

private fun calculateNewOffset(
newScale: Float,
position: Offset,
pan: Offset,
): Offset {
val size = fitContentSize * scale
val newSize = fitContentSize * newScale
val deltaWidth = newSize.width - size.width
val deltaHeight = newSize.height - size.height

// Position with the origin at the left top corner of the content.
val xInContent = position.x - offsetX + (size.width - layoutSize.width) * 0.5f
val yInContent = position.y - offsetY + (size.height - layoutSize.height) * 0.5f
// Amount of offset change required to zoom around the position.
val deltaX = (deltaWidth * 0.5f) - (deltaWidth * xInContent / size.width)
val deltaY = (deltaHeight * 0.5f) - (deltaHeight * yInContent / size.height)

val x = offsetX + pan.x + deltaX
val y = offsetY + pan.y + deltaY

return Offset(x, y)
}

private fun calculateNewBounds(
newScale: Float,
): Rect {
val newSize = fitContentSize * newScale
val boundX = max((newSize.width - layoutSize.width), 0f) * 0.5f
val boundY = max((newSize.height - layoutSize.height), 0f) * 0.5f
return Rect(-boundX, -boundY, boundX, boundY)
}

internal suspend fun endGesture() = coroutineScope {
if (shouldFling) {
val velocity = velocityTracker.calculateVelocity()
Expand Down
26 changes: 25 additions & 1 deletion zoomable/src/main/java/net/engawapg/lib/zoomable/Zoomable.kt
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ private suspend fun PointerInputScope.detectTransformGestures(
onGesture: (centroid: Offset, pan: Offset, zoom: Float, timeMillis: Long) -> Boolean,
onGestureStart: () -> Unit = {},
onGestureEnd: () -> Unit = {},
onDoubleTap: (position: Offset) -> Unit = {},
enableOneFingerZoom: Boolean = true,
) = awaitEachGesture {
val firstDown = awaitFirstDown(requireUnconsumed = false)
Expand Down Expand Up @@ -82,7 +83,10 @@ private suspend fun PointerInputScope.detectTransformGestures(

// Vertical scrolling following a double tap is treated as a zoom gesture.
if (enableOneFingerZoom && isTap) {
if (awaitSecondDown(firstUp) != null) {
val secondDown = awaitSecondDown(firstUp)
if (secondDown != null) {
var isDoubleTap = true
var secondUp: PointerInputChange = secondDown
val secondTouchSlop = TouchSlop(viewConfiguration.touchSlop)
forEachPointerEventUntilReleased { event ->
if (secondTouchSlop.isPast(event)) {
Expand All @@ -96,7 +100,20 @@ private suspend fun PointerInputScope.detectTransformGestures(
event.consumePositionChanges()
}
}
isDoubleTap = false
}
if (event.changes.size > 1) {
isDoubleTap = false
}
secondUp = event.changes[0]
}

if (secondUp.uptimeMillis - secondDown.uptimeMillis > viewConfiguration.longPressTimeoutMillis) {
isDoubleTap = false
}

if (isDoubleTap) {
onDoubleTap(secondUp.position)
}
}
}
Expand Down Expand Up @@ -193,6 +210,7 @@ private class TouchSlop(private val threshold: Float) {
fun Modifier.zoomable(
zoomState: ZoomState,
enableOneFingerZoom: Boolean = true,
doubleTapZoomSpec: DoubleTapZoomSpec = DoubleTapZoomScale(2.5f)
): Modifier = composed(
inspectorInfo = debugInspectorInfo {
name = "zoomable"
Expand Down Expand Up @@ -226,6 +244,12 @@ fun Modifier.zoomable(
zoomState.endGesture()
}
},
onDoubleTap = { position ->
scope.launch {
val scale = doubleTapZoomSpec.nextScale(zoomState.scale)
zoomState.changeScale(scale, position)
}
},
enableOneFingerZoom = enableOneFingerZoom,
)
}
Expand Down