Skip to content

Commit efff633

Browse files
add ZoomableImage
1 parent 14d6e41 commit efff633

File tree

1 file changed

+152
-0
lines changed

1 file changed

+152
-0
lines changed
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package com.smarttoolfactory.image.zoom
2+
3+
import androidx.compose.animation.core.Animatable
4+
import androidx.compose.animation.core.VectorConverter
5+
import androidx.compose.animation.core.spring
6+
import androidx.compose.foundation.Image
7+
import androidx.compose.foundation.gestures.detectTapGestures
8+
import androidx.compose.runtime.*
9+
import androidx.compose.ui.Alignment
10+
import androidx.compose.ui.Modifier
11+
import androidx.compose.ui.draw.clipToBounds
12+
import androidx.compose.ui.geometry.Offset
13+
import androidx.compose.ui.geometry.Size
14+
import androidx.compose.ui.graphics.*
15+
import androidx.compose.ui.graphics.drawscope.DrawScope
16+
import androidx.compose.ui.input.pointer.pointerInput
17+
import androidx.compose.ui.layout.ContentScale
18+
import androidx.compose.ui.platform.LocalDensity
19+
import com.smarttoolfactory.gesture.detectTransformGestures
20+
import com.smarttoolfactory.image.ImageWithConstraints
21+
import kotlinx.coroutines.launch
22+
23+
/**
24+
* Zoomable image that zooms in and out in [ [minZoom], [maxZoom] ] interval and translates
25+
* zoomed image based on pointer position.
26+
* Double tap gestures reset image translation and zoom to default values with animation.
27+
*
28+
* @param initialZoom zoom set initially
29+
* @param minZoom minimum zoom value this Composable can possess
30+
* @param maxZoom maximum zoom value this Composable can possess
31+
* @param clipTransformToContentScale when set true zoomable image takes borders of image drawn
32+
* while zooming in. [contentScale] determines whether will be empty spaces on edges of Composable
33+
*/
34+
@Composable
35+
fun ZoomableImage(
36+
modifier: Modifier = Modifier,
37+
imageBitmap: ImageBitmap,
38+
alignment: Alignment = Alignment.Center,
39+
contentScale: ContentScale = ContentScale.Fit,
40+
contentDescription: String? = null,
41+
alpha: Float = DefaultAlpha,
42+
initialZoom: Float = 1f,
43+
minZoom: Float = 1f,
44+
maxZoom: Float = 5f,
45+
clipTransformToContentScale: Boolean = false,
46+
colorFilter: ColorFilter? = null,
47+
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality,
48+
) {
49+
50+
val coroutineScope = rememberCoroutineScope()
51+
val zoomMin = minZoom.coerceAtLeast(.5f)
52+
val zoomMax = maxZoom.coerceAtLeast(1f)
53+
val zoomInitial = initialZoom.coerceIn(zoomMin, zoomMax)
54+
55+
require(zoomMax >= zoomMin)
56+
57+
var size by remember { mutableStateOf(Size.Zero) }
58+
59+
val animatableOffset = remember(imageBitmap, contentScale) {
60+
Animatable(Offset.Zero, Offset.VectorConverter)
61+
}
62+
val animatableZoom = remember(imageBitmap, contentScale) { Animatable(zoomInitial) }
63+
64+
val zoomModifier = Modifier
65+
.clipToBounds()
66+
.graphicsLayer {
67+
val zoom = animatableZoom.value
68+
translationX = animatableOffset.value.x
69+
translationY = animatableOffset.value.y
70+
scaleX = zoom
71+
scaleY = zoom
72+
}
73+
.pointerInput(imageBitmap, contentScale) {
74+
75+
detectTransformGestures(
76+
onGesture = { _,
77+
gesturePan: Offset,
78+
gestureZoom: Float,
79+
_,
80+
_,
81+
_ ->
82+
83+
var zoom = animatableZoom.value
84+
val offset = animatableOffset.value
85+
86+
zoom = (zoom * gestureZoom).coerceIn(zoomMin, zoomMax)
87+
val newOffset = offset + gesturePan.times(zoom)
88+
89+
val maxX = (size.width * (zoom - 1) / 2f).coerceAtLeast(0f)
90+
val maxY = (size.height * (zoom - 1) / 2f).coerceAtLeast(0f)
91+
92+
coroutineScope.launch {
93+
animatableZoom.snapTo(zoom)
94+
}
95+
coroutineScope.launch {
96+
animatableOffset.snapTo(
97+
Offset(
98+
newOffset.x.coerceIn(-maxX, maxX),
99+
newOffset.y.coerceIn(-maxY, maxY)
100+
)
101+
)
102+
}
103+
}
104+
)
105+
}
106+
.pointerInput(imageBitmap, contentScale) {
107+
detectTapGestures(
108+
onDoubleTap = {
109+
coroutineScope.launch {
110+
animatableOffset.animateTo(Offset.Zero, spring())
111+
}
112+
coroutineScope.launch {
113+
animatableZoom.animateTo(zoomInitial, spring())
114+
}
115+
}
116+
)
117+
}
118+
119+
ImageWithConstraints(
120+
modifier = if (clipTransformToContentScale) modifier else modifier.then(zoomModifier),
121+
imageBitmap = imageBitmap,
122+
alignment = alignment,
123+
contentScale = contentScale,
124+
contentDescription = contentDescription,
125+
alpha = alpha,
126+
colorFilter = colorFilter,
127+
filterQuality = filterQuality,
128+
drawImage = !clipTransformToContentScale
129+
) {
130+
131+
size = with(LocalDensity.current) {
132+
Size(
133+
width = imageWidth.toPx(),
134+
height = imageHeight.toPx()
135+
)
136+
}
137+
138+
if (clipTransformToContentScale) {
139+
Image(
140+
bitmap = imageBitmap,
141+
contentScale = contentScale,
142+
modifier = zoomModifier,
143+
alignment = alignment,
144+
contentDescription = contentDescription,
145+
alpha = alpha,
146+
colorFilter = colorFilter,
147+
filterQuality = filterQuality,
148+
)
149+
}
150+
}
151+
}
152+

0 commit comments

Comments
 (0)