Browse Source

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
pull/2998/head
Igor Demin 1 year ago committed by GitHub
parent
commit
40ae8ec29d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 94
      examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/ScalableState.kt
  2. 39
      examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/ScalableImage.common.kt
  3. 6
      examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ZoomControllerView.desktop.kt

94
examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/ScalableState.kt

@ -1,32 +1,56 @@
package example.imageviewer.model package example.imageviewer.model
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.isSpecified import androidx.compose.ui.geometry.isSpecified
import kotlin.math.max
/** /**
* Encapsulate all transformations about showing some target (an image, relative to its center) * 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) * scaled and shifted in some area (a window, relative to its center)
*/ */
class ScalableState { 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) var zoom by mutableStateOf(1f)
private set
var scale by mutableStateOf(1f)
private set private set
private var areaSize: Size = Size.Unspecified private var areaSize: Size by mutableStateOf(Size.Unspecified)
private var targetSize: Size = Size.Zero 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 fun zoomToScale(zoom: Float) = zoom * scaleFor100PercentZoom
private var offsetYLimits = Float.NEGATIVE_INFINITY..Float.POSITIVE_INFINITY
/** /**
* Limit the target center position, so: * Limit the target center position, so:
@ -46,22 +70,20 @@ class ScalableState {
private fun applyLimits() { private fun applyLimits() {
if (targetSize.isSpecified && areaSize.isSpecified) { if (targetSize.isSpecified && areaSize.isSpecified) {
offsetXLimits = centerLimits(targetSize.width * scale, areaSize.width) val offsetXLimits = centerLimits(targetSize.width * transformation.scale, areaSize.width)
offsetYLimits = centerLimits(targetSize.height * scale, areaSize.height) val offsetYLimits = centerLimits(targetSize.height * transformation.scale, areaSize.height)
offset = Offset( offset = Offset(
offset.x.coerceIn(offsetXLimits), offset.x.coerceIn(offsetXLimits),
offset.y.coerceIn(offsetYLimits), 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<Float> { private fun centerLimits(targetSize: Float, areaSize: Float): ClosedFloatingPointRange<Float> {
val areaCenter = areaSize / 2 val areaCenter = areaSize / 2
val imageCenter = imageSize / 2 val targetCenter = targetSize / 2
val extra = (imageCenter - areaCenter).coerceAtLeast(0f) val extra = (targetCenter - areaCenter).coerceAtLeast(0f)
return -extra / 2..extra / 2 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 * After we apply the new scale, the camera should be focused on the same point in
* the target coordinate system. * the target coordinate system.
*/ */
fun addScale(scaleMultiplier: Float, focus: Offset = Offset.Zero) { fun addZoom(zoomMultiplier: Float, focus: Offset = Offset.Zero) {
setScale(scale * scaleMultiplier, focus) setZoom(zoom * zoomMultiplier, focus)
} }
fun setScale(scale: Float, focus: Offset = Offset.Zero) { /**
val newScale = scale.coerceIn(scaleLimits) * @param focus on which point the camera is focused in the area coordinate system.
val focusInTargetSystem = (focus - offset) / this.scale * After we apply the new scale, the camera should be focused on the same point in
// calculate newOffset from this equation: * the target coordinate system.
// focusInTargetSystem = (focus - newOffset) / newScale */
offset = focus - focusInTargetSystem * newScale fun setZoom(zoom: Float, focus: Offset = Offset.Zero) {
this.scale = newScale 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() 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
}
}
} }

39
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 androidx.compose.ui.platform.LocalDensity
import example.imageviewer.model.ScalableState import example.imageviewer.model.ScalableState
import example.imageviewer.utils.onPointerEvent import example.imageviewer.utils.onPointerEvent
import kotlin.math.min
import kotlin.math.pow 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 @Composable
internal fun ScalableImage(scalableState: ScalableState, image: ImageBitmap, modifier: Modifier = Modifier) { internal fun ScalableImage(scalableState: ScalableState, image: ImageBitmap, modifier: Modifier = Modifier) {
BoxWithConstraints { BoxWithConstraints {
@ -29,24 +38,14 @@ internal fun ScalableImage(scalableState: ScalableState, image: ImageBitmap, mod
val imageCenter = Offset(image.width / 2f, image.height / 2f) val imageCenter = Offset(image.width / 2f, image.height / 2f)
val areaCenter = Offset(areaSize.width / 2f, areaSize.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( Box(
modifier modifier
.drawWithContent { .drawWithContent {
drawIntoCanvas { drawIntoCanvas {
it.withSave { it.withSave {
it.translate(areaCenter.x, areaCenter.y) it.translate(areaCenter.x, areaCenter.y)
it.translate(scalableState.offset.x, scalableState.offset.y) it.translate(scalableState.transformation.offset.x, scalableState.transformation.offset.y)
it.scale(scalableState.scale, scalableState.scale) it.scale(scalableState.transformation.scale, scalableState.transformation.scale)
it.translate(-imageCenter.x, -imageCenter.y) it.translate(-imageCenter.x, -imageCenter.y)
drawImage(image) drawImage(image)
} }
@ -55,22 +54,24 @@ internal fun ScalableImage(scalableState: ScalableState, image: ImageBitmap, mod
.pointerInput(Unit) { .pointerInput(Unit) {
detectTransformGestures { centroid, pan, zoom, _ -> detectTransformGestures { centroid, pan, zoom, _ ->
scalableState.addPan(pan) scalableState.addPan(pan)
scalableState.addScale(zoom, centroid - areaCenter) scalableState.addZoom(zoom, centroid - areaCenter)
} }
} }
.onPointerEvent(PointerEventType.Scroll) { .onPointerEvent(PointerEventType.Scroll) {
val centroid = it.changes[0].position val centroid = it.changes[0].position
val delta = it.changes[0].scrollDelta val delta = it.changes[0].scrollDelta
val zoom = 1.2f.pow(-delta.y) val zoom = 1.2f.pow(-delta.y)
scalableState.addScale(zoom, centroid - areaCenter) scalableState.addZoom(zoom, centroid - areaCenter)
} }
.pointerInput(Unit) { .pointerInput(Unit) {
detectTapGestures(onDoubleTap = { position -> detectTapGestures(onDoubleTap = { position ->
scalableState.setScale( // If a user zoomed significantly, the zoom should be the restored on double tap,
if (scalableState.scale > 2.0) { // otherwise the zoom should be increased
scalableState.scaleLimits.start scalableState.setZoom(
if (scalableState.zoom > SLIGHTLY_INCREASED_ZOOM) {
INITIAL_ZOOM
} else { } else {
scalableState.scaleLimits.endInclusive scalableState.zoomLimits.endInclusive
}, },
position - areaCenter position - areaCenter
) )

6
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) { internal actual fun ZoomControllerView(modifier: Modifier, scalableState: ScalableState) {
Slider( Slider(
modifier = modifier.fillMaxWidth(0.5f).padding(12.dp), modifier = modifier.fillMaxWidth(0.5f).padding(12.dp),
value = scalableState.scale, value = scalableState.zoom,
valueRange = scalableState.scaleLimits.start..scalableState.scaleLimits.endInclusive, valueRange = scalableState.zoomLimits.start..scalableState.zoomLimits.endInclusive,
onValueChange = { scalableState.setScale(it) }, onValueChange = { scalableState.setZoom(it) },
colors = SliderDefaults.colors(thumbColor = Color.White, activeTrackColor = Color.White) colors = SliderDefaults.colors(thumbColor = Color.White, activeTrackColor = Color.White)
) )
} }

Loading…
Cancel
Save