How to update position change of objects on canvas in Jetpack Compose? - android

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

Related

Drag Image to the bounds of the Box - Jetpack Compose

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
}

How to create timed Instagram story loading bar using Jetpack Compose animation?

I would like to create a composable component very similar to Instagram story loading bar with 10 seconds of duration.
I had an idea how to do it but I'm not how to executed. I was thinking about adding a static bar (grey color) using a BOX and then add another bar (white color) which animates from 0 to final of composable width in 10 seconds.
Do you have any idea how I can implement this component?
You can use this Composable to create a segmented progress bar
private const val BackgroundOpacity = 0.25f
private const val NumberOfSegments = 8
private val StrokeWidth = 4.dp
private val SegmentGap = 8.dp
#Composable
fun SegmentedProgressIndicator(
/*#FloatRange(from = 0.0, to = 1.0)*/
progress: Float,
modifier: Modifier = Modifier,
color: Color = MaterialTheme.colors.primary,
backgroundColor: Color = color.copy(alpha = BackgroundOpacity),
strokeWidth: Dp = StrokeWidth,
numberOfSegments: Int = NumberOfSegments,
segmentGap: Dp = SegmentGap
) {
val gap: Float
val stroke: Float
with(LocalDensity.current) {
gap = segmentGap.toPx()
stroke = strokeWidth.toPx()
}
Canvas(
modifier
.progressSemantics(progress)
.fillMaxWidth()
.height(strokeWidth)
.focusable()
) {
drawSegments(1f, backgroundColor, stroke, numberOfSegments, gap)
drawSegments(progress, color, stroke, numberOfSegments, gap)
}
}
private fun DrawScope.drawSegments(
progress: Float,
color: Color,
strokeWidth: Float,
segments: Int,
segmentGap: Float,
) {
val width = size.width
val start = 0f
val gaps = (segments - 1) * segmentGap
val segmentWidth = (width - gaps) / segments
val barsWidth = segmentWidth * segments
val end = barsWidth * progress + (progress * segments).toInt()* segmentGap
repeat(segments) { index ->
val offset = index * (segmentWidth + segmentGap)
if (offset < end) {
val barEnd = (offset + segmentWidth).coerceAtMost(end)
drawLine(
color,
Offset(start + offset, 0f),
Offset(barEnd, 0f),
strokeWidth
)
}
}
}
You use it like this
var running by remember { mutableStateOf(false) }
val progress: Float by animateFloatAsState(
if (running) 1f else 0f,
animationSpec = tween(
durationMillis = 10_000,
easing = LinearEasing
)
)
Surface(color = MaterialTheme.colors.background) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
SegmentedProgressIndicator(
progress = progress,
modifier = Modifier
.padding(top = 64.dp, start = 32.dp, end = 32.dp)
.fillMaxWidth(),
)
Button(
onClick = { running = !running },
modifier = Modifier.padding(top = 32.dp)
) {
Text(
text = if (running) "Reverse Animation" else "Start Animation"
)
}
}
}
This is the result
You can animate the progress of a LinearProgressIndicator.
Something like:
var enabled by remember { mutableStateOf(false) }
val progress: Float by animateFloatAsState(
if (enabled) 1f else 0.0f,
animationSpec = tween(
durationMillis = 10000,
delayMillis = 40,
easing = LinearOutSlowInEasing
)
)
LinearProgressIndicator(
color = White,
backgroundColor = LightGray,
progress = progress,
modifier = Modifier.width(100.dp)
)
Just set enabled=true to start the animation (it is just an example).
try this solution, it should work:
var progress by remember {mutableStateOf(0.0f)}
var enabled by remember { mutableStateOf(true) }
LaunchedEffect(key1 = progress, key2 = enabled) {
if(progress<1 && enabled) {
delay(100L)
progress += 0.01F
}
}
LinearProgressIndicator(
color = White,
backgroundColor = LightGray,
progress = progress,
modifier = Modifier.width(100.dp)
)
This is can be a full sample solution you can see and try to extend as well as refactor. The code is self-explanatory and you will see how it goes.
A story composable:
#OptIn(ExperimentalAnimationApi::class)
#Composable
fun Story(story: Stories) {
AnimatedContent(story) {
when (story) {
Stories.ONE -> {
StoryContent("1")
}
Stories.TWO -> {
StoryContent("2")
}
Stories.THREE -> {
StoryContent("3")
}
}
}
}
#Composable
private fun StoryContent(content: String) {
Text("Story $content")
}
Story progress indicator:
#Composable
fun StoryProgressIndicator(running: Boolean, modifier: Modifier = Modifier, onTenSecondsOnThisStory: () -> Unit) {
val progress: Float by animateFloatAsState(
if (running) 1f else 0f,
animationSpec = tween(
durationMillis = if (running) 10_000 else 0,
easing = LinearEasing
)
)
if (progress == 1f) {
onTenSecondsOnThisStory()
}
LinearProgressIndicator(
progress, modifier
)
}
And a screen that contains the navigation among stories and playing the animation.
#OptIn(ExperimentalAnimationApi::class)
#Composable
fun InstagramLikeStories() {
var screenWidth by remember { mutableStateOf(1) }
var currentStory by remember { mutableStateOf(Stories.ONE) }
var currentStoryPointer by remember { mutableStateOf(0) }
var runningStoryOne by remember { mutableStateOf(false) }
var runningStoryTwo by remember { mutableStateOf(false) }
var runningStoryThree by remember { mutableStateOf(false) }
val runStoryOne = { runningStoryOne = true; runningStoryTwo = false; runningStoryThree = false }
val runStoryTwo = { runningStoryOne = false; runningStoryTwo = true; runningStoryThree = false }
val runStoryThree = { runningStoryOne = false; runningStoryTwo = false; runningStoryThree = true }
val stories = Stories.values()
LaunchedEffect(Unit) { runStoryOne() }
Column(
Modifier.fillMaxSize().onGloballyPositioned {
screenWidth = it.size.width
}.pointerInput(Unit) {
detectTapGestures(onTap = {
if ((it.x / screenWidth) * 100 > 50) {
if (currentStoryPointer == stories.size - 1) {
currentStoryPointer = 0
} else {
currentStoryPointer++
}
currentStory = stories[currentStoryPointer]
} else {
if (currentStoryPointer != 0) {
currentStoryPointer--
currentStory = stories[currentStoryPointer]
}
}
runStoryIndicator(currentStory, runStoryOne, runStoryTwo, runStoryThree)
})
}
) {
Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
StoryProgressIndicator(runningStoryOne, Modifier.weight(1f), onTenSecondsOnThisStory = {
runStoryIndicator(currentStory, runStoryOne, runStoryTwo, runStoryThree)
currentStoryPointer = 1
currentStory = stories[currentStoryPointer]
})
Spacer(Modifier.weight(0.1f))
StoryProgressIndicator(runningStoryTwo, Modifier.weight(1f), onTenSecondsOnThisStory = {
runStoryIndicator(currentStory, runStoryOne, runStoryTwo, runStoryThree)
currentStoryPointer = 2
currentStory = stories[currentStoryPointer]
})
Spacer(Modifier.weight(0.1f))
StoryProgressIndicator(runningStoryThree, Modifier.weight(1f), onTenSecondsOnThisStory = {
runStoryIndicator(currentStory, runStoryOne, runStoryTwo, runStoryThree)
// go to first one
currentStoryPointer = 0
currentStory = stories[currentStoryPointer]
})
}
}
Story(currentStory)
Row {
Button(onClick = {
}) {
Text("button")
}
}
}
private fun runStoryIndicator(
currentStory: Stories,
runStoryOne: () -> Unit,
runStoryTwo: () -> Unit,
runStoryThree: () -> Unit
) {
when (currentStory) {
Stories.ONE -> runStoryOne()
Stories.TWO -> runStoryTwo()
Stories.THREE -> runStoryThree()
}
}
enum class Stories {
ONE, TWO, THREE
}

