Browse Source

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
v.mazunin/dev/image-viewer-share
Igor Demin 2 years ago committed by GitHub
parent
commit
fb63908b5c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 7
      experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/ScalableImage.android.kt
  2. 2
      experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/Config.kt
  3. 135
      experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/ScalableState.kt
  4. 26
      experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/utils/EventUtils.kt
  5. 65
      experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/FullscreenImageScreen.kt
  6. 92
      experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/ScalableImage.common.kt
  7. 28
      experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ScalableImage.desktop.kt
  8. 5
      experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ZoomControllerView.desktop.kt
  9. 7
      experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/view/addUserInput.kt

7
experimental/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/ScalableImage.android.kt

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

2
experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/model/Config.kt

@ -1,5 +1,3 @@
package example.imageviewer.model package example.imageviewer.model
const val MAX_SCALE = 5f
const val MIN_SCALE = 1f
const val TOAST_DURATION = 3000L const val TOAST_DURATION = 3000L

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

@ -4,81 +4,88 @@ 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.unit.IntOffset import androidx.compose.ui.geometry.Size
import androidx.compose.ui.unit.IntRect import androidx.compose.ui.geometry.isSpecified
import androidx.compose.ui.unit.IntSize
class ScalableState() { /**
var imageSize by mutableStateOf(IntSize(0, 0)) * Encapsulate all transformations about showing some target (an image, relative to its center)
var boxSize by mutableStateOf(IntSize(1, 1)) * scaled and shifted in some area (a window, relative to its center)
var offset by mutableStateOf(IntOffset.Zero) */
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) var scale by mutableStateOf(1f)
} private set
val ScalableState.visiblePart private var areaSize: Size = Size.Unspecified
get() : IntRect { private var targetSize: Size = Size.Zero
val boxRatio = boxSize.width.toFloat() / boxSize.height
val imageRatio = imageSize.width.toFloat() / imageSize.height.toFloat()
val size: IntSize = private var offsetXLimits = Float.NEGATIVE_INFINITY..Float.POSITIVE_INFINITY
if (boxRatio > imageRatio) { private var offsetYLimits = Float.NEGATIVE_INFINITY..Float.POSITIVE_INFINITY
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()))
}
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) { private fun applyLimits() {
boxSize = size if (targetSize.isSpecified && areaSize.isSpecified) {
updateOffsetLimits() offsetXLimits = centerLimits(targetSize.width * scale, areaSize.width)
} offsetYLimits = centerLimits(targetSize.height * scale, areaSize.height)
offset = Offset(
fun ScalableState.setScale(scale: Float) { offset.x.coerceIn(offsetXLimits),
this.scale = scale offset.y.coerceIn(offsetYLimits),
} )
} else {
fun ScalableState.addScale(diff: Float) { offsetXLimits = Float.NEGATIVE_INFINITY..Float.POSITIVE_INFINITY
scale = if (scale + diff > MAX_SCALE) { offsetYLimits = Float.NEGATIVE_INFINITY..Float.POSITIVE_INFINITY
MAX_SCALE }
} else if (scale + diff < MIN_SCALE) {
MIN_SCALE
} else {
scale + diff
} }
updateOffsetLimits()
}
fun ScalableState.addDragAmount(diff: Offset) {
offset -= IntOffset((diff.x + 1).toInt(), (diff.y + 1).toInt())
updateOffsetLimits()
}
fun ScalableState.updateImageSize(width: Int, height: Int) {
imageSize = IntSize(width, height)
updateOffsetLimits()
}
private fun ScalableState.updateOffsetLimits() { private fun centerLimits(imageSize: Float, areaSize: Float): ClosedFloatingPointRange<Float> {
if (offset.x + visiblePart.width > imageSize.width) { val areaCenter = areaSize / 2
changeOffset(x = imageSize.width - visiblePart.width) val imageCenter = imageSize / 2
} val extra = (imageCenter - areaCenter).coerceAtLeast(0f)
if (offset.y + visiblePart.height > imageSize.height) { return -extra / 2..extra / 2
changeOffset(y = imageSize.height - visiblePart.height)
} }
if (offset.x < 0) {
changeOffset(x = 0) fun addPan(pan: Offset) {
offset += pan
applyLimits()
} }
if (offset.y < 0) {
changeOffset(y = 0) /**
* @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)
} }
}
private fun ScalableState.changeOffset(x: Int = offset.x, y: Int = offset.y) { fun setScale(scale: Float, focus: Offset = Offset.Zero) {
offset = IntOffset(x, y) 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()
}
} }

26
experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/utils/EventUtils.kt

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

65
experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/FullscreenImageScreen.kt

@ -8,6 +8,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.painter.BitmapPainter
@ -54,46 +55,34 @@ internal fun FullscreenImageScreen(
Box(Modifier.fillMaxSize().background(color = ImageviewerColors.fullScreenImageBackground)) { Box(Modifier.fillMaxSize().background(color = ImageviewerColors.fullScreenImageBackground)) {
if (imageWithFilter != null) { if (imageWithFilter != null) {
val scalableState = remember { ScalableState() } val scalableState = remember { ScalableState() }
scalableState.updateImageSize(imageWithFilter.width, imageWithFilter.height)
val visiblePartOfImage: IntRect = scalableState.visiblePart ScalableImage(
Box( scalableState,
Modifier.fillMaxSize() imageWithFilter,
.onGloballyPositioned { coordinates -> modifier = Modifier.fillMaxSize().clipToBounds(),
scalableState.changeBoxSize(coordinates.size) )
}
.addUserInput(scalableState) Column(
Modifier
.align(Alignment.BottomCenter)
.clip(RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp))
.background(ImageviewerColors.filterButtonsBackground)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Image( FilterButtons(
modifier = Modifier.fillMaxSize(), picture = picture,
painter = BitmapPainter( filters = availableFilters,
imageWithFilter, selectedFilters = selectedFilters,
srcOffset = visiblePartOfImage.topLeft, onSelectFilter = {
srcSize = visiblePartOfImage.size if (it !in selectedFilters) {
), selectedFilters += it
contentDescription = null, } else {
selectedFilters -= it
}
},
) )
Column( ZoomControllerView(Modifier, scalableState)
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)
}
} }
} else { } else {
LoadingScreen() LoadingScreen()

92
experimental/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/view/ScalableImage.common.kt

@ -2,23 +2,91 @@ package example.imageviewer.view
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectTransformGestures 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.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.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import example.imageviewer.model.ScalableState import example.imageviewer.model.ScalableState
import example.imageviewer.model.addDragAmount import example.imageviewer.utils.onPointerEvent
import example.imageviewer.model.addScale import kotlin.math.min
import example.imageviewer.model.setScale 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 = if (areaSize.width > 0 && areaSize.height > 0) {
pointerInput(Unit) { DisposableEffect(Unit) {
detectTransformGestures { _, pan, zoom, _ -> scalableState.setScale(
state.addDragAmount(pan) min(areaSize.width / imageSize.width, areaSize.height / imageSize.height),
state.addScale(zoom - 1f) Offset.Zero,
)
onDispose { }
}
} }
}.pointerInput(Unit) {
detectTapGestures( Box(
onDoubleTap = { state.setScale(1f) } 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())
} }

28
experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ScalableImage.desktop.kt

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

5
experimental/examples/imageviewer/shared/src/desktopMain/kotlin/example/imageviewer/view/ZoomControllerView.desktop.kt

@ -8,17 +8,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp 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.ScalableState
import example.imageviewer.model.setScale
@Composable @Composable
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.scale,
valueRange = MIN_SCALE..MAX_SCALE, valueRange = scalableState.scaleLimits.start..scalableState.scaleLimits.endInclusive,
onValueChange = { scalableState.setScale(it) }, onValueChange = { scalableState.setScale(it) },
colors = SliderDefaults.colors(thumbColor = Color.White, activeTrackColor = Color.White) colors = SliderDefaults.colors(thumbColor = Color.White, activeTrackColor = Color.White)
) )

7
experimental/examples/imageviewer/shared/src/iosMain/kotlin/example/imageviewer/view/addUserInput.kt

@ -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)
Loading…
Cancel
Save