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
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<Float> {
private fun centerLimits(targetSize: Float, areaSize: Float): ClosedFloatingPointRange<Float> {
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
}
}
}

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 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
)

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) {
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)
)
}

Loading…
Cancel
Save