Hi I want to have a zoomable image in a horizontal pager. I already implemented transform gestures and double tap but now, I can't scroll my pager with scrolling over the image. I guess panning and scrolling mixes each other. Can you help me about that ?
This is my Zoomable Image Composable:
@Composable
fun DoubleTapZoom(
) {
var zoomed by remember { mutableStateOf(false) }
var transforming by remember { mutableStateOf(false) }
var offset by remember { mutableStateOf(Offset.Zero) }
var zoomFloat by remember { mutableStateOf(1f) }
var angle by remember { mutableStateOf(0f) }
Image(
painter = painterResource(id = R.drawable.ic_launcher_foreground),
contentDescription = null,
modifier = Modifier
.size(500.dp)
.background(Color.Red)
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = { tapOffset ->
offset = if (zoomed) Offset.Zero else
calculateOffsetFromClick(size, tapOffset)
zoomed = !zoomed
}
)
}
.pointerInput(Unit) {
detectTransformGestures { centroid, pan, gestureZoom, gestureRotate ->
transforming = true
val oldScale = zoomFloat
val newScale = zoomFloat * gestureZoom
offset = (offset + centroid / oldScale).rotateBy(gestureRotate) -
(centroid / newScale + pan / oldScale)
zoomFloat = newScale
angle += gestureRotate
transforming = false
}
}
.graphicsLayer {
scaleX = zoomFloat
scaleY = zoomFloat
translationX = -offset.x * zoomFloat
translationY = -offset.y * zoomFloat
rotationZ = angle
transformOrigin = TransformOrigin(0f, 0f)
}
)
}
And here is the my usage in horizontal pager:
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HorizontalPagerWithZoomableContent() {
val pagerState = rememberPagerState()
HorizontalPager(
state = pagerState,
modifier = Modifier
.border(4.dp, Color.Red, RoundedCornerShape(36.dp))
.clip(RoundedCornerShape(36.dp)),
pageCount = 4,
) { pageIndex ->
Box (modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center){
DoubleTapZoom()
}
}
}
In jetpack Compose events are propagated from descendant to ancestor by default with default pass(PointerEventPass.Main). If a gesture is consumed and parent/child checking if it has been consumed before it never gets that event.
You can refer this answer for more details.
By default detectTransformGestures
consume PointerInputChange
s when there is a move event. To overcome this you can add an optional parameter to consume conditionally such as zoom being bigger than 1 or number of pointers down bigger than 1.
I wrote a detecTransformGesture that checks consume, and returns pointer that are down on screen as
suspend fun PointerInputScope.detectCustomTransformGestures(
panZoomLock: Boolean = false,
consume: Boolean = true,
pass: PointerEventPass = PointerEventPass.Main,
onGestureStart: (PointerInputChange) -> Unit = {},
onGesture: (
centroid: Offset,
pan: Offset,
zoom: Float,
rotation: Float,
mainPointer: PointerInputChange,
changes: List<PointerInputChange>
) -> Unit,
onGestureEnd: (PointerInputChange) -> Unit = {}
) {
awaitEachGesture {
var rotation = 0f
var zoom = 1f
var pan = Offset.Zero
var pastTouchSlop = false
val touchSlop = viewConfiguration.touchSlop
var lockedToPanZoom = false
// Wait for at least one pointer to press down, and set first contact position
val down: PointerInputChange = awaitFirstDown(
requireUnconsumed = false,
pass = pass
)
onGestureStart(down)
var pointer = down
// Main pointer is the one that is down initially
var pointerId = down.id
do {
val event = awaitPointerEvent(pass = pass)
// If any position change is consumed from another PointerInputChange
// or pointer count requirement is not fulfilled
val canceled =
event.changes.any { it.isConsumed }
if (!canceled) {
// Get pointer that is down, if first pointer is up
// get another and use it if other pointers are also down
// event.changes.first() doesn't return same order
val pointerInputChange =
event.changes.firstOrNull { it.id == pointerId }
?: event.changes.first()
// Next time will check same pointer with this id
pointerId = pointerInputChange.id
pointer = pointerInputChange
val zoomChange = event.calculateZoom()
val rotationChange = event.calculateRotation()
val panChange = event.calculatePan()
if (!pastTouchSlop) {
zoom *= zoomChange
rotation += rotationChange
pan += panChange
val centroidSize = event.calculateCentroidSize(useCurrent = false)
val zoomMotion = abs(1 - zoom) * centroidSize
val rotationMotion =
abs(rotation * kotlin.math.PI.toFloat() * centroidSize / 180f)
val panMotion = pan.getDistance()
if (zoomMotion > touchSlop ||
rotationMotion > touchSlop ||
panMotion > touchSlop
) {
pastTouchSlop = true
lockedToPanZoom = panZoomLock && rotationMotion < touchSlop
}
}
if (pastTouchSlop) {
val centroid = event.calculateCentroid(useCurrent = false)
val effectiveRotation = if (lockedToPanZoom) 0f else rotationChange
if (effectiveRotation != 0f ||
zoomChange != 1f ||
panChange != Offset.Zero
) {
onGesture(
centroid,
panChange,
zoomChange,
effectiveRotation,
pointer,
event.changes
)
}
if (consume) {
event.changes.forEach {
if (it.positionChanged()) {
it.consume()
}
}
}
}
}
} while (!canceled && event.changes.any { it.pressed })
onGestureEnd(pointer)
}
}
As can be from snippet above i only consume change only if consume param is set to true
if (consume) {
event.changes.forEach {
if (it.positionChanged()) {
it.consume()
}
}
}
unlike default gesture.
This gesture can be used as
@Composable
fun DoubleTapZoom(
) {
var zoomed by remember { mutableStateOf(false) }
var transforming by remember { mutableStateOf(false) }
var offset by remember { mutableStateOf(Offset.Zero) }
var zoomFloat by remember { mutableStateOf(1f) }
var angle by remember { mutableStateOf(0f) }
val context = LocalContext.current
Image(
painter = painterResource(id = R.drawable.ic_launcher_foreground),
contentDescription = null,
modifier = Modifier
.size(500.dp)
.background(Color.Red)
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = { tapOffset ->
Toast
.makeText(context, "DoubleTap", Toast.LENGTH_SHORT)
.show()
// offset = if (zoomed) Offset.Zero else
// calculateOffsetFromClick(size, tapOffset)
// zoomed = !zoomed
}
)
}
.pointerInput(Unit) {
detectCustomTransformGestures(
consume = false,
onGesture = { centroid, pan, gestureZoom, gestureRotate, _, changes ->
transforming = true
val oldScale = zoomFloat
val newScale = zoomFloat * gestureZoom
offset = (offset + centroid / oldScale).rotateBy(gestureRotate) -
(centroid / newScale + pan / oldScale)
zoomFloat = newScale
angle += gestureRotate
transforming = false
// If more than 1 pointer is down consume event
// to prevent Pager from scrolling
if (changes.size > 1) {
changes.forEach { it.consume() }
}
}
)
}
.graphicsLayer {
scaleX = zoomFloat
scaleY = zoomFloat
translationX = -offset.x * zoomFloat
translationY = -offset.y * zoomFloat
rotationZ = angle
transformOrigin = TransformOrigin(0f, 0f)
}
)
}
Also available as gesture library that extends capabilities of default gestures available here
https://github.com/SmartToolFactory/Compose-Extended-Gestures/tree/master/gesture