Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added more accurate gestures #1

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions zoomableimage/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,9 @@ dependencies {
implementation 'androidx.appcompat:appcompat:1.3.1'
implementation 'com.google.android.material:material:1.4.0'
implementation "androidx.compose.material:material:1.0.0"
implementation "androidx.compose.ui:ui:1.0.0"
implementation "androidx.compose.ui:ui-tooling-preview:1.0.0"
implementation "androidx.compose.ui:ui:1.0.5"
implementation "androidx.compose.ui:ui-tooling-preview:1.0.5"
implementation "androidx.compose.ui:ui-util:1.0.5"
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
testImplementation 'junit:junit:4.+'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
Expand All @@ -75,4 +76,4 @@ afterEvaluate {
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,82 +1,294 @@
package com.umut.soysal.zoomableimage

import android.annotation.SuppressLint
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.*
import androidx.compose.runtime.*
import androidx.compose.foundation.gestures.animateZoomBy
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.awaitTouchSlopOrCancellation
import androidx.compose.foundation.gestures.calculateCentroid
import androidx.compose.foundation.gestures.calculateCentroidSize
import androidx.compose.foundation.gestures.calculatePan
import androidx.compose.foundation.gestures.calculateRotation
import androidx.compose.foundation.gestures.calculateZoom
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.drag
import androidx.compose.foundation.gestures.forEachGesture
import androidx.compose.foundation.gestures.rememberTransformableState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.offset
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.foundation.layout.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.consumeAllChanges
import androidx.compose.ui.input.pointer.consumePositionChange
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.input.pointer.positionChangeConsumed
import androidx.compose.ui.input.pointer.positionChanged
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastForEach
import kotlinx.coroutines.launch
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.atan
import kotlin.math.cos
import kotlin.math.roundToInt
import kotlin.math.sin
import kotlin.math.sqrt

/**
* Creates an Image composable that can be zoomed in and out.
* @author Arnau Mora, Mr. Pine, umutsoysl
* @since 20220118
* @param painter The data to load into the image.
* @param contentDescription The description of the image for accessibility.
* @param modifier Modifiers to apply to the image component.
* @param minScale The minimum scale that can be applied to the image.
* @param maxScale The maximum scale that can be applied to the image.
* @param isRotation Whether or not the image can be rotated.
* @param isZoomable Whether or not the image can be zoomed.
* @param onSwipeRight Will be called when the user swipes the image to the right.
* @param onSwipeLeft Will be called when the user swipes the image to the left.
* @see <a href="https://stackoverflow.com/a/69782530/5717211">StackOverflow</a>
*/
@Composable
fun ZoomableImage(
painter: Painter,
maxScale: Float = .30f,
minScale: Float = 3f,
contentDescription: String,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Fit,
@SuppressLint("ModifierParameter") modifier: Modifier = Modifier,
minScale: Float = .5f,
maxScale: Float = 3f,
isRotation: Boolean = false,
isZoomable: Boolean = true
isZoomable: Boolean = true,
onSwipeRight: (() -> Unit)? = null,
onSwipeLeft: (() -> Unit)? = null,
) {
val scale = remember { mutableStateOf(1f) }
val rotationState = remember { mutableStateOf(1f) }
val offsetX = remember { mutableStateOf(1f) }
val offsetY = remember { mutableStateOf(1f) }
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) }

fun onTransformGesture(
centroid: Offset,
pan: Offset,
zoom: Float,
transformRotation: Float
) {
offset += pan
scale *= zoom

// Constrain scale
scale = maxOf(minScale, minOf(maxScale, scale))

if (isRotation)
rotation += transformRotation
else
rotation = 0f

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
}

Box(
modifier = Modifier
modifier = modifier
.clip(RectangleShape)
.background(Color.Transparent)
.pointerInput(Unit) {
if (isZoomable) {
forEachGesture {
awaitPointerEventScope {
awaitFirstDown()
do {
val event = awaitPointerEvent()
scale.value *= event.calculateZoom()
if (scale.value > 1) {
val offset = event.calculatePan()
offsetX.value += offset.x
offsetY.value += offset.y
rotationState.value += event.calculateRotation()
} else {
scale.value = 1f
offsetX.value = 1f
offsetY.value = 1f
.background(MaterialTheme.colorScheme.background)
.let { mod ->
return@let if (!isZoomable)
mod
else
mod
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = {
if (scale != 1f) {
scope.launch {
state.animateZoomBy(1 / scale)
}
offset = Offset.Zero
rotation = 0f
} else {
scope.launch {
state.animateZoomBy(2f)
}
}
}
} while (event.changes.any { it.pressed })
)
}
}
}
}
.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 {
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?.invoke()
else if (offsetX < -300)
onSwipeLeft?.invoke()
}
}
}
}
}
}
}
) {
Image(
painter = painter,
contentDescription = null,
contentDescription = contentDescription,
contentScale = contentScale,
modifier = modifier
modifier = Modifier
.align(Alignment.Center)
.graphicsLayer {
if (isZoomable) {
scaleX = maxOf(maxScale, minOf(minScale, scale.value))
scaleY = maxOf(maxScale, minOf(minScale, scale.value))
if (isRotation) {
rotationZ = rotationState.value
}
translationX = offsetX.value
translationY = offsetY.value
}
}
.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
},
)
}
}
}