mirror of
https://github.com/jlengrand/compose-multiplatform.git
synced 2026-03-10 08:11:20 +00:00
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
This commit is contained in:
@@ -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)
|
||||
|
||||
private var offsetXLimits = Float.NEGATIVE_INFINITY..Float.POSITIVE_INFINITY
|
||||
private var offsetYLimits = Float.NEGATIVE_INFINITY..Float.POSITIVE_INFINITY
|
||||
/**
|
||||
* 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 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user