diff --git a/zoomable/src/androidTest/java/net/engawapg/lib/zoomable/ZoomableTest.kt b/zoomable/src/androidTest/java/net/engawapg/lib/zoomable/ZoomableTest.kt index ae61faf..8b1baa4 100644 --- a/zoomable/src/androidTest/java/net/engawapg/lib/zoomable/ZoomableTest.kt +++ b/zoomable/src/androidTest/java/net/engawapg/lib/zoomable/ZoomableTest.kt @@ -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 @@ -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) + } + } \ No newline at end of file diff --git a/zoomable/src/main/java/net/engawapg/lib/zoomable/DoubleTapZoomSpec.kt b/zoomable/src/main/java/net/engawapg/lib/zoomable/DoubleTapZoomSpec.kt new file mode 100644 index 0000000..2e0c326 --- /dev/null +++ b/zoomable/src/main/java/net/engawapg/lib/zoomable/DoubleTapZoomSpec.kt @@ -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): DoubleTapZoomSpec { + override fun nextScale(currentScale: Float): Float { + if (scaleList.isEmpty()) { + return currentScale + } + + for (scale in scaleList) { + if (currentScale < scale) { + return scale + } + } + + return scaleList[0] + } +} \ No newline at end of file diff --git a/zoomable/src/main/java/net/engawapg/lib/zoomable/ZoomState.kt b/zoomable/src/main/java/net/engawapg/lib/zoomable/ZoomState.kt index 16f28dc..2b4f509 100644 --- a/zoomable/src/main/java/net/engawapg/lib/zoomable/ZoomState.kt +++ b/zoomable/src/main/java/net/engawapg/lib/zoomable/ZoomState.kt @@ -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 @@ -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 { @@ -215,6 +207,68 @@ class ZoomState( } } + internal suspend fun changeScale( + targetScale: Float, + position: Offset, + animationSpec: AnimationSpec = 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() diff --git a/zoomable/src/main/java/net/engawapg/lib/zoomable/Zoomable.kt b/zoomable/src/main/java/net/engawapg/lib/zoomable/Zoomable.kt index 6677582..4931afc 100644 --- a/zoomable/src/main/java/net/engawapg/lib/zoomable/Zoomable.kt +++ b/zoomable/src/main/java/net/engawapg/lib/zoomable/Zoomable.kt @@ -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) @@ -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)) { @@ -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) } } } @@ -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" @@ -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, ) }