I am going to build a zoomable image view inside a box, like the first screenshot. But when I zoom in on the image, it will go out of the box. Is there a way to zoom the image, but keep the size? Without view or fragment, only box seems not enough. I am expecting the image to get bigger, but still stay inside the red box, but I got the second screenshot after zooming in.
Thanks to nglauber and Amirhosein, I got the final solution with having the zooming and dragging features at the same time inside a "box"(fixed area) with the following code as the new screenshot as shown below.
val imageBitmap = imageResource(id = R.drawable.android)
Image(
modifier = Modifier
.preferredSize(400.dp, 300.dp)
.clip(RectangleShape)
.zoomable(onZoomDelta = { scale.value *= it })
.rawDragGestureFilter(
object : DragObserver {
override fun onDrag(dragDistance: Offset): Offset {
translate.value = translate.value.plus(dragDistance)
return super.onDrag(dragDistance)
}
})
.graphicsLayer(
scaleX = scale.value,
scaleY = scale.value,
translationX = translate.value.x,
translationY = translate.value.y
),
contentDescription = null,
bitmap = imageBitmap
)
Here is my solution... Might be helpful for someone...
#Composable
fun ZoomableImage() {
val scale = remember { mutableStateOf(1f) }
val rotationState = remember { mutableStateOf(1f) }
Box(
modifier = Modifier
.clip(RectangleShape) // Clip the box content
.fillMaxSize() // Give the size you want...
.background(Color.Gray)
.pointerInput(Unit) {
detectTransformGestures { centroid, pan, zoom, rotation ->
scale.value *= zoom
rotationState.value += rotation
}
}
) {
Image(
modifier = Modifier
.align(Alignment.Center) // keep the image centralized into the Box
.graphicsLayer(
// adding some zoom limits (min 50%, max 200%)
scaleX = maxOf(.5f, minOf(3f, scale.value)),
scaleY = maxOf(.5f, minOf(3f, scale.value)),
rotationZ = rotationState.value
),
contentDescription = null,
painter = painterResource(R.drawable.dog)
)
}
}
I have a bit more general solution that supports panning. This is based on the answers provided by nglauber and arun-padiyan.
#Composable
fun ZoomableBox(
modifier: Modifier = Modifier,
minScale: Float = 0.1f,
maxScale: Float = 5f,
content: #Composable ZoomableBoxScope.() -> Unit
) {
var scale by remember { mutableStateOf(1f) }
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }
var size by remember { mutableStateOf(IntSize.Zero) }
Box(
modifier = modifier
.clip(RectangleShape)
.onSizeChanged { size = it }
.pointerInput(Unit) {
detectTransformGestures { _, pan, zoom, _ ->
scale = maxOf(minScale, minOf(scale * zoom, maxScale))
val maxX = (size.width * (scale - 1)) / 2
val minX = -maxX
offsetX = maxOf(minX, minOf(maxX, offsetX + pan.x))
val maxY = (size.height * (scale - 1)) / 2
val minY = -maxY
offsetY = maxOf(minY, minOf(maxY, offsetY + pan.y))
}
}
) {
val scope = ZoomableBoxScopeImpl(scale, offsetX, offsetY)
scope.content()
}
}
interface ZoomableBoxScope {
val scale: Float
val offsetX: Float
val offsetY: Float
}
private data class ZoomableBoxScopeImpl(
override val scale: Float,
override val offsetX: Float,
override val offsetY: Float
) : ZoomableBoxScope
The usage is then something like:
ZoomableBox {
Image(
modifier = Modifier
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = offsetX,
translationY = offsetY
),
bitmap = bitmap,
contentDescription = null
)
}
zoomable has deprecated can use PointerInputScope.detectTransformGestures
#Composable
fun ImagePreview(link: String) {
Box(modifier = Modifier.fillMaxSize()) {
var angle by remember { mutableStateOf(0f) }
var zoom by remember { mutableStateOf(1f) }
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }
CoilImage(
data = link,
contentDescription = "image",
contentScale = ContentScale.Fit,
modifier = Modifier
.offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) }
.graphicsLayer(
scaleX = zoom,
scaleY = zoom,
rotationZ = angle
)
.pointerInput(Unit) {
detectTransformGestures(
onGesture = { _, pan, gestureZoom, gestureRotate ->
angle += gestureRotate
zoom *= gestureZoom
val x = pan.x * zoom
val y = pan.y * zoom
val angleRad = angle * PI / 180.0
offsetX += (x * cos(angleRad) - y * sin(angleRad)).toFloat()
offsetY += (x * sin(angleRad) + y * cos(angleRad)).toFloat()
}
)
}
.fillMaxSize()
)
}
}
Just set zoomable and rawDragGestureFilter on Image instead of Box :
#Preview
#Composable
fun Zoomable(){
val scale = remember { mutableStateOf(1f) }
val translate = remember { mutableStateOf(Offset(0f, 0f)) }
Box(
modifier = Modifier.preferredSize(300.dp)
) {
val imageBitmap = imageResource(id = R.drawable.cover)
Image(
modifier = Modifier
.zoomable(onZoomDelta = { scale.value *= it })
.rawDragGestureFilter(
object : DragObserver {
override fun onDrag(dragDistance: Offset): Offset {
translate.value = translate.value.plus(dragDistance)
return super.onDrag(dragDistance)
}
})
.graphicsLayer(
scaleX = scale.value,
scaleY = scale.value,
translationX = translate.value.x,
translationY = translate.value.y
),
contentDescription = null,
bitmap = imageBitmap
)
}
}
Using Jetpack Compose AsyncImage from coil.
Copied code from nglauber and adjusted for my requirements.
Add zoom/pan screen overflow restrictions. No image Rotation.
I didn't want to add dependency just to show an accessible image.
#Composable
fun ImagePreview(model: Any, contentDescription: String? = null) {
Box(modifier = Modifier.fillMaxSize()) {
val angle by remember { mutableStateOf(0f) }
var zoom by remember { mutableStateOf(1f) }
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }
val configuration = LocalConfiguration.current
val screenWidth = configuration.screenWidthDp.dp.value
val screenHeight = configuration.screenHeightDp.dp.value
AsyncImage(
model,
contentDescription = contentDescription,
contentScale = ContentScale.Fit,
modifier = Modifier
.offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) }
.graphicsLayer(
scaleX = zoom,
scaleY = zoom,
rotationZ = angle
)
.pointerInput(Unit) {
detectTransformGestures(
onGesture = { _, pan, gestureZoom, _ ->
zoom = (zoom * gestureZoom).coerceIn(1F..4F)
if (zoom > 1) {
val x = (pan.x * zoom)
val y = (pan.y * zoom)
val angleRad = angle * PI / 180.0
offsetX =
(offsetX + (x * cos(angleRad) - y * sin(angleRad)).toFloat()).coerceIn(
-(screenWidth * zoom)..(screenWidth * zoom)
)
offsetY =
(offsetY + (x * sin(angleRad) + y * cos(angleRad)).toFloat()).coerceIn(
-(screenHeight * zoom)..(screenHeight * zoom)
)
} else {
offsetX = 0F
offsetY = 0F
}
}
)
}
.fillMaxSize()
)
}
}
Related
I have the HorizontalPager with images. If the image has detectTransformGestures, the HorizontalPager will not scroll. If it's not there, then everything works. How can detectTransformGestures be disabled when scrolling horizontally.
var size by remember { mutableStateOf(Size.Zero) }
var scale by remember { mutableStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
HorizontalPager(
count = postPictures.size,
modifier = Modifier
.background(Color.Black.copy(alpha = 0.7f))
.fillMaxSize()
.onSizeChanged { size = it.toSize() }
) { image ->
GlideImage(
previewPlaceholder = 0,
imageOptions = ImageOptions(
contentScale = ContentScale.FillWidth
),
requestOptions = { RequestOptions.skipMemoryCacheOf(false) },
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.pointerInput(Unit) {
detectTransformGestures(
onGesture = { _, pan, zoom, _ ->
val newScale: Float = (scale * zoom).coerceIn(1f, 4f)
val newOffset = offset + pan
scale = newScale
val maxX = (size.width * (scale - 1) / 2f)
val maxY = (size.height * (scale - 1) / 2f)
offset = Offset(
newOffset.x.coerceIn(-maxX, maxX),
newOffset.y.coerceIn(-maxY, maxY)
)
}
)
}
}
.graphicsLayer {
scaleX = scale
scaleY = scale
translationX = offset.x
translationY = offset.y
},
imageModel = postPictures[image],
)
You need to write you own detectTransformGestures that selectively calls PointerInputChange.consume to prevent scrolling. I wrote a library that extends default gestures that returns PointerInputChange that you can consume based on your preferences. It's here
Modifier.pointerInput(Unit) {
detectTransformGestures(
onGestureStart = {
},
onGesture = { gestureCentroid: Offset,
gesturePan: Offset,
gestureZoom: Float,
gestureRotate: Float,
mainPointerInputChange: PointerInputChange,
pointerList: List<PointerInputChange> ->
},
onGestureEnd = {
}
)
}
calling mainPointerInputChange.consume will prevent scrolling and you can call consume based on rotation, zoom or anything you prefer.
If you wish to have a zoom modifier that already does this with other options such as fling and moving back to bounds or limiting pan as in your code you can check out this library.
fun Modifier.enhancedZoom(
key: Any? = Unit,
clip: Boolean = true,
enhancedZoomState: EnhancedZoomState,
enabled: (Float, Offset, Float) -> Boolean = DefaultEnabled,
zoomOnDoubleTap: (ZoomLevel) -> Float = enhancedZoomState.DefaultOnDoubleTap,
onGestureStart: ((EnhancedZoomData) -> Unit)? = null,
onGesture: ((EnhancedZoomData) -> Unit)? = null,
onGestureEnd: ((EnhancedZoomData) -> Unit)? = null,
)
enabled lambda returns zoom, pan and rotation info to selectively enable or disable scrolling of other Composables.
Usage
Image(
modifier = Modifier
.background(Color.LightGray)
.border(2.dp, Color.Red)
.fillMaxWidth()
.aspectRatio(4 / 3f)
.enhancedZoom(
clip = true,
enhancedZoomState = rememberEnhancedZoomState(
minZoom = .5f,
imageSize = IntSize(width, height),
limitPan = true,
moveToBounds = true
),
enabled = { zoom, pan, rotation ->
(zoom > 1f)
}
),
bitmap = imageBitmap,
contentDescription = "",
contentScale = ContentScale.FillBounds
)
I need to prepare a UI with a circular slider where I can pass value or move drag the pointer to slide in the slider similar to the screenshot attached.
How can I achieve it?
You can check out this quick solution. I have 0 degrees at the right but you can reverse the quadrants to suit your needs.
#Composable
fun Content() {
var radius by remember {
mutableStateOf(0f)
}
var shapeCenter by remember {
mutableStateOf(Offset.Zero)
}
var handleCenter by remember {
mutableStateOf(Offset.Zero)
}
var angle by remember {
mutableStateOf(20.0)
}
Canvas(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
handleCenter += dragAmount
angle = getRotationAngle(handleCenter, shapeCenter)
change.consumeAllChanges()
}
}
.padding(30.dp)
) {
shapeCenter = center
radius = size.minDimension / 2
val x = (shapeCenter.x + cos(Math.toRadians(angle)) * radius).toFloat()
val y = (shapeCenter.y + sin(Math.toRadians(angle)) * radius).toFloat()
handleCenter = Offset(x, y)
drawCircle(color = Color.Black.copy(alpha = 0.10f), style = Stroke(20f), radius = radius)
drawArc(
color = Color.Yellow,
startAngle = 0f,
sweepAngle = angle.toFloat(),
useCenter = false,
style = Stroke(20f)
)
drawCircle(color = Color.Cyan, center = handleCenter, radius = 60f)
}
}
private fun getRotationAngle(currentPosition: Offset, center: Offset): Double {
val (dx, dy) = currentPosition - center
val theta = atan2(dy, dx).toDouble()
var angle = Math.toDegrees(theta)
if (angle < 0) {
angle += 360.0
}
return angle
}
Check out the solution in this code on Github.
Just replace the angle value in Canvas.
I'm trying to create a Zoomable Image in Jetpack Compose. I've enabled zoom in/out functionality, but I'm not sure how should I set the limit on translationX property so that I cannot move the image horizontally outside of the Box bounds? Any solutions?
Example:
#Composable
fun ZoomableImage(
painter: Painter
) {
val scale = remember { mutableStateOf(1f) }
var offsetX by remember { mutableStateOf(0f) }
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.welcomeScreenBackgroundColor)
.pointerInput(Unit) {
detectTransformGestures { centroid, pan, zoom, rotation ->
scale.value *= zoom
}
},
contentAlignment = Alignment.Center
) {
Image(
modifier = Modifier
.pointerInput(Unit) {
detectHorizontalDragGestures { change, dragAmount ->
offsetX += dragAmount
}
}
.graphicsLayer(
translationX = offsetX,
scaleX = maxOf(1f, minOf(3f, scale.value)),
scaleY = maxOf(1f, minOf(3f, scale.value))
),
contentDescription = "Image",
painter = painter,
contentScale = ContentScale.Fit
)
}
}
I'm not sure if this is a good idea, but you could use onPlace.
And a WIP example:
#OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class)
#Composable
fun ZoomableImage(
painter: Painter,
contentDescription: String?,
modifier: Modifier = Modifier,
onLongPress: ((Offset) -> Unit)? = null,
onTap: ((Offset) -> Unit)? = null
) {
val scope = rememberCoroutineScope()
var layout: LayoutCoordinates? = null
var scale by remember { mutableStateOf(1f) }
var translation by remember { mutableStateOf(Offset.Zero) }
val transformableState = rememberTransformableState { zoomChange, panChange, _ ->
scale *= zoomChange
translation += panChange.times(scale)
}
Box(
modifier = modifier
.clipToBounds()
.transformable(state = transformableState)
.pointerInput(Unit) {
detectTapGestures(
onLongPress = onLongPress,
onDoubleTap = {
val maxScale = 2f
val midScale = 1.5f
val minScale = 1f
val targetScale = when {
scale >= minScale -> midScale
scale >= midScale -> maxScale
scale >= maxScale -> minScale
else -> minScale
}
scope.launch {
transformableState.animateZoomBy(targetScale / scale)
}
},
onTap = onTap
)
}
.pointerInput(Unit) {
forEachGesture {
awaitPointerEventScope {
val down = awaitFirstDown(requireUnconsumed = false)
drag(down.id) {
if (layout == null) return#drag
val maxX = layout!!.size.width * (scale - 1) / 2f
val maxY = layout!!.size.height * (scale - 1) / 2f
val targetTranslation = (it.positionChange() + translation)
if (targetTranslation.x > -maxX && targetTranslation.x < maxX &&
targetTranslation.y > -maxY && targetTranslation.y < maxY
) {
translation = targetTranslation
it.consumePositionChange()
}
}
}
}
}
) {
Image(
painter = painter,
contentDescription = contentDescription,
modifier = Modifier
.matchParentSize()
.onPlaced { layout = it }
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = translation.x,
translationY = translation.y
),
contentScale = ContentScale.Fit
)
LaunchedEffect(transformableState.isTransformInProgress) {
if (!transformableState.isTransformInProgress) {
if (scale < 1f) {
val originScale = scale
val originTranslation = translation
AnimationState(initialValue = 0f).animateTo(
1f,
SpringSpec(stiffness = Spring.StiffnessLow)
) {
scale = originScale + (1 - originScale) * this.value
translation = originTranslation * (1 - this.value)
}
} else {
if (layout == null) return#LaunchedEffect
val maxX = layout!!.size.width * (scale - 1) / 2f
val maxY = layout!!.size.height * (scale - 1) / 2f
val target = Offset(
translation.x.coerceIn(-maxX, maxX),
translation.y.coerceIn(-maxY, maxY)
)
AnimationState(
typeConverter = Offset.VectorConverter,
initialValue = translation
).animateTo(target, SpringSpec(stiffness = Spring.StiffnessLow)) {
translation = this.value
}
}
}
}
}
}
If you only need to constraint pan inside your image instead of animating back to desired levels it's very simple. Constraint it using your Composable size and zoom level as
val maxX = (size.width * (zoom - 1) / 2f)
val maxY = (size.height * (zoom - 1) / 2f)
offset = newOffset
offset = Offset(
newOffset.x.coerceIn(-maxX, maxX),
newOffset.y.coerceIn(-maxY, maxY)
-max, +max is the range and you set it between -max to +max when your TransformOrigin for graphicsLayer is (0.5,0.5) which is the default value. Be mindful about moving range when you change TransformOrigin
Full Implementation
States
var zoom by remember { mutableStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
var size by remember { mutableStateOf(IntSize.Zero) }
Modifier
val imageModifier = Modifier
.fillMaxSize()
.aspectRatio(4/3f)
.clipToBounds()
.pointerInput(Unit) {
detectTransformGestures(
onGesture = { _, gesturePan, gestureZoom, _ ->
val newScale = (zoom * gestureZoom).coerceIn(1f, 3f)
val newOffset = offset + gesturePan
zoom = newScale
val maxX = (size.width * (zoom - 1) / 2f)
val maxY = (size.height * (zoom - 1) / 2f)
offset = Offset(
newOffset.x.coerceIn(-maxX, maxX),
newOffset.y.coerceIn(-maxY, maxY)
)
}
)
}
.onSizeChanged {
size = it
}
.graphicsLayer {
translationX = offset.x
translationY = offset.y
scaleX = zoom
scaleY = zoom
}
The code below is working fine apart from the fact that once I have dragged my rectangles, I can only select them again by touching the area where they were before I moved them. I don't know how to update their position once I have dragged them. I couldn't find how to do it in the doc, but maybe I was not looking in the right place (androidx.compose.foundation.gestures).
So this is the code that I am using so far:
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }
var offsetX2 by remember { mutableStateOf(0f) }
var offsetY2 by remember { mutableStateOf(0f) }
val rect1 = RectF(offsetX, offsetY, offsetX + 200f, offsetY + 300f)
val rect2 = RectF(offsetX2, offsetY2, offsetX2 + 300f, offsetY2 + 400f)
var selectedRect: RectF? = null
val collision = RectF.intersects(rect1, rect2)
val imageBitmap = ImageBitmap(
1000, 1000, ImageBitmapConfig.Argb8888, false,
Color.Black.colorSpace
)
val imageBitmapCanvas = Canvas(imageBitmap)
val canvas = Canvas(imageBitmapCanvas.nativeCanvas)
val paint = Paint()
val rectanglePaint = Paint().apply {
color = android.graphics.Color.BLUE
style = Paint.Style.STROKE
strokeWidth = 8f
}
Column(
modifier = Modifier
.background(color = Color.DarkGray)
.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally
) {
TextField(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 30.dp),
value = textState.value,
onValueChange = { textState.value = it }
)
CanvasDrawScope().draw(Density(1.0f), LayoutDirection.Ltr, canvas,
Size(1000f, 1000f), ) {
drawRect(
topLeft = Offset(0f, 0f), color = if (collision) Color.Red else Color.Green,
size = Size(1000f, 1000f)
)
}
canvas.nativeCanvas.drawRect(rect1, rectanglePaint)
canvas.nativeCanvas.drawRect(rect2, rectanglePaint)
Image(bitmap = imageBitmap, "New Image", Modifier
.pointerInput(Unit) {
detectTapGestures(
onPress = {
val x = it.x
val y = it.y
selectedRect = when {
rect1.contains(x, y) -> rect1
rect2.contains(x, y) -> rect2
else -> null
}
},
)
}
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consumeAllChanges()
if (selectedRect == rect1) {
offsetX += dragAmount.x
offsetY += dragAmount.y
} else {
offsetX2 += dragAmount.x
offsetY2 += dragAmount.y
}
}
})
I would be grateful for any ideas.
I changed something in your code in order to use a Canvas Composable.
In the detectDragGestures I update also the Offset in the selected Rect. I would avoid it but I didn't find a better solution.
data class RectData(
var size: Size,
var offset: Offset
)
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }
var offsetX2 by remember { mutableStateOf(250f) }
var offsetY2 by remember { mutableStateOf(300f) }
val rectList = mutableListOf<RectData>()
var rectA = RectData(Size(200f,300f), Offset(offsetX, offsetY))
var rectB = RectData(Size(500f,600f), Offset(offsetX2, offsetY2))
rectList.add(rectA)
rectList.add(rectB)
var selectedRect: RectData? by remember { mutableStateOf(null) }
Canvas(modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures(
onPress = {
val x = it.x
val y = it.y
selectedRect = null
rectList.forEach(){
val rect = RectF(
it.offset.x,
it.offset.y,
it.offset.x+it.size.width,
it.offset.y + it.size.height
)
if (rect.contains(x,y)) selectedRect = it
}
},
)
}
.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consumeAllChanges()
when (selectedRect) {
rectA -> {
offsetX += dragAmount.x
offsetY += dragAmount.y
rectA.offset = Offset(offsetX,offsetY) //update the offset
}
rectB -> {
offsetX2 += dragAmount.x
offsetY2 += dragAmount.y
rectB.offset = Offset(offsetX2,offsetY2) //update the offset
}
}
}
}
){
val canvasQuadrantSize = size / 2F
drawRect(
topLeft = Offset(0f,0f),
color = Color.Green,
size = canvasQuadrantSize
)
rectList.forEach(){
drawRect(
brush = SolidColor(Color.Blue),
topLeft = it.offset,
size = it.size,
style = Stroke(width = 8f)
)
}
}
I want to animate a composable based on a fixed value and the $width of the composable.
How can I get the $width to use it for the animate function?
This is my code
#Composable
fun ExpandingCircle() {
val (checked, setChecked) = remember { mutableStateOf(false) }
val radius = if (checked) **$width** else 4.dp
val radiusAnimated = animate(radius)
Canvas(
modifier = Modifier.fillMaxSize()
.clickable(onClick = { setChecked(!checked) }),
onDraw = {
drawCircle(color = Color.Black, radius = radiusAnimated.toPx())
}
)
}
We can get the size from DrawScope, from the size we can get the width and height of the Canvas, So you can do animation like this.
#Composable
fun ExpandingCircle() {
val (checked, setChecked) = remember { mutableStateOf(false) }
val unCheckedRadius = 4.dp
Canvas(
modifier = Modifier.fillMaxSize()
.clickable(onClick = { setChecked(!checked) }),
onDraw = {
val width = size.width
drawCircle(color = Color.Black, radius = if (checked) width/2 else unCheckedRadius.toPx())
}
)
}
I realized I don't need the width already for animate, but i can just use a animated / interpolating float to use it for the calculation in the DrawScope
#Composable
fun ExpandingCircle() {
val (checked, setChecked) = remember { mutableStateOf(false) }
val radiusExpandFactor = if (checked) 1f else 0f
val radiusExpandFactorAnimated = animate(radiusExpandFactor)
Canvas(
modifier = Modifier.fillMaxSize()
.clickable(onClick = { setChecked(!checked) }),
onDraw = {
val radius = 4.dp.toPx() + (radiusExpandFactorAnimated * (size.width / 2 - 4.dp.toPx()))
drawCircle(color = Color.Black, radius = radius)
}
)
}