Skip to content

Commit 0ad26f2

Browse files
Merge branch 'feature/zoomable-image' into develop
2 parents 14d6e41 + b68dc92 commit 0ad26f2

File tree

3 files changed

+228
-5
lines changed

3 files changed

+228
-5
lines changed

app/src/main/java/com/smarttoolfactory/composeimage/MainActivity.kt

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,7 @@ import com.google.accompanist.pager.ExperimentalPagerApi
1818
import com.google.accompanist.pager.HorizontalPager
1919
import com.google.accompanist.pager.PagerState
2020
import com.google.accompanist.pager.rememberPagerState
21-
import com.smarttoolfactory.composeimage.demo.EditScaleDemo
22-
import com.smarttoolfactory.composeimage.demo.EditSizeDemo
23-
import com.smarttoolfactory.composeimage.demo.ImageWithConstraintsDemo
24-
import com.smarttoolfactory.composeimage.demo.ThumbnailDemo
21+
import com.smarttoolfactory.composeimage.demo.*
2522
import com.smarttoolfactory.composeimage.ui.theme.ComposeImageTheme
2623
import kotlinx.coroutines.launch
2724

@@ -93,7 +90,8 @@ private fun HomeContent() {
9390
0 -> ImageWithConstraintsDemo()
9491
1 -> ThumbnailDemo()
9592
2 -> EditScaleDemo()
96-
else -> EditSizeDemo()
93+
3 -> EditSizeDemo()
94+
else -> ZoomableImageDemo()
9795
}
9896
}
9997
}
@@ -105,4 +103,5 @@ internal val tabList =
105103
"Image Thumbnail",
106104
"Editable Scale",
107105
"Editable Size",
106+
"Zoomable Image",
108107
)
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package com.smarttoolfactory.composeimage.demo
2+
3+
import androidx.compose.foundation.background
4+
import androidx.compose.foundation.layout.*
5+
import androidx.compose.material3.MaterialTheme
6+
import androidx.compose.material3.Text
7+
import androidx.compose.runtime.*
8+
import androidx.compose.ui.Modifier
9+
import androidx.compose.ui.graphics.Color
10+
import androidx.compose.ui.graphics.ImageBitmap
11+
import androidx.compose.ui.layout.ContentScale
12+
import androidx.compose.ui.platform.LocalContext
13+
import androidx.compose.ui.res.imageResource
14+
import androidx.compose.ui.text.font.FontWeight
15+
import androidx.compose.ui.unit.dp
16+
import androidx.compose.ui.unit.sp
17+
import com.smarttoolfactory.composeimage.ContentScaleSelectionMenu
18+
import com.smarttoolfactory.composeimage.R
19+
import com.smarttoolfactory.image.zoom.ZoomableImage
20+
21+
@Composable
22+
fun ZoomableImageDemo() {
23+
24+
Column(modifier = Modifier.fillMaxSize()) {
25+
val imageBitmapLarge = ImageBitmap.imageResource(
26+
LocalContext.current.resources,
27+
R.drawable.landscape4
28+
)
29+
30+
var contentScale by remember { mutableStateOf(ContentScale.Fit) }
31+
32+
ContentScaleSelectionMenu(contentScale = contentScale) {
33+
contentScale = it
34+
}
35+
36+
Text(
37+
text = "clipTransformToContentScale false",
38+
fontSize = 16.sp,
39+
fontWeight = FontWeight.Bold,
40+
color = MaterialTheme.colorScheme.primary,
41+
modifier = Modifier.padding(8.dp)
42+
)
43+
ZoomableImage(
44+
modifier = Modifier
45+
.background(Color.LightGray)
46+
.fillMaxWidth()
47+
.aspectRatio(4 / 3f),
48+
imageBitmap = imageBitmapLarge,
49+
contentScale = contentScale,
50+
clipTransformToContentScale = false
51+
)
52+
53+
Spacer(modifier = Modifier.height(20.dp))
54+
55+
Text(
56+
text = "clipTransformToContentScale true",
57+
fontSize = 16.sp,
58+
fontWeight = FontWeight.Bold,
59+
color = MaterialTheme.colorScheme.primary,
60+
modifier = Modifier.padding(8.dp)
61+
)
62+
ZoomableImage(
63+
modifier = Modifier
64+
.background(Color.LightGray)
65+
.fillMaxWidth()
66+
.aspectRatio(4 / 3f),
67+
imageBitmap = imageBitmapLarge,
68+
contentScale = contentScale,
69+
clipTransformToContentScale = true
70+
)
71+
}
72+
}
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)