How to apply the Layout modifier to each placeable individually in Jetpack Compose?

I have a draggable modifier in my custom layout. The problem is that all my placeables are moving as a block, whereas I would like them to move individually. What would be the correct way of looping through them to make sure only one placeable is selected at a time? Or is there a better way of going about it? Here is my custom layout:
#Composable
fun CustomLayout(
modifier: Modifier = Modifier,
content: #Composable() () -> Unit
) {
val coroutineScope = rememberCoroutineScope()
val offsetX = remember { Animatable(0f) }
val offsetY = remember { Animatable(0f) }
Layout(
modifier = modifier
.offset {
IntOffset(
offsetX.value.roundToInt(),
offsetY.value.roundToInt()
)
}
.draggable(
state = rememberDraggableState { delta ->
coroutineScope.launch {
offsetX.snapTo(offsetX.value + delta)
}
},
orientation = Orientation.Horizontal,
onDragStarted = {},
onDragStopped = {
coroutineScope.launch {
offsetX.animateTo(
targetValue = 0f,
animationSpec = tween(
durationMillis = 1000,
delayMillis = 0
)
)
}
}
),
content = content
) { measurables, constraints ->
val tileSize = constraints.maxWidth / 7
val childConstraints = constraints.copy(
minWidth = minOf(constraints.minWidth, tileSize),
maxWidth = tileSize
)
val placeables = measurables.map { measurable ->
measurable.measure(childConstraints)
}
layout(constraints.maxWidth, constraints.maxHeight) {
var yPosition = 0
val xPosition = 0
placeables.forEachIndexed { index, placeable ->
if (index <= 6) {
placeable.placeRelative(x = xPosition, y = yPosition)
} else {
placeable.placeRelative(
constraints.maxWidth - tileSize,
yPosition - placeable.height * 7
)
}
yPosition += placeable.height
}
}
}
}
Here I would like to move one tile only at a time:
You solution doesn't work because you're applying the offset to the whole layout, but you need to apply it for a single item.
Layout only intended to layout items: in the MeasureScope we only have access to item sizes/positions, and we can't add modifiers to them, as those will modify the state and it'll lead to recursion.
My suggestion is to pass items count and an item generator to your Composable, so we can add both offset and draggable modifiers to each item:
#Composable
fun DraggableLayout(
modifier: Modifier = Modifier,
count: Int,
item: #Composable (Int, Modifier) -> Unit
) {
val coroutineScope = rememberCoroutineScope()
val offsetsX = remember { mutableStateMapOf<Int, Animatable<Float, AnimationVector1D>>() }
CustomLayout(
modifier = modifier,
content = {
for (i in 0 until count) {
item(
i,
Modifier
.offset {
IntOffset(
offsetsX[i]?.value?.roundToInt() ?: 0,
0
)
}
.draggable(
state = rememberDraggableState { delta ->
coroutineScope.launch {
val offsetX = offsetsX[i] ?: Animatable(0f)
offsetX.snapTo(offsetX.value + delta)
offsetsX[i] = offsetX
}
},
orientation = Orientation.Horizontal,
onDragStarted = {},
onDragStopped = {
coroutineScope.launch {
offsetsX[i]!!.animateTo(
targetValue = 0f,
animationSpec = tween(
durationMillis = 1000,
delayMillis = 0
)
)
}
}
),
)
}
}
)
}
#Composable
fun CustomLayout(
modifier: Modifier = Modifier,
content: #Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content,
) { measurables, constraints ->
val tileSize = constraints.maxWidth / 7
val childConstraints = constraints.copy(
minWidth = minOf(constraints.minWidth, tileSize),
maxWidth = tileSize
)
val placeables = measurables.map { measurable ->
measurable.measure(childConstraints)
}
layout(constraints.maxWidth, constraints.maxHeight) {
var yPosition = 0
val xPosition = 0
placeables.forEachIndexed { index, placeable ->
if (index <= 6) {
placeable.placeRelative(x = xPosition, y = yPosition)
} else {
placeable.placeRelative(
constraints.maxWidth - tileSize,
yPosition - placeable.height * 7
)
}
yPosition += placeable.height
}
}
}
}
And use it like this:
CustomLayout(
count = 10,
item = { i, modifier ->
Text(
"Test $i",
modifier = Modifier
.size(50.dp)
.then(modifier)
)
}
)
The result:

Android Jetpack Compose: how to zoom a image in a "box"?

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

Animate based on dimensions

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

Categories

Resources