Skip to content

Commit

Permalink
Merge pull request #60 from usuiat/topic_double_tap_zoom
Browse files Browse the repository at this point in the history
Support double tap zoom
  • Loading branch information
usuiat committed May 28, 2023
2 parents b156836 + a1dbd68 commit a9c7de9
Show file tree
Hide file tree
Showing 4 changed files with 254 additions and 18 deletions.
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

0 comments on commit a9c7de9

Please sign in to comment.