Reputation: 352
The default zoom behaviour as explained in the compose documentation interferes with dragGestures and rotates and scales around the center of the zoomable and not your fingers
Is there a better way to do this?
Upvotes: 8
Views: 6209
Reputation: 6957
In case anyone is struggling to migrate from forEachGesture to awaitEachGesture after upgrading to Compose 1.4.3, here is a working solution:
@Composable
fun ZoomableImage(
modifier: Modifier = Modifier, //for image
animateSnapBack: Boolean = true,
containerModifier: Modifier = Modifier, //for box
bitmap: ImageBitmap? = null,
contentDescription: String? = null,
contentScale: ContentScale = ContentScale.Fit,
magnificationScale: Float = MAGNIFICATION_SCALE_DEFAULT,
onZoomModeChanged: ((Boolean) -> Unit)? = null,
scrollState: ScrollableState? = null,
snapBack: Boolean = false,
supportRotation: Boolean = false,
) {
val coroutineScope = rememberCoroutineScope()
var scale by remember { mutableStateOf(MAGNIFICATION_BASELINE) }
var rotation by remember { mutableStateOf(ROTATION_BASELINE) }
var offsetX by remember { mutableStateOf(OFFSET_X_BASELINE) }
var offsetY by remember { mutableStateOf(OFFSET_Y_BASELINE) }
// for animating scale, rotation and translation back to its original state
var isInGesture by remember { mutableStateOf(false) }
val snapBackScale by animateFloatAsState(
if (isInGesture) scale else MAGNIFICATION_BASELINE,
label = LABEL_ZOOMABLE_IMG_SNAP_BACK_SCALE
)
val snapBackRotation by animateFloatAsState(
if (isInGesture) rotation else ROTATION_BASELINE,
label = LABEL_ZOOMABLE_IMG_SNAP_BACK_ROTATION
)
val snapBackOffsetX by animateFloatAsState(
if (isInGesture) offsetX else OFFSET_X_BASELINE,
label = LABEL_ZOOMABLE_IMG_SNAP_BACK_OFFSET_X
)
val snapBackOffsetY by animateFloatAsState(
if (isInGesture) offsetY else OFFSET_Y_BASELINE,
label = LABEL_ZOOMABLE_IMG_SNAP_BACK_OFFSET_Y
)
Box(
modifier = containerModifier
.combinedClickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = { },
onDoubleClick = {
if (scale >= MAGNIFICATION_THRESHOLD) {
onZoomModeChanged?.invoke(false)
scale = MAGNIFICATION_BASELINE
offsetX = OFFSET_X_BASELINE
offsetY = OFFSET_Y_BASELINE
} else {
scale = magnificationScale
}
},
)
.pointerInput(Unit) {
awaitEachGesture {
awaitFirstDown()
do {
isInGesture = true
val event = awaitPointerEvent()
scale *= event.calculateZoom()
if (scale > MAGNIFICATION_BASELINE) {
scrollState?.run {
coroutineScope.launch {
setScrolling(false)
}
}
onZoomModeChanged?.invoke(true)
val pan = event.calculatePan()
offsetX += pan.x
offsetY += pan.y
rotation += event.calculateRotation()
scrollState?.run {
coroutineScope.launch {
setScrolling(true)
}
}
} else if (!snapBack) {
// for the no snap back use case, the image should shift back into its container when it gets close enough to the original position
onZoomModeChanged?.invoke(false)
scale = MAGNIFICATION_BASELINE
offsetX = OFFSET_X_BASELINE
offsetY = OFFSET_Y_BASELINE
rotation = ROTATION_BASELINE
}
} while (event.changes.any { it.pressed })
// Gesture complete actions
if (snapBack) {
onZoomModeChanged?.invoke(false)
isInGesture = false
scale = MAGNIFICATION_BASELINE
offsetX = OFFSET_X_BASELINE
offsetY = OFFSET_Y_BASELINE
rotation = ROTATION_BASELINE
}
}
}
) {
fun GraphicsLayerScope.manipulateImage() {
if (!isInGesture && animateSnapBack) {
scaleX = snapBackScale
scaleY = snapBackScale
if (supportRotation) {
rotationZ = snapBackRotation
}
translationX = snapBackOffsetX
translationY = snapBackOffsetY
} else {
scaleX = maxOf(MAGNIFICATION_BASELINE, minOf(magnificationScale, scale))
scaleY = maxOf(MAGNIFICATION_BASELINE, minOf(magnificationScale, scale))
if (supportRotation) {
rotationZ = rotation
}
translationX = offsetX
translationY = offsetY
}
}
if (bitmap != null) {
androidx.compose.foundation.Image(
bitmap = bitmap,
contentDescription = null,
contentScale = contentScale,
modifier = modifier.graphicsLayer { manipulateImage() },
)
}
}
}
suspend fun ScrollableState.setScrolling(value: Boolean) {
scroll(scrollPriority = MutatePriority.PreventUserInput) {
when (value) {
true -> Unit
else -> awaitCancellation()
}
}
}
@Immutable
object ZoomableImageDimens {
internal const val OFFSET_X_BASELINE = 1f
internal const val OFFSET_Y_BASELINE = 1f
internal const val MAGNIFICATION_BASELINE = 1f
internal const val MAGNIFICATION_SCALE_DEFAULT = 2f
internal const val ROTATION_BASELINE = 0f
internal const val MAGNIFICATION_THRESHOLD = 2f
}
Upvotes: 1
Reputation: 13
var scale by remember { mutableFloatStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
var rotation by remember { mutableFloatStateOf(0f) }
fun Offset.rotateBy(angle: Double): Offset {
return Offset((x*cos(angle) - y*sin(angle)).toFloat(), (x*sin(angle) + y*cos(angle)).toFloat())
}
val state = rememberTransformableState { zoomChange, offsetChange, rotChange ->
offset = offset.rotateBy(rotChange*PI/180) + offsetChange/scale
scale *= zoomChange; rotation += rotChange
if (scale > 10f) scale = 10f; if (scale < .1f) scale = .1f
}
Box(modifier = Modifier.fillMaxSize().transformable(state)) {
AsyncImage(model = yourModel, contentDescription = null,
contentScale = ContentScale.None,
modifier = Modifier.wrapContentSize(unbounded = true)
.graphicsLayer(scaleX = scale, scaleY = scale, rotationZ = rotation,
translationX = scale*offset.x, translationY = scale*offset.y)
)
}
Upvotes: 1
Reputation: 352
I made the code from this solution into a library: de.mr-pine.utils:zoomables
You have to use pointerInputScope with detectTransformGestures and this function as your onGesture:
fun onTransformGesture(
centroid: Offset,
pan: Offset,
zoom: Float,
transformRotation: Float
) {
offset += pan
scale *= zoom
rotation += transformRotation
val x0 = centroid.x - imageCenter.x
val y0 = centroid.y - imageCenter.y
val hyp0 = sqrt(x0 * x0 + y0 * y0)
val hyp1 = zoom * hyp0 * (if (x0 > 0) {
1f
} else {
-1f
})
val alpha0 = atan(y0 / x0)
val alpha1 = alpha0 + (transformRotation * ((2 * PI) / 360))
val x1 = cos(alpha1) * hyp1
val y1 = sin(alpha1) * hyp1
transformOffset =
centroid - (imageCenter - offset) - Offset(x1.toFloat(), y1.toFloat())
offset = transformOffset
}
Here's an example of how to rotate/scale around the touch inputs which also supports swiping and double-tapping to reset zoom/zoom in:
val scope = rememberCoroutineScope()
var scale by remember { mutableStateOf(1f) }
var rotation by remember { mutableStateOf(0f) }
var offset by remember { mutableStateOf(Offset.Zero) }
val state = rememberTransformableState { zoomChange, offsetChange, rotationChange ->
scale *= zoomChange
rotation += rotationChange
offset += offsetChange
}
var dragOffset by remember { mutableStateOf(Offset.Zero) }
var imageCenter by remember { mutableStateOf(Offset.Zero) }
var transformOffset by remember { mutableStateOf(Offset.Zero) }
Box(
Modifier
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = {
if (scale != 1f) {
scope.launch {
state.animateZoomBy(1 / scale)
}
offset = Offset.Zero
rotation = 0f
} else {
scope.launch {
state.animateZoomBy(2f)
}
}
}
)
}
.pointerInput(Unit) {
val panZoomLock = true
forEachGesture {
awaitPointerEventScope {
var transformRotation = 0f
var zoom = 1f
var pan = Offset.Zero
var pastTouchSlop = false
val touchSlop = viewConfiguration.touchSlop
var lockedToPanZoom = false
var drag: PointerInputChange?
var overSlop = Offset.Zero
val down = awaitFirstDown(requireUnconsumed = false)
var transformEventCounter = 0
do {
val event = awaitPointerEvent()
val canceled = event.changes.fastAny { it.positionChangeConsumed() }
var relevant = true
if (event.changes.size > 1) {
if (!canceled) {
val zoomChange = event.calculateZoom()
val rotationChange = event.calculateRotation()
val panChange = event.calculatePan()
if (!pastTouchSlop) {
zoom *= zoomChange
transformRotation += rotationChange
pan += panChange
val centroidSize =
event.calculateCentroidSize(useCurrent = false)
val zoomMotion = abs(1 - zoom) * centroidSize
val rotationMotion =
abs(transformRotation * PI.toFloat() * centroidSize / 180f)
val panMotion = pan.getDistance()
if (zoomMotion > touchSlop ||
rotationMotion > touchSlop ||
panMotion > touchSlop
) {
pastTouchSlop = true
lockedToPanZoom =
panZoomLock && rotationMotion < touchSlop
}
}
if (pastTouchSlop) {
val eventCentroid = event.calculateCentroid(useCurrent = false)
val effectiveRotation =
if (lockedToPanZoom) 0f else rotationChange
if (effectiveRotation != 0f ||
zoomChange != 1f ||
panChange != Offset.Zero
) {
onTransformGesture(
eventCentroid,
panChange,
zoomChange,
effectiveRotation
)
}
event.changes.fastForEach {
if (it.positionChanged()) {
it.consumeAllChanges()
}
}
}
}
} else if (transformEventCounter > 3) relevant = false
transformEventCounter++
} while (!canceled && event.changes.fastAny { it.pressed } && relevant)
do {
val event = awaitPointerEvent()
drag = awaitTouchSlopOrCancellation(down.id) { change, over ->
change.consumePositionChange()
overSlop = over
}
} while (drag != null && !drag.positionChangeConsumed())
if (drag != null) {
dragOffset = Offset.Zero
if (scale !in 0.92f..1.08f) {
offset += overSlop
} else {
dragOffset += overSlop
}
if (drag(drag.id) {
if (scale !in 0.92f..1.08f) {
offset += it.positionChange()
} else {
dragOffset += it.positionChange()
}
it.consumePositionChange()
}
) {
if (scale in 0.92f..1.08f) {
val offsetX = dragOffset.x
if (offsetX > 300) {
onSwipeRight()
} else if (offsetX < -300) {
onSwipeLeft()
}
}
}
}
}
}
}
) {
ZoomComposable(
modifier = Modifier
.offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) }
.graphicsLayer(
scaleX = scale - 0.02f,
scaleY = scale - 0.02f,
rotationZ = rotation
)
.onGloballyPositioned { coordinates ->
val localOffset =
Offset(
coordinates.size.width.toFloat() / 2,
coordinates.size.height.toFloat() / 2
)
val windowOffset = coordinates.localToWindow(localOffset)
imageCenter = coordinates.parentLayoutCoordinates?.windowToLocal(windowOffset)
?: Offset.Zero
}
)
}
Upvotes: 10
Reputation: 5791
This is a really simple Zoomable Image.
@Composable
fun ZoomableImage() {
var scale by remember { mutableStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
Box(
Modifier
.size(600.dp)
) {
Image(
painter = rememberImagePainter(data = "https://picsum.photos/600/600"),
contentDescription = "A Content description",
modifier = Modifier
.align(Alignment.Center)
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = if (scale > 1f) offset.x else 0f,
translationY = if (scale > 1f) offset.y else 0f
)
.pointerInput(Unit) {
detectTransformGestures(
onGesture = { _, pan: Offset, zoom: Float, _ ->
offset += pan
scale = (scale * zoom).coerceIn(0.5f, 4f)
}
)
}
)
}
}
Only zooming and panning are supported. Rotation and double tap is not.
For a slightly smoother panning, you can apply a small multiplier to pan
, like:
offset += pan * 1.5f
I've also added coerceIn
to avoid zooming in/out until bounds that will look weird. Feel free to remove coerceIn
if you need to. You can also remove the containing Box
and the Alignment
.
translation (panning) will only be applied if we've zoomed previously. That looks more natural IMHO.
Feedback and improvements are welcome
Upvotes: 2