From 40ae8ec29d7948afdbdc2ae7e4d063f0a814bc8a Mon Sep 17 00:00:00 2001 From: Igor Demin Date: Thu, 6 Apr 2023 11:56:30 +0200 Subject: [PATCH] ImageViewer - limit zoom by the window/screen size (#2993) - add zoom field, which is the same across different screen/window sizes - scale is not the base state now, it is derived from the current zoom and the current screen/window size. it now represents the end scale of the image - drag amount is still independent of scale/zoom (if we drag by 5 pixels, the image moves by 5 pixels) - offset is still limited by the area and the current scale * ImageViewer - limit zoom by the window/screen size - add zoom field, which is the same across different screen/window sizes - scale is not the base state now, it is derived from the current zoom and the current screen/window size. it now represents the end scale of the image - drag amount is still independent of scale/zoom (if we drag by 5 pixels, the image moves by 5 pixels) - offset is still limited by the area and the current scale --- .../imageviewer/model/ScalableState.kt | 94 ++++++++++++++----- .../imageviewer/view/ScalableImage.common.kt | 39 ++++---- .../view/ZoomControllerView.desktop.kt | 6 +- 3 files changed, 91 insertions(+), 48 deletions(-) diff --git a/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/ScalableState.kt b/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/ScalableState.kt index 725e7714ea..f7d51136b4 100644 --- a/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/ScalableState.kt +++ b/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/ScalableState.kt @@ -1,32 +1,56 @@ package example.imageviewer.model +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.isSpecified +import kotlin.math.max /** * Encapsulate all transformations about showing some target (an image, relative to its center) * scaled and shifted in some area (a window, relative to its center) */ class ScalableState { - val scaleLimits = 0.2f..10f + var zoomLimits = 1.0f..5f + + private var offset by mutableStateOf(Offset.Zero) /** - * Offset of the camera before scaling (an offset in pixels in the area coordinate system) + * Zoom of the target relative to the area size. 1.0 - the target completely fits the area. */ - var offset by mutableStateOf(Offset.Zero) - private set - var scale by mutableStateOf(1f) + var zoom by mutableStateOf(1f) private set - private var areaSize: Size = Size.Unspecified - private var targetSize: Size = Size.Zero + private var areaSize: Size by mutableStateOf(Size.Unspecified) + private var targetSize: Size by mutableStateOf(Size.Zero) + + /** + * A transformation that should be applied to render the target in the area. + * offset - in pixels in the area coordinate system, should be applied before scaling + * scale - scale of the target in the area + */ + val transformation: Transformation by derivedStateOf { + Transformation( + offset = offset, + scale = zoomToScale(zoom) + ) + } + + /** + * The calculated base scale for 100% zoom. Calculated so that the target fits the area. + */ + private val scaleFor100PercentZoom by derivedStateOf { + if (targetSize.isSpecified && areaSize.isSpecified) { + max(areaSize.width / targetSize.width, areaSize.height / targetSize.height) + } else { + 1.0f + } + } - private var offsetXLimits = Float.NEGATIVE_INFINITY..Float.POSITIVE_INFINITY - private var offsetYLimits = Float.NEGATIVE_INFINITY..Float.POSITIVE_INFINITY + private fun zoomToScale(zoom: Float) = zoom * scaleFor100PercentZoom /** * Limit the target center position, so: @@ -46,22 +70,20 @@ class ScalableState { private fun applyLimits() { if (targetSize.isSpecified && areaSize.isSpecified) { - offsetXLimits = centerLimits(targetSize.width * scale, areaSize.width) - offsetYLimits = centerLimits(targetSize.height * scale, areaSize.height) + val offsetXLimits = centerLimits(targetSize.width * transformation.scale, areaSize.width) + val offsetYLimits = centerLimits(targetSize.height * transformation.scale, areaSize.height) + offset = Offset( offset.x.coerceIn(offsetXLimits), offset.y.coerceIn(offsetYLimits), ) - } else { - offsetXLimits = Float.NEGATIVE_INFINITY..Float.POSITIVE_INFINITY - offsetYLimits = Float.NEGATIVE_INFINITY..Float.POSITIVE_INFINITY } } - private fun centerLimits(imageSize: Float, areaSize: Float): ClosedFloatingPointRange { + private fun centerLimits(targetSize: Float, areaSize: Float): ClosedFloatingPointRange { val areaCenter = areaSize / 2 - val imageCenter = imageSize / 2 - val extra = (imageCenter - areaCenter).coerceAtLeast(0f) + val targetCenter = targetSize / 2 + val extra = (targetCenter - areaCenter).coerceAtLeast(0f) return -extra / 2..extra / 2 } @@ -75,17 +97,37 @@ class ScalableState { * After we apply the new scale, the camera should be focused on the same point in * the target coordinate system. */ - fun addScale(scaleMultiplier: Float, focus: Offset = Offset.Zero) { - setScale(scale * scaleMultiplier, focus) + fun addZoom(zoomMultiplier: Float, focus: Offset = Offset.Zero) { + setZoom(zoom * zoomMultiplier, focus) } - fun setScale(scale: Float, focus: Offset = Offset.Zero) { - val newScale = scale.coerceIn(scaleLimits) - val focusInTargetSystem = (focus - offset) / this.scale - // calculate newOffset from this equation: - // focusInTargetSystem = (focus - newOffset) / newScale - offset = focus - focusInTargetSystem * newScale - this.scale = newScale + /** + * @param focus on which point the camera is focused in the area coordinate system. + * After we apply the new scale, the camera should be focused on the same point in + * the target coordinate system. + */ + fun setZoom(zoom: Float, focus: Offset = Offset.Zero) { + val newZoom = zoom.coerceIn(zoomLimits) + val newOffset = Transformation.offsetOf( + point = transformation.pointOf(focus), + transformedPoint = focus, + scale = zoomToScale(newZoom) + ) + this.offset = newOffset + this.zoom = newZoom applyLimits() } + + data class Transformation( + val offset: Offset, + val scale: Float, + ) { + fun pointOf(transformedPoint: Offset) = (transformedPoint - offset) / scale + + companion object { + // is derived from the equation `point = (transformedPoint - offset) / scale` + fun offsetOf(point: Offset, transformedPoint: Offset, scale: Float) = + transformedPoint - point * scale + } + } } diff --git a/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/ScalableImage.common.kt b/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/ScalableImage.common.kt index a988b54866..37c95701c8 100644 --- a/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/ScalableImage.common.kt +++ b/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/ScalableImage.common.kt @@ -18,9 +18,18 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import example.imageviewer.model.ScalableState import example.imageviewer.utils.onPointerEvent -import kotlin.math.min import kotlin.math.pow +/** + * Initial zoom of the image. 1.0f means the image fully fits the window. + */ +private const val INITIAL_ZOOM = 1.0f + +/** + * This zoom means that the image isn't significantly zoomed for the user yet. + */ +private const val SLIGHTLY_INCREASED_ZOOM = 1.5f + @Composable internal fun ScalableImage(scalableState: ScalableState, image: ImageBitmap, modifier: Modifier = Modifier) { BoxWithConstraints { @@ -29,24 +38,14 @@ internal fun ScalableImage(scalableState: ScalableState, image: ImageBitmap, mod val imageCenter = Offset(image.width / 2f, image.height / 2f) val areaCenter = Offset(areaSize.width / 2f, areaSize.height / 2f) - if (areaSize.width > 0 && areaSize.height > 0) { - DisposableEffect(Unit) { - scalableState.setScale( - min(areaSize.width / imageSize.width, areaSize.height / imageSize.height), - Offset.Zero, - ) - onDispose { } - } - } - Box( modifier .drawWithContent { drawIntoCanvas { it.withSave { it.translate(areaCenter.x, areaCenter.y) - it.translate(scalableState.offset.x, scalableState.offset.y) - it.scale(scalableState.scale, scalableState.scale) + it.translate(scalableState.transformation.offset.x, scalableState.transformation.offset.y) + it.scale(scalableState.transformation.scale, scalableState.transformation.scale) it.translate(-imageCenter.x, -imageCenter.y) drawImage(image) } @@ -55,22 +54,24 @@ internal fun ScalableImage(scalableState: ScalableState, image: ImageBitmap, mod .pointerInput(Unit) { detectTransformGestures { centroid, pan, zoom, _ -> scalableState.addPan(pan) - scalableState.addScale(zoom, centroid - areaCenter) + scalableState.addZoom(zoom, centroid - areaCenter) } } .onPointerEvent(PointerEventType.Scroll) { val centroid = it.changes[0].position val delta = it.changes[0].scrollDelta val zoom = 1.2f.pow(-delta.y) - scalableState.addScale(zoom, centroid - areaCenter) + scalableState.addZoom(zoom, centroid - areaCenter) } .pointerInput(Unit) { detectTapGestures(onDoubleTap = { position -> - scalableState.setScale( - if (scalableState.scale > 2.0) { - scalableState.scaleLimits.start + // If a user zoomed significantly, the zoom should be the restored on double tap, + // otherwise the zoom should be increased + scalableState.setZoom( + if (scalableState.zoom > SLIGHTLY_INCREASED_ZOOM) { + INITIAL_ZOOM } else { - scalableState.scaleLimits.endInclusive + scalableState.zoomLimits.endInclusive }, position - areaCenter ) diff --git a/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ZoomControllerView.desktop.kt b/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ZoomControllerView.desktop.kt index f43a465b0d..f0e0e5d714 100644 --- a/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ZoomControllerView.desktop.kt +++ b/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ZoomControllerView.desktop.kt @@ -14,9 +14,9 @@ import example.imageviewer.model.ScalableState internal actual fun ZoomControllerView(modifier: Modifier, scalableState: ScalableState) { Slider( modifier = modifier.fillMaxWidth(0.5f).padding(12.dp), - value = scalableState.scale, - valueRange = scalableState.scaleLimits.start..scalableState.scaleLimits.endInclusive, - onValueChange = { scalableState.setScale(it) }, + value = scalableState.zoom, + valueRange = scalableState.zoomLimits.start..scalableState.zoomLimits.endInclusive, + onValueChange = { scalableState.setZoom(it) }, colors = SliderDefaults.colors(thumbColor = Color.White, activeTrackColor = Color.White) ) }