Skip to content

Commit 1f01e48

Browse files
create BeforeAfterImage
1 parent 0faca7a commit 1f01e48

1 file changed

Lines changed: 351 additions & 0 deletions

File tree

Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
package com.smarttoolfactory.image.beforeafter
2+
3+
import androidx.compose.foundation.Canvas
4+
import androidx.compose.foundation.layout.Box
5+
import androidx.compose.foundation.layout.BoxWithConstraints
6+
import androidx.compose.foundation.layout.size
7+
import androidx.compose.runtime.*
8+
import androidx.compose.ui.Alignment
9+
import androidx.compose.ui.Modifier
10+
import androidx.compose.ui.draw.clipToBounds
11+
import androidx.compose.ui.geometry.Offset
12+
import androidx.compose.ui.geometry.Size
13+
import androidx.compose.ui.graphics.*
14+
import androidx.compose.ui.graphics.drawscope.DrawScope
15+
import androidx.compose.ui.graphics.drawscope.translate
16+
import androidx.compose.ui.input.pointer.pointerInput
17+
import androidx.compose.ui.layout.ContentScale
18+
import androidx.compose.ui.platform.LocalDensity
19+
import androidx.compose.ui.semantics.Role
20+
import androidx.compose.ui.semantics.contentDescription
21+
import androidx.compose.ui.semantics.role
22+
import androidx.compose.ui.semantics.semantics
23+
import androidx.compose.ui.unit.*
24+
import com.smarttoolfactory.gesture.detectTransformGestures
25+
import com.smarttoolfactory.gesture.pointerMotionEvents
26+
import com.smarttoolfactory.image.ImageScope
27+
import com.smarttoolfactory.image.ImageScopeImpl
28+
import com.smarttoolfactory.image.getParentSize
29+
import com.smarttoolfactory.image.getScaledBitmapRect
30+
31+
32+
/**
33+
* A composable that lays out and draws a given [ImageBitmap]. This will attempt to
34+
* size the composable according to the [ImageBitmap]'s given width and height. However, an
35+
* optional [Modifier] parameter can be provided to adjust sizing or draw additional content (ex.
36+
* background). Any unspecified dimension will leverage the [ImageBitmap]'s size as a minimum
37+
* constraint.
38+
*
39+
* [ImageScope] returns constraints, width and height of the drawing area based on [contentScale]
40+
* and rectangle of [beforeImage] drawn. When a bitmap is displayed scaled to fit area of Composable
41+
* space used for drawing image is represented with [ImageScope.imageWidth] and
42+
* [ImageScope.imageHeight].
43+
*
44+
* When we display a bitmap 1000x1000px with [ContentScale.Crop] if it's cropped to 500x500px
45+
* [ImageScope.rect] returns `IntRect(250,250,750,750)`.
46+
*
47+
* @param alignment determines where image will be aligned inside [BoxWithConstraints]
48+
* This is observable when bitmap image/width ratio differs from [Canvas] that draws [ImageBitmap]
49+
* @param contentDescription text used by accessibility services to describe what this image
50+
* represents. This should always be provided unless this image is used for decorative purposes,
51+
* and does not represent a meaningful action that a user can take. This text should be
52+
* localized, such as by using [androidx.compose.ui.res.stringResource] or similar
53+
* @param contentScale how image should be scaled inside Canvas to match parent dimensions.
54+
* [ContentScale.Fit] for instance maintains src ratio and scales image to fit inside the parent.
55+
* @param alpha Opacity to be applied to [beforeImage] from 0.0f to 1.0f representing
56+
* fully transparent to fully opaque respectively
57+
* @param colorFilter ColorFilter to apply to the [beforeImage] when drawn into the destination
58+
* @param filterQuality Sampling algorithm applied to the [beforeImage] when it is scaled and drawn
59+
* into the destination. The default is [FilterQuality.Low] which scales using a bilinear
60+
* sampling algorithm
61+
* @param content is a Composable that can be matched at exact position where [beforeImage] is drawn.
62+
* This is useful for drawing thumbs, cropping or another layout that should match position
63+
* with the image that is scaled is drawn
64+
*/
65+
@Composable
66+
fun BeforeAfterImage(
67+
modifier: Modifier = Modifier,
68+
beforeImage: ImageBitmap,
69+
afterImage: ImageBitmap,
70+
alignment: Alignment = Alignment.Center,
71+
contentScale: ContentScale = ContentScale.Fit,
72+
contentDescription: String? = null,
73+
alpha: Float = DefaultAlpha,
74+
colorFilter: ColorFilter? = null,
75+
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality,
76+
content: @Composable ImageScope.() -> Unit = {}
77+
) {
78+
79+
val semantics = if (contentDescription != null) {
80+
Modifier.semantics {
81+
this.contentDescription = contentDescription
82+
this.role = Role.Image
83+
}
84+
} else {
85+
Modifier
86+
}
87+
88+
BoxWithConstraints(
89+
modifier = modifier
90+
.then(semantics),
91+
contentAlignment = alignment,
92+
) {
93+
94+
val bitmapWidth = beforeImage.width
95+
val bitmapHeight = beforeImage.height
96+
97+
98+
val (boxWidth: Int, boxHeight: Int) = getParentSize(bitmapWidth, bitmapHeight)
99+
100+
// Src is Bitmap, Dst is the container(Image) that Bitmap will be displayed
101+
val srcSize = Size(bitmapWidth.toFloat(), bitmapHeight.toFloat())
102+
val dstSize = Size(boxWidth.toFloat(), boxHeight.toFloat())
103+
104+
val scaleFactor = contentScale.computeScaleFactor(srcSize, dstSize)
105+
106+
// Image is the container for bitmap that is located inside Box
107+
// image bounds can be smaller or bigger than its parent based on how it's scaled
108+
val imageWidth = bitmapWidth * scaleFactor.scaleX
109+
val imageHeight = bitmapHeight * scaleFactor.scaleY
110+
111+
var handlePosition by remember { mutableStateOf(imageWidth.coerceAtMost(boxWidth.toFloat()) / 2f) }
112+
113+
var isHandleTouched by remember { mutableStateOf(false) }
114+
115+
var zoom by remember { mutableStateOf(1f) }
116+
var pan by remember { mutableStateOf(Offset.Zero) }
117+
118+
119+
val imageModifier = Modifier
120+
.clipToBounds()
121+
.pointerInput(Unit) {
122+
detectTransformGestures(
123+
onGesture = { _: Offset, panChange: Offset, zoomChange: Float, _, _, _ ->
124+
125+
zoom = (zoom * zoomChange).coerceIn(1f, 5f)
126+
127+
val maxX = (size.width * (zoom - 1) / 2f)
128+
val maxY = (size.height * (zoom - 1) / 2f)
129+
130+
val newPan = pan + panChange.times(zoom)
131+
pan = Offset(
132+
newPan.x.coerceIn(-maxX, maxX),
133+
newPan.y.coerceIn(-maxY, maxY)
134+
)
135+
}
136+
)
137+
}
138+
.pointerMotionEvents(
139+
onDown = {
140+
val position = it.position
141+
val xPos = position.x
142+
143+
isHandleTouched = ((handlePosition - xPos) * (handlePosition - xPos) < 10000)
144+
},
145+
onMove = {
146+
if (isHandleTouched) {
147+
handlePosition = it.position.x
148+
it.consume()
149+
}
150+
},
151+
onUp = {
152+
isHandleTouched = false
153+
}
154+
)
155+
.graphicsLayer {
156+
this.scaleX = zoom
157+
this.scaleY = zoom
158+
this.translationX = pan.x
159+
this.translationY = pan.y
160+
}
161+
162+
val bitmapRect = getScaledBitmapRect(
163+
boxWidth = boxWidth,
164+
boxHeight = boxHeight,
165+
imageWidth = imageWidth,
166+
imageHeight = imageHeight,
167+
bitmapWidth = bitmapWidth,
168+
bitmapHeight = bitmapHeight
169+
)
170+
171+
val density = LocalDensity.current
172+
173+
// Dimensions of canvas that will draw this Bitmap
174+
val canvasWidthInDp: Dp
175+
val canvasHeightInDp: Dp
176+
177+
with(density) {
178+
canvasWidthInDp = imageWidth.coerceAtMost(boxWidth.toFloat()).toDp()
179+
canvasHeightInDp = imageHeight.coerceAtMost(boxHeight.toFloat()).toDp()
180+
}
181+
182+
ImageLayout(
183+
modifier = imageModifier,
184+
constraints = constraints,
185+
beforeImage = beforeImage,
186+
afterImage = afterImage,
187+
handlePosition = handlePosition,
188+
translateX = pan.x,
189+
zoom = zoom,
190+
bitmapRect = bitmapRect,
191+
imageWidth = imageWidth,
192+
imageHeight = imageHeight,
193+
canvasWidthInDp = canvasWidthInDp,
194+
canvasHeightInDp = canvasHeightInDp,
195+
alpha = alpha,
196+
colorFilter = colorFilter,
197+
filterQuality = filterQuality,
198+
content = content
199+
)
200+
}
201+
}
202+
203+
@Composable
204+
private fun ImageLayout(
205+
modifier: Modifier,
206+
constraints: Constraints,
207+
beforeImage: ImageBitmap,
208+
afterImage: ImageBitmap,
209+
handlePosition: Float,
210+
translateX: Float,
211+
zoom: Float,
212+
bitmapRect: IntRect,
213+
imageWidth: Float,
214+
imageHeight: Float,
215+
canvasWidthInDp: Dp,
216+
canvasHeightInDp: Dp,
217+
alpha: Float = DefaultAlpha,
218+
colorFilter: ColorFilter? = null,
219+
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality,
220+
content: @Composable ImageScope.() -> Unit
221+
) {
222+
223+
val density = LocalDensity.current
224+
225+
// Send rectangle of Bitmap drawn to Canvas as bitmapRect, content scale modes like
226+
// crop might crop image from center so Rect can be such as IntRect(250,250,500,500)
227+
228+
// canvasWidthInDp, and canvasHeightInDp are Canvas dimensions coerced to Box size
229+
// that covers Canvas
230+
val imageScopeImpl = ImageScopeImpl(
231+
density = density,
232+
constraints = constraints,
233+
imageWidth = canvasWidthInDp,
234+
imageHeight = canvasHeightInDp,
235+
rect = bitmapRect
236+
)
237+
238+
// width and height params for translating draw position if scaled Image dimensions are
239+
// bigger than Canvas dimensions
240+
ImageImpl(
241+
modifier = modifier.size(canvasWidthInDp, canvasHeightInDp),
242+
beforeImage = beforeImage,
243+
afterImage = afterImage,
244+
handlePosition = handlePosition,
245+
translateX = translateX,
246+
zoom = zoom,
247+
alpha = alpha,
248+
width = imageWidth.toInt(),
249+
height = imageHeight.toInt(),
250+
canvasWidthInDp = canvasWidthInDp,
251+
canvasHeightInDp = canvasHeightInDp,
252+
colorFilter = colorFilter,
253+
filterQuality = filterQuality
254+
)
255+
256+
imageScopeImpl.content()
257+
}
258+
259+
@Composable
260+
private fun ImageImpl(
261+
modifier: Modifier,
262+
beforeImage: ImageBitmap,
263+
afterImage: ImageBitmap,
264+
handlePosition: Float,
265+
translateX: Float,
266+
zoom: Float,
267+
width: Int,
268+
height: Int,
269+
canvasWidthInDp: Dp,
270+
canvasHeightInDp: Dp,
271+
alpha: Float = DefaultAlpha,
272+
colorFilter: ColorFilter? = null,
273+
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality,
274+
) {
275+
val bitmapWidth = beforeImage.width
276+
val bitmapHeight = beforeImage.height
277+
278+
Box {
279+
Canvas(modifier = modifier) {
280+
281+
val canvasWidth = size.width
282+
val canvasHeight = size.height
283+
284+
val touchPosition =
285+
(+width - canvasWidth) / 2f + (handlePosition / zoom).coerceIn(0f, canvasWidth)
286+
.toInt()
287+
288+
// Translate to left or down when Image size is bigger than this canvas.
289+
// ImageSize is bigger when scale modes like Crop is used which enlarges image
290+
// For instance 1000x1000 image can be 1000x2000 for a Canvas with 1000x1000
291+
// so top is translated -500 to draw center of ImageBitmap
292+
translate(
293+
top = (-height + canvasHeight) / 2f,
294+
left = (-width + canvasWidth) / 2f,
295+
) {
296+
297+
val maxX = (size.width * (zoom - 1) / 2f)
298+
val pan = (maxX - translateX) / zoom
299+
300+
println(
301+
"🔥 canvasWidth: $canvasWidth, bitmapWidth: $bitmapWidth, maxX: $maxX\n" +
302+
"touchPosition: $touchPosition, translateX: $translateX, pan: $pan, zoom: $zoom"
303+
)
304+
305+
306+
val srcOffsetX = ((pan + touchPosition) * bitmapWidth / width).toInt()
307+
val dstOffsetX = (pan + touchPosition).toInt()
308+
309+
println("🍏 srcOffsetX: $srcOffsetX, dstOffsetX: $dstOffsetX")
310+
311+
drawImage(
312+
afterImage,
313+
srcSize = IntSize(bitmapWidth, bitmapHeight),
314+
dstSize = IntSize(width, height),
315+
alpha = alpha,
316+
colorFilter = colorFilter,
317+
filterQuality = filterQuality
318+
)
319+
drawImage(
320+
beforeImage,
321+
srcSize = IntSize(bitmapWidth, bitmapHeight),
322+
srcOffset = IntOffset(srcOffsetX, 0),
323+
dstSize = IntSize(width, height),
324+
dstOffset = IntOffset(dstOffsetX, 0),
325+
alpha = alpha,
326+
colorFilter = colorFilter,
327+
filterQuality = filterQuality
328+
)
329+
}
330+
}
331+
332+
Canvas(modifier = Modifier.size(canvasWidthInDp, canvasHeightInDp)) {
333+
334+
val canvasWidth = size.width
335+
336+
val imagePosition = handlePosition.coerceIn(0f, canvasWidth)
337+
338+
drawLine(
339+
Color.White,
340+
strokeWidth = 2.dp.toPx(),
341+
start = Offset(imagePosition, 0f),
342+
end = Offset(imagePosition, size.height)
343+
)
344+
drawCircle(
345+
color = Color.Red,
346+
center = Offset(imagePosition, size.height / 2),
347+
radius = 30f
348+
)
349+
}
350+
}
351+
}

0 commit comments

Comments
 (0)