mirror of
https://github.com/jlengrand/compose-multiplatform.git
synced 2026-03-10 08:11:20 +00:00
Add multitouch to ImageViewer (#2935)
The code of `ScalableImage` is exactly the same as [here](https://github.com/JetBrains/compose-multiplatform-core/pull/459/files#diff-2df227d37a7fcdb885f4fd1a715c0efd94b8e206d446d553d69a456f83e284f6R19): 1. The initial size of an image is chosen by the area size (phone/windows size) 2. We can zoom using pinch-to-zoom (with taking centroid of touches into account) 3. We can zoom using mouse scroll (also, taking the mouse position into account). On touchpad/macOS it also works great. 4. The code of the old `ScalableState` and `ScalableImage` was complete rewritten. 5. The zoom is not limited by phone/window dimensions, we can zoom out
This commit is contained in:
@@ -1,7 +0,0 @@
|
||||
package example.imageviewer.view
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
import example.imageviewer.model.ScalableState
|
||||
|
||||
actual fun Modifier.addUserInput(state: ScalableState) =
|
||||
addTouchUserInput(state)
|
||||
@@ -1,5 +1,3 @@
|
||||
package example.imageviewer.model
|
||||
|
||||
const val MAX_SCALE = 5f
|
||||
const val MIN_SCALE = 1f
|
||||
const val TOAST_DURATION = 3000L
|
||||
|
||||
@@ -4,81 +4,88 @@ 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.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntRect
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.geometry.isSpecified
|
||||
|
||||
class ScalableState() {
|
||||
var imageSize by mutableStateOf(IntSize(0, 0))
|
||||
var boxSize by mutableStateOf(IntSize(1, 1))
|
||||
var offset by mutableStateOf(IntOffset.Zero)
|
||||
/**
|
||||
* 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
|
||||
|
||||
/**
|
||||
* Offset of the camera before scaling (an offset in pixels in the area coordinate system)
|
||||
*/
|
||||
var offset by mutableStateOf(Offset.Zero)
|
||||
private set
|
||||
var scale by mutableStateOf(1f)
|
||||
}
|
||||
private set
|
||||
|
||||
val ScalableState.visiblePart
|
||||
get() : IntRect {
|
||||
val boxRatio = boxSize.width.toFloat() / boxSize.height
|
||||
val imageRatio = imageSize.width.toFloat() / imageSize.height.toFloat()
|
||||
private var areaSize: Size = Size.Unspecified
|
||||
private var targetSize: Size = Size.Zero
|
||||
|
||||
val size: IntSize =
|
||||
if (boxRatio > imageRatio) {
|
||||
val height = imageSize.height / scale
|
||||
val targetWidth = height * boxRatio
|
||||
IntSize(minOf(imageSize.width, targetWidth.toInt()), height.toInt())
|
||||
} else {
|
||||
val width = imageSize.width / scale
|
||||
val targetHeight = width / boxRatio
|
||||
IntSize(width.toInt(), minOf(imageSize.height, targetHeight.toInt()))
|
||||
}
|
||||
private var offsetXLimits = Float.NEGATIVE_INFINITY..Float.POSITIVE_INFINITY
|
||||
private var offsetYLimits = Float.NEGATIVE_INFINITY..Float.POSITIVE_INFINITY
|
||||
|
||||
return IntRect(offset = offset, size = size)
|
||||
/**
|
||||
* Limit the target center position, so:
|
||||
* - if the size of the target is less than area,
|
||||
* the center of the target is bound to the center of the area
|
||||
* - if the size of the target is greater, then limit the center of it,
|
||||
* so the target will be always in the area
|
||||
*/
|
||||
fun limitTargetInsideArea(
|
||||
areaSize: Size,
|
||||
targetSize: Size,
|
||||
) {
|
||||
this.areaSize = areaSize
|
||||
this.targetSize = targetSize
|
||||
applyLimits()
|
||||
}
|
||||
|
||||
fun ScalableState.changeBoxSize(size: IntSize) {
|
||||
boxSize = size
|
||||
updateOffsetLimits()
|
||||
}
|
||||
|
||||
fun ScalableState.setScale(scale: Float) {
|
||||
this.scale = scale
|
||||
}
|
||||
|
||||
fun ScalableState.addScale(diff: Float) {
|
||||
scale = if (scale + diff > MAX_SCALE) {
|
||||
MAX_SCALE
|
||||
} else if (scale + diff < MIN_SCALE) {
|
||||
MIN_SCALE
|
||||
} else {
|
||||
scale + diff
|
||||
private fun applyLimits() {
|
||||
if (targetSize.isSpecified && areaSize.isSpecified) {
|
||||
offsetXLimits = centerLimits(targetSize.width * scale, areaSize.width)
|
||||
offsetYLimits = centerLimits(targetSize.height * 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
|
||||
}
|
||||
}
|
||||
updateOffsetLimits()
|
||||
}
|
||||
|
||||
fun ScalableState.addDragAmount(diff: Offset) {
|
||||
offset -= IntOffset((diff.x + 1).toInt(), (diff.y + 1).toInt())
|
||||
updateOffsetLimits()
|
||||
}
|
||||
private fun centerLimits(imageSize: Float, areaSize: Float): ClosedFloatingPointRange<Float> {
|
||||
val areaCenter = areaSize / 2
|
||||
val imageCenter = imageSize / 2
|
||||
val extra = (imageCenter - areaCenter).coerceAtLeast(0f)
|
||||
return -extra / 2..extra / 2
|
||||
}
|
||||
|
||||
fun ScalableState.updateImageSize(width: Int, height: Int) {
|
||||
imageSize = IntSize(width, height)
|
||||
updateOffsetLimits()
|
||||
}
|
||||
fun addPan(pan: Offset) {
|
||||
offset += pan
|
||||
applyLimits()
|
||||
}
|
||||
|
||||
private fun ScalableState.updateOffsetLimits() {
|
||||
if (offset.x + visiblePart.width > imageSize.width) {
|
||||
changeOffset(x = imageSize.width - visiblePart.width)
|
||||
/**
|
||||
* @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 addScale(scaleMultiplier: Float, focus: Offset = Offset.Zero) {
|
||||
setScale(scale * scaleMultiplier, focus)
|
||||
}
|
||||
if (offset.y + visiblePart.height > imageSize.height) {
|
||||
changeOffset(y = imageSize.height - visiblePart.height)
|
||||
}
|
||||
if (offset.x < 0) {
|
||||
changeOffset(x = 0)
|
||||
}
|
||||
if (offset.y < 0) {
|
||||
changeOffset(y = 0)
|
||||
|
||||
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
|
||||
applyLimits()
|
||||
}
|
||||
}
|
||||
|
||||
private fun ScalableState.changeOffset(x: Int = offset.x, y: Int = offset.y) {
|
||||
offset = IntOffset(x, y)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package example.imageviewer.utils
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.input.pointer.*
|
||||
|
||||
fun Modifier.onPointerEvent(
|
||||
eventType: PointerEventType,
|
||||
pass: PointerEventPass = PointerEventPass.Main,
|
||||
onEvent: AwaitPointerEventScope.(event: PointerEvent) -> Unit
|
||||
): Modifier = composed {
|
||||
val currentEventType by rememberUpdatedState(eventType)
|
||||
val currentOnEvent by rememberUpdatedState(onEvent)
|
||||
pointerInput(pass) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(pass)
|
||||
if (event.type == currentEventType) {
|
||||
currentOnEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
@@ -54,46 +55,34 @@ internal fun FullscreenImageScreen(
|
||||
Box(Modifier.fillMaxSize().background(color = ImageviewerColors.fullScreenImageBackground)) {
|
||||
if (imageWithFilter != null) {
|
||||
val scalableState = remember { ScalableState() }
|
||||
scalableState.updateImageSize(imageWithFilter.width, imageWithFilter.height)
|
||||
val visiblePartOfImage: IntRect = scalableState.visiblePart
|
||||
Box(
|
||||
Modifier.fillMaxSize()
|
||||
.onGloballyPositioned { coordinates ->
|
||||
scalableState.changeBoxSize(coordinates.size)
|
||||
}
|
||||
.addUserInput(scalableState)
|
||||
|
||||
ScalableImage(
|
||||
scalableState,
|
||||
imageWithFilter,
|
||||
modifier = Modifier.fillMaxSize().clipToBounds(),
|
||||
)
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.clip(RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp))
|
||||
.background(ImageviewerColors.filterButtonsBackground)
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Image(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
painter = BitmapPainter(
|
||||
imageWithFilter,
|
||||
srcOffset = visiblePartOfImage.topLeft,
|
||||
srcSize = visiblePartOfImage.size
|
||||
),
|
||||
contentDescription = null,
|
||||
FilterButtons(
|
||||
picture = picture,
|
||||
filters = availableFilters,
|
||||
selectedFilters = selectedFilters,
|
||||
onSelectFilter = {
|
||||
if (it !in selectedFilters) {
|
||||
selectedFilters += it
|
||||
} else {
|
||||
selectedFilters -= it
|
||||
}
|
||||
},
|
||||
)
|
||||
Column(
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.clip(RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp))
|
||||
.background(ImageviewerColors.filterButtonsBackground)
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
FilterButtons(
|
||||
picture = picture,
|
||||
filters = availableFilters,
|
||||
selectedFilters = selectedFilters,
|
||||
onSelectFilter = {
|
||||
if (it !in selectedFilters) {
|
||||
selectedFilters += it
|
||||
} else {
|
||||
selectedFilters -= it
|
||||
}
|
||||
},
|
||||
)
|
||||
ZoomControllerView(Modifier, scalableState)
|
||||
}
|
||||
ZoomControllerView(Modifier, scalableState)
|
||||
}
|
||||
} else {
|
||||
LoadingScreen()
|
||||
|
||||
@@ -2,23 +2,91 @@ package example.imageviewer.view
|
||||
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.BoxWithConstraintsScope
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.graphics.withSave
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import example.imageviewer.model.ScalableState
|
||||
import example.imageviewer.model.addDragAmount
|
||||
import example.imageviewer.model.addScale
|
||||
import example.imageviewer.model.setScale
|
||||
import example.imageviewer.utils.onPointerEvent
|
||||
import kotlin.math.min
|
||||
import kotlin.math.pow
|
||||
|
||||
expect fun Modifier.addUserInput(state: ScalableState): Modifier
|
||||
@Composable
|
||||
internal fun ScalableImage(scalableState: ScalableState, image: ImageBitmap, modifier: Modifier = Modifier) {
|
||||
BoxWithConstraints {
|
||||
val areaSize = areaSize
|
||||
val imageSize = image.size
|
||||
val imageCenter = Offset(image.width / 2f, image.height / 2f)
|
||||
val areaCenter = Offset(areaSize.width / 2f, areaSize.height / 2f)
|
||||
|
||||
fun Modifier.addTouchUserInput(state: ScalableState): Modifier =
|
||||
pointerInput(Unit) {
|
||||
detectTransformGestures { _, pan, zoom, _ ->
|
||||
state.addDragAmount(pan)
|
||||
state.addScale(zoom - 1f)
|
||||
if (areaSize.width > 0 && areaSize.height > 0) {
|
||||
DisposableEffect(Unit) {
|
||||
scalableState.setScale(
|
||||
min(areaSize.width / imageSize.width, areaSize.height / imageSize.height),
|
||||
Offset.Zero,
|
||||
)
|
||||
onDispose { }
|
||||
}
|
||||
}
|
||||
}.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onDoubleTap = { state.setScale(1f) }
|
||||
|
||||
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(-imageCenter.x, -imageCenter.y)
|
||||
drawImage(image)
|
||||
}
|
||||
}
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
detectTransformGestures { centroid, pan, zoom, _ ->
|
||||
scalableState.addPan(pan)
|
||||
scalableState.addScale(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)
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(onDoubleTap = { position ->
|
||||
scalableState.setScale(
|
||||
if (scalableState.scale > 2.0) {
|
||||
scalableState.scaleLimits.start
|
||||
} else {
|
||||
scalableState.scaleLimits.endInclusive
|
||||
},
|
||||
position - areaCenter
|
||||
)
|
||||
}) { }
|
||||
},
|
||||
)
|
||||
|
||||
SideEffect {
|
||||
scalableState.limitTargetInsideArea(areaSize, imageSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val ImageBitmap.size get() = Size(width.toFloat(), height.toFloat())
|
||||
|
||||
private val BoxWithConstraintsScope.areaSize
|
||||
@Composable get() = with(LocalDensity.current) {
|
||||
Size(maxWidth.toPx(), maxHeight.toPx())
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package example.imageviewer.view
|
||||
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import example.imageviewer.model.ScalableState
|
||||
import example.imageviewer.model.addDragAmount
|
||||
import example.imageviewer.model.addScale
|
||||
|
||||
actual fun Modifier.addUserInput(state: ScalableState): Modifier =
|
||||
pointerInput(Unit) {
|
||||
detectDragGestures { change, dragAmount: Offset ->
|
||||
state.addDragAmount(dragAmount)
|
||||
change.consume()
|
||||
}
|
||||
}.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
if (event.type == PointerEventType.Scroll) {
|
||||
val delta = event.changes.getOrNull(0)?.scrollDelta ?: Offset.Zero
|
||||
state.addScale(delta.y / 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,17 +8,14 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import example.imageviewer.model.MAX_SCALE
|
||||
import example.imageviewer.model.MIN_SCALE
|
||||
import example.imageviewer.model.ScalableState
|
||||
import example.imageviewer.model.setScale
|
||||
|
||||
@Composable
|
||||
internal actual fun ZoomControllerView(modifier: Modifier, scalableState: ScalableState) {
|
||||
Slider(
|
||||
modifier = modifier.fillMaxWidth(0.5f).padding(12.dp),
|
||||
value = scalableState.scale,
|
||||
valueRange = MIN_SCALE..MAX_SCALE,
|
||||
valueRange = scalableState.scaleLimits.start..scalableState.scaleLimits.endInclusive,
|
||||
onValueChange = { scalableState.setScale(it) },
|
||||
colors = SliderDefaults.colors(thumbColor = Color.White, activeTrackColor = Color.White)
|
||||
)
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
package example.imageviewer.view
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
import example.imageviewer.model.ScalableState
|
||||
|
||||
actual fun Modifier.addUserInput(state: ScalableState): Modifier =
|
||||
addTouchUserInput(state)
|
||||
Reference in New Issue
Block a user