Skip to content

Commit 1fb6bd9

Browse files
add ImageWithConstraints
1 parent cf3534b commit 1fb6bd9

File tree

1 file changed

+296
-0
lines changed

1 file changed

+296
-0
lines changed
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
package com.smarttoolfactory.image
2+
3+
import androidx.compose.foundation.Canvas
4+
import androidx.compose.foundation.layout.BoxWithConstraints
5+
import androidx.compose.foundation.layout.BoxWithConstraintsScope
6+
import androidx.compose.foundation.layout.size
7+
import androidx.compose.runtime.Composable
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.Size
12+
import androidx.compose.ui.graphics.*
13+
import androidx.compose.ui.graphics.drawscope.DrawScope
14+
import androidx.compose.ui.graphics.drawscope.translate
15+
import androidx.compose.ui.layout.ContentScale
16+
import androidx.compose.ui.platform.LocalDensity
17+
import androidx.compose.ui.semantics.Role
18+
import androidx.compose.ui.semantics.contentDescription
19+
import androidx.compose.ui.semantics.role
20+
import androidx.compose.ui.semantics.semantics
21+
import androidx.compose.ui.unit.*
22+
23+
24+
/**
25+
* A composable that lays out and draws a given [ImageBitmap]. This will attempt to
26+
* size the composable according to the [ImageBitmap]'s given width and height.
27+
*
28+
* [ImageScope] contains [Constraints] since [ImageWithConstraints] uses [BoxWithConstraints]
29+
* also it contains information about canvas width, height and top left position relative
30+
* to parent [BoxWithConstraints].
31+
*
32+
* @param alignment determines where image will be aligned inside [BoxWithConstraints]
33+
* This is observable when bitmap image/width ratio differs from [Canvas] that draws [ImageBitmap]
34+
* @param contentDescription text used by accessibility services to describe what this image
35+
* represents. This should always be provided unless this image is used for decorative purposes,
36+
* and does not represent a meaningful action that a user can take. This text should be
37+
* localized, such as by using [androidx.compose.ui.res.stringResource] or similar
38+
* @param contentScale how image should be scaled inside Canvas to match parent dimensions.
39+
* [ContentScale.Fit] for instance maintains src ratio and scales image to fit inside the parent.
40+
* @param alpha Opacity to be applied to [imageBitmap] from 0.0f to 1.0f representing
41+
* fully transparent to fully opaque respectively
42+
* @param colorFilter ColorFilter to apply to the [imageBitmap] when drawn into the destination
43+
* @param filterQuality Sampling algorithm applied to the [imageBitmap] when it is scaled and drawn
44+
* into the destination. The default is [FilterQuality.Low] which scales using a bilinear
45+
* sampling algorithm
46+
* @param content is a Composable that can be matched at exact position where [imageBitmap] is drawn.
47+
* This is useful for drawing thumbs, cropping or another layout that should match position
48+
* with the image that is scaled is drawn
49+
* @param drawImage flag to draw image on canvas. Some Composables might only require
50+
* the calculation and rectangle bounds of image after scaling but not drawing.
51+
* Composables like image cropper that scales or
52+
* rotates image. Drawing here again have 2 drawings overlap each other.
53+
*/
54+
@Composable
55+
fun ImageWithConstraints(
56+
modifier: Modifier = Modifier,
57+
imageBitmap: ImageBitmap,
58+
alignment: Alignment = Alignment.Center,
59+
contentScale: ContentScale = ContentScale.Fit,
60+
contentDescription: String? = null,
61+
alpha: Float = DefaultAlpha,
62+
colorFilter: ColorFilter? = null,
63+
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality,
64+
drawImage: Boolean = true,
65+
content: @Composable ImageScope.() -> Unit = {}
66+
) {
67+
68+
val semantics = if (contentDescription != null) {
69+
Modifier.semantics {
70+
this.contentDescription = contentDescription
71+
this.role = Role.Image
72+
}
73+
} else {
74+
Modifier
75+
}
76+
77+
BoxWithConstraints(
78+
modifier = modifier
79+
.then(semantics),
80+
contentAlignment = alignment,
81+
) {
82+
83+
val bitmapWidth = imageBitmap.width
84+
val bitmapHeight = imageBitmap.height
85+
86+
val (boxWidth: Int, boxHeight: Int) = getParentSize(bitmapWidth, bitmapHeight)
87+
88+
// Src is Bitmap, Dst is the container(Image) that Bitmap will be displayed
89+
val srcSize = Size(bitmapWidth.toFloat(), bitmapHeight.toFloat())
90+
val dstSize = Size(boxWidth.toFloat(), boxHeight.toFloat())
91+
92+
val scaleFactor = contentScale.computeScaleFactor(srcSize, dstSize)
93+
94+
// Image is the container for bitmap that is located inside Box
95+
// image bounds can be smaller or bigger than its parent based on how it's scaled
96+
val imageWidth = bitmapWidth * scaleFactor.scaleX
97+
val imageHeight = bitmapHeight * scaleFactor.scaleY
98+
99+
val bitmapRect = getScaledBitmapRect(
100+
boxWidth = boxWidth,
101+
boxHeight = boxHeight,
102+
imageWidth = imageWidth,
103+
imageHeight = imageHeight,
104+
bitmapWidth = bitmapWidth,
105+
bitmapHeight = bitmapHeight
106+
)
107+
108+
ImageLayout(
109+
constraints = constraints,
110+
imageBitmap = imageBitmap,
111+
bitmapRect = bitmapRect,
112+
imageWidth = imageWidth,
113+
imageHeight = imageHeight,
114+
boxWidth = boxWidth,
115+
boxHeight = boxHeight,
116+
alpha = alpha,
117+
colorFilter = colorFilter,
118+
filterQuality = filterQuality,
119+
drawImage = drawImage,
120+
content = content
121+
)
122+
}
123+
}
124+
125+
/**
126+
* Get Rectangle of [ImageBitmap] with [bitmapWidth] and [bitmapHeight] that is drawn inside
127+
* Canvas with [imageWidth] and [imageHeight]. [boxWidth] and [boxHeight] belong
128+
* to [BoxWithConstraints] that contains Canvas.
129+
* @param boxWidth width of the parent container
130+
* @param boxHeight height of the parent container
131+
* @param imageWidth width of the [Canvas] that draw [ImageBitmap]
132+
* @param imageHeight height of the [Canvas] that draw [ImageBitmap]
133+
* @param bitmapWidth intrinsic width of the [ImageBitmap]
134+
* @param bitmapHeight intrinsic height of the [ImageBitmap]
135+
* @return [IntRect] that covers [ImageBitmap] bounds. When image [ContentScale] is crop
136+
* this rectangle might return smaller rectangle than actual [ImageBitmap] and left or top
137+
* of the rectangle might be bigger than zero.
138+
*/
139+
private fun getScaledBitmapRect(
140+
boxWidth: Int,
141+
boxHeight: Int,
142+
imageWidth: Float,
143+
imageHeight: Float,
144+
bitmapWidth: Int,
145+
bitmapHeight: Int
146+
): IntRect {
147+
// Get scale of box to width of the image
148+
// We need a rect that contains Bitmap bounds to pass if any child requires it
149+
// For a image with 100x100 px with 300x400 px container and image with crop 400x400px
150+
// So we need to pass top left as 0,50 and size
151+
val scaledBitmapX = boxWidth / imageWidth
152+
val scaledBitmapY = boxHeight / imageHeight
153+
154+
val topLeft = IntOffset(
155+
x = (bitmapWidth * (imageWidth - boxWidth) / imageWidth / 2)
156+
.coerceAtLeast(0f).toInt(),
157+
y = (bitmapHeight * (imageHeight - boxHeight) / imageHeight / 2)
158+
.coerceAtLeast(0f).toInt()
159+
)
160+
161+
val size = IntSize(
162+
width = (bitmapWidth * scaledBitmapX).toInt().coerceAtMost(bitmapWidth),
163+
height = (bitmapHeight * scaledBitmapY).toInt().coerceAtMost(bitmapHeight)
164+
)
165+
166+
return IntRect(offset = topLeft, size = size)
167+
}
168+
169+
/**
170+
* Get [IntSize] of the parent or container that contains [Canvas] that draws [ImageBitmap]
171+
* @param bitmapWidth intrinsic width of the [ImageBitmap]
172+
* @param bitmapHeight intrinsic height of the [ImageBitmap]
173+
* @return size of parent Composable. When Modifier is assigned with fixed or finite size
174+
* they are used, but when any dimension is set to infinity intrinsic dimensions of
175+
* [ImageBitmap] are returned
176+
*/
177+
private fun BoxWithConstraintsScope.getParentSize(
178+
bitmapWidth: Int,
179+
bitmapHeight: Int
180+
): IntSize {
181+
// Check if Composable has fixed size dimensions
182+
val hasBoundedDimens = constraints.hasBoundedWidth && constraints.hasBoundedHeight
183+
// Check if Composable has infinite dimensions
184+
val hasFixedDimens = constraints.hasFixedWidth && constraints.hasFixedHeight
185+
186+
// Box is the parent(BoxWithConstraints) that contains Canvas under the hood
187+
// Canvas aspect ratio or size might not match parent but it's upper bounds are
188+
// what are passed from parent. Canvas cannot be bigger or taller than BoxWithConstraints
189+
val boxWidth: Int = if (hasBoundedDimens || hasFixedDimens) {
190+
constraints.maxWidth
191+
} else {
192+
constraints.minWidth.coerceAtLeast(bitmapWidth)
193+
}
194+
val boxHeight: Int = if (hasBoundedDimens || hasFixedDimens) {
195+
constraints.maxHeight
196+
} else {
197+
constraints.minHeight.coerceAtLeast(bitmapHeight)
198+
}
199+
return IntSize(boxWidth, boxHeight)
200+
}
201+
202+
@Composable
203+
private fun ImageLayout(
204+
constraints: Constraints,
205+
imageBitmap: ImageBitmap,
206+
bitmapRect: IntRect,
207+
imageWidth: Float,
208+
imageHeight: Float,
209+
boxWidth: Int,
210+
boxHeight: Int,
211+
alpha: Float = DefaultAlpha,
212+
colorFilter: ColorFilter? = null,
213+
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality,
214+
drawImage: Boolean = true,
215+
content: @Composable ImageScope.() -> Unit
216+
) {
217+
val density = LocalDensity.current
218+
219+
// Dimensions of canvas that will draw this Bitmap
220+
val canvasWidthInDp: Dp
221+
val canvasHeightInDp: Dp
222+
223+
with(density) {
224+
canvasWidthInDp = imageWidth.coerceAtMost(boxWidth.toFloat()).toDp()
225+
canvasHeightInDp = imageHeight.coerceAtMost(boxHeight.toFloat()).toDp()
226+
}
227+
228+
// Send the not scaled ImageBitmap dimensions which can be larger than Canvas size
229+
// but the one constraint with Canvas size
230+
// because modes like ContentScale.Crop
231+
// which displays center section of the ImageBitmap if it's scaled
232+
// to be bigger than Canvas.
233+
// What user see on screen cannot be bigger than Canvas dimensions
234+
val imageScopeImpl = ImageScopeImpl(
235+
density = density,
236+
constraints = constraints,
237+
imageWidth = canvasWidthInDp,
238+
imageHeight = canvasHeightInDp,
239+
rect = bitmapRect
240+
)
241+
242+
// width and height params for translating draw position if scaled Image dimensions are
243+
// bigger than Canvas dimensions
244+
if (drawImage) {
245+
ImageImpl(
246+
modifier = Modifier.size(canvasWidthInDp, canvasHeightInDp),
247+
imageBitmap = imageBitmap,
248+
alpha = alpha,
249+
width = imageWidth.toInt(),
250+
height = imageHeight.toInt(),
251+
colorFilter = colorFilter,
252+
filterQuality = filterQuality
253+
)
254+
}
255+
256+
imageScopeImpl.content()
257+
}
258+
259+
@Composable
260+
private fun ImageImpl(
261+
modifier: Modifier,
262+
imageBitmap: ImageBitmap,
263+
width: Int,
264+
height: Int,
265+
alpha: Float = DefaultAlpha,
266+
colorFilter: ColorFilter? = null,
267+
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality,
268+
) {
269+
val bitmapWidth = imageBitmap.width
270+
val bitmapHeight = imageBitmap.height
271+
272+
Canvas(modifier = modifier.clipToBounds()) {
273+
274+
val canvasWidth = size.width.toInt()
275+
val canvasHeight = size.height.toInt()
276+
277+
// Translate to left or down when Image size is bigger than this canvas.
278+
// ImageSize is bigger when scale modes like Crop is used which enlarges image
279+
// For instance 1000x1000 image can be 1000x2000 for a Canvas with 1000x1000
280+
// so top is translated -500 to draw center of ImageBitmap
281+
translate(
282+
top = (-height + canvasHeight) / 2f,
283+
left = (-width + canvasWidth) / 2f,
284+
285+
) {
286+
drawImage(
287+
imageBitmap,
288+
srcSize = IntSize(bitmapWidth, bitmapHeight),
289+
dstSize = IntSize(width, height),
290+
alpha = alpha,
291+
colorFilter = colorFilter,
292+
filterQuality = filterQuality
293+
)
294+
}
295+
}
296+
}

0 commit comments

Comments
 (0)