chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* ImageToolbox is an image editor for android
|
||||
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
* You should have received a copy of the Apache License
|
||||
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.image.toolbox.library)
|
||||
alias(libs.plugins.image.toolbox.compose)
|
||||
}
|
||||
|
||||
android.namespace = "com.t8rin.image"
|
||||
|
||||
dependencies {
|
||||
implementation(projects.lib.gesture)
|
||||
implementation(libs.androidx.palette.ktx)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* ImageToolbox is an image editor for android
|
||||
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
* You should have received a copy of the Apache License
|
||||
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
|
||||
*/
|
||||
|
||||
package com.t8rin.image
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.ui.graphics.Canvas
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntRect
|
||||
|
||||
|
||||
/**
|
||||
* Receiver scope being used by the children parameter of [ImageWithConstraints]
|
||||
*/
|
||||
@Stable
|
||||
interface ImageScope {
|
||||
/**
|
||||
* The constraints given by the parent layout in pixels.
|
||||
*
|
||||
* Use [minWidth], [maxWidth], [minHeight] or [maxHeight] if you need value in [Dp].
|
||||
*/
|
||||
val constraints: Constraints
|
||||
|
||||
/**
|
||||
* The minimum width in [Dp].
|
||||
*
|
||||
* @see constraints for the values in pixels.
|
||||
*/
|
||||
val minWidth: Dp
|
||||
|
||||
/**
|
||||
* The maximum width in [Dp].
|
||||
*
|
||||
* @see constraints for the values in pixels.
|
||||
*/
|
||||
val maxWidth: Dp
|
||||
|
||||
/**
|
||||
* The minimum height in [Dp].
|
||||
*
|
||||
* @see constraints for the values in pixels.
|
||||
*/
|
||||
val minHeight: Dp
|
||||
|
||||
/**
|
||||
* The maximum height in [Dp].
|
||||
*
|
||||
* @see constraints for the values in pixels.
|
||||
*/
|
||||
val maxHeight: Dp
|
||||
|
||||
/**
|
||||
* Width of area inside BoxWithConstraints that is scaled based on [ContentScale]
|
||||
* This is width of the [Canvas] draw [ImageBitmap]
|
||||
*/
|
||||
val imageWidth: Dp
|
||||
|
||||
/**
|
||||
* Height of area inside BoxWithConstraints that is scaled based on [ContentScale]
|
||||
* This is height of the [Canvas] draw [ImageBitmap]
|
||||
*/
|
||||
val imageHeight: Dp
|
||||
|
||||
/**
|
||||
* [IntRect] that covers boundaries of [ImageBitmap]
|
||||
*/
|
||||
val rect: IntRect
|
||||
}
|
||||
|
||||
internal data class ImageScopeImpl(
|
||||
private val density: Density,
|
||||
override val constraints: Constraints,
|
||||
override val imageWidth: Dp,
|
||||
override val imageHeight: Dp,
|
||||
override val rect: IntRect,
|
||||
) : ImageScope {
|
||||
|
||||
override val minWidth: Dp get() = with(density) { constraints.minWidth.toDp() }
|
||||
|
||||
override val maxWidth: Dp
|
||||
get() = with(density) {
|
||||
if (constraints.hasBoundedWidth) constraints.maxWidth.toDp() else Dp.Infinity
|
||||
}
|
||||
|
||||
override val minHeight: Dp get() = with(density) { constraints.minHeight.toDp() }
|
||||
|
||||
override val maxHeight: Dp
|
||||
get() = with(density) {
|
||||
if (constraints.hasBoundedHeight) constraints.maxHeight.toDp() else Dp.Infinity
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
/*
|
||||
* ImageToolbox is an image editor for android
|
||||
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
* You should have received a copy of the Apache License
|
||||
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
|
||||
*/
|
||||
|
||||
package com.t8rin.image
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Canvas
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.DefaultAlpha
|
||||
import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.translate
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntRect
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import com.t8rin.image.util.getParentSize
|
||||
import com.t8rin.image.util.getScaledBitmapRect
|
||||
|
||||
|
||||
/**
|
||||
* A composable that lays out and draws a given [ImageBitmap]. This will attempt to
|
||||
* size the composable according to the [ImageBitmap]'s given width and height. However, an
|
||||
* optional [Modifier] parameter can be provided to adjust sizing or draw additional content (ex.
|
||||
* background). Any unspecified dimension will leverage the [ImageBitmap]'s size as a minimum
|
||||
* constraint.
|
||||
*
|
||||
* [ImageScope] returns constraints, width and height of the drawing area based on [contentScale]
|
||||
* and rectangle of [imageBitmap] drawn. When a bitmap is displayed scaled to fit area of Composable
|
||||
* space used for drawing image is represented with [ImageScope.imageWidth] and
|
||||
* [ImageScope.imageHeight].
|
||||
*
|
||||
* When we display a bitmap 1000x1000px with [ContentScale.Crop] if it's cropped to 500x500px
|
||||
* [ImageScope.rect] returns `IntRect(250,250,750,750)`.
|
||||
*
|
||||
* @param alignment determines where image will be aligned inside [BoxWithConstraints]
|
||||
* This is observable when bitmap image/width ratio differs from [Canvas] that draws [ImageBitmap]
|
||||
* @param contentDescription text used by accessibility services to describe what this image
|
||||
* represents. This should always be provided unless this image is used for decorative purposes,
|
||||
* and does not represent a meaningful action that a user can take. This text should be
|
||||
* localized, such as by using [androidx.compose.ui.res.stringResource] or similar
|
||||
* @param contentScale how image should be scaled inside Canvas to match parent dimensions.
|
||||
* [ContentScale.Fit] for instance maintains src ratio and scales image to fit inside the parent.
|
||||
* @param alpha Opacity to be applied to [imageBitmap] from 0.0f to 1.0f representing
|
||||
* fully transparent to fully opaque respectively
|
||||
* @param colorFilter ColorFilter to apply to the [imageBitmap] when drawn into the destination
|
||||
* @param filterQuality Sampling algorithm applied to the [imageBitmap] when it is scaled and drawn
|
||||
* into the destination. The default is [FilterQuality.Low] which scales using a bilinear
|
||||
* sampling algorithm
|
||||
* @param content is a Composable that can be matched at exact position where [imageBitmap] is drawn.
|
||||
* This is useful for drawing thumbs, cropping or another layout that should match position
|
||||
* with the image that is scaled is drawn
|
||||
* @param drawImage flag to draw image on canvas. Some Composables might only require
|
||||
* the calculation and rectangle bounds of image after scaling but not drawing.
|
||||
* Composables like image cropper that scales or
|
||||
* rotates image. Drawing here again have 2 drawings overlap each other.
|
||||
*/
|
||||
@Composable
|
||||
fun ImageWithConstraints(
|
||||
modifier: Modifier = Modifier,
|
||||
imageBitmap: ImageBitmap,
|
||||
alignment: Alignment = Alignment.Center,
|
||||
contentScale: ContentScale = ContentScale.Fit,
|
||||
contentDescription: String? = null,
|
||||
alpha: Float = DefaultAlpha,
|
||||
colorFilter: ColorFilter? = null,
|
||||
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality,
|
||||
drawImage: Boolean = true,
|
||||
content: @Composable ImageScope.() -> Unit = {}
|
||||
) {
|
||||
|
||||
val semantics = if (contentDescription != null) {
|
||||
Modifier.semantics {
|
||||
this.contentDescription = contentDescription
|
||||
this.role = Role.Image
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
.then(semantics),
|
||||
contentAlignment = alignment,
|
||||
) {
|
||||
|
||||
val bitmapWidth = imageBitmap.width
|
||||
val bitmapHeight = imageBitmap.height
|
||||
|
||||
val (boxWidth: Int, boxHeight: Int) = getParentSize(bitmapWidth, bitmapHeight)
|
||||
|
||||
// Src is Bitmap, Dst is the container(Image) that Bitmap will be displayed
|
||||
val srcSize = Size(bitmapWidth.toFloat(), bitmapHeight.toFloat())
|
||||
val dstSize = Size(boxWidth.toFloat(), boxHeight.toFloat())
|
||||
|
||||
val scaleFactor = contentScale.computeScaleFactor(srcSize, dstSize)
|
||||
|
||||
// Image is the container for bitmap that is located inside Box
|
||||
// image bounds can be smaller or bigger than its parent based on how it's scaled
|
||||
val imageWidth = bitmapWidth * scaleFactor.scaleX
|
||||
val imageHeight = bitmapHeight * scaleFactor.scaleY
|
||||
|
||||
val bitmapRect = getScaledBitmapRect(
|
||||
boxWidth = boxWidth,
|
||||
boxHeight = boxHeight,
|
||||
imageWidth = imageWidth,
|
||||
imageHeight = imageHeight,
|
||||
bitmapWidth = bitmapWidth,
|
||||
bitmapHeight = bitmapHeight
|
||||
)
|
||||
|
||||
ImageLayout(
|
||||
constraints = constraints,
|
||||
imageBitmap = imageBitmap,
|
||||
bitmapRect = bitmapRect,
|
||||
imageWidth = imageWidth,
|
||||
imageHeight = imageHeight,
|
||||
boxWidth = boxWidth,
|
||||
boxHeight = boxHeight,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality,
|
||||
drawImage = drawImage,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImageLayout(
|
||||
constraints: Constraints,
|
||||
imageBitmap: ImageBitmap,
|
||||
bitmapRect: IntRect,
|
||||
imageWidth: Float,
|
||||
imageHeight: Float,
|
||||
boxWidth: Int,
|
||||
boxHeight: Int,
|
||||
alpha: Float = DefaultAlpha,
|
||||
colorFilter: ColorFilter? = null,
|
||||
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality,
|
||||
drawImage: Boolean = true,
|
||||
content: @Composable ImageScope.() -> Unit
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
|
||||
// Dimensions of canvas that will draw this Bitmap
|
||||
val canvasWidthInDp: Dp
|
||||
val canvasHeightInDp: Dp
|
||||
|
||||
with(density) {
|
||||
canvasWidthInDp = imageWidth.coerceAtMost(boxWidth.toFloat()).toDp()
|
||||
canvasHeightInDp = imageHeight.coerceAtMost(boxHeight.toFloat()).toDp()
|
||||
}
|
||||
|
||||
// Send rectangle of Bitmap drawn to Canvas as bitmapRect, content scale modes like
|
||||
// crop might crop image from center so Rect can be such as IntRect(250,250,500,500)
|
||||
|
||||
// canvasWidthInDp, and canvasHeightInDp are Canvas dimensions coerced to Box size
|
||||
// that covers Canvas
|
||||
val imageScopeImpl = ImageScopeImpl(
|
||||
density = density,
|
||||
constraints = constraints,
|
||||
imageWidth = canvasWidthInDp,
|
||||
imageHeight = canvasHeightInDp,
|
||||
rect = bitmapRect
|
||||
)
|
||||
|
||||
// width and height params for translating draw position if scaled Image dimensions are
|
||||
// bigger than Canvas dimensions
|
||||
if (drawImage) {
|
||||
ImageImpl(
|
||||
modifier = Modifier.size(canvasWidthInDp, canvasHeightInDp),
|
||||
imageBitmap = imageBitmap,
|
||||
alpha = alpha,
|
||||
width = imageWidth.toInt(),
|
||||
height = imageHeight.toInt(),
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality
|
||||
)
|
||||
}
|
||||
|
||||
imageScopeImpl.content()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImageImpl(
|
||||
modifier: Modifier,
|
||||
imageBitmap: ImageBitmap,
|
||||
width: Int,
|
||||
height: Int,
|
||||
alpha: Float = DefaultAlpha,
|
||||
colorFilter: ColorFilter? = null,
|
||||
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality,
|
||||
) {
|
||||
val bitmapWidth = imageBitmap.width
|
||||
val bitmapHeight = imageBitmap.height
|
||||
|
||||
Canvas(modifier = modifier.clipToBounds()) {
|
||||
|
||||
val canvasWidth = size.width.toInt()
|
||||
val canvasHeight = size.height.toInt()
|
||||
|
||||
// Translate to left or down when Image size is bigger than this canvas.
|
||||
// ImageSize is bigger when scale modes like Crop is used which enlarges image
|
||||
// For instance 1000x1000 image can be 1000x2000 for a Canvas with 1000x1000
|
||||
// so top is translated -500 to draw center of ImageBitmap
|
||||
translate(
|
||||
top = (-height + canvasHeight) / 2f,
|
||||
left = (-width + canvasWidth) / 2f,
|
||||
|
||||
) {
|
||||
drawImage(
|
||||
imageBitmap,
|
||||
srcSize = IntSize(bitmapWidth, bitmapHeight),
|
||||
dstSize = IntSize(width, height),
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* ImageToolbox is an image editor for android
|
||||
* Copyright (c) 2026 T8RIN (Malik Mukhametzyanov)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
* You should have received a copy of the Apache License
|
||||
* along with this program. If not, see <http://www.apache.org/licenses/LICENSE-2.0>.
|
||||
*/
|
||||
|
||||
package com.t8rin.image.util
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.BoxWithConstraintsScope
|
||||
import androidx.compose.ui.graphics.Canvas
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntRect
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
|
||||
/**
|
||||
* Get Rectangle of [ImageBitmap] with [bitmapWidth] and [bitmapHeight] that is drawn inside
|
||||
* Canvas with [imageWidth] and [imageHeight]. [boxWidth] and [boxHeight] belong
|
||||
* to [BoxWithConstraints] that contains Canvas.
|
||||
* @param boxWidth width of the parent container
|
||||
* @param boxHeight height of the parent container
|
||||
* @param imageWidth width of the [Canvas] that draws [ImageBitmap]
|
||||
* @param imageHeight height of the [Canvas] that draws [ImageBitmap]
|
||||
* @param bitmapWidth intrinsic width of the [ImageBitmap]
|
||||
* @param bitmapHeight intrinsic height of the [ImageBitmap]
|
||||
* @return [IntRect] that covers [ImageBitmap] bounds. When image [ContentScale] is crop
|
||||
* this rectangle might return smaller rectangle than actual [ImageBitmap] and left or top
|
||||
* of the rectangle might be bigger than zero.
|
||||
*/
|
||||
internal fun getScaledBitmapRect(
|
||||
boxWidth: Int,
|
||||
boxHeight: Int,
|
||||
imageWidth: Float,
|
||||
imageHeight: Float,
|
||||
bitmapWidth: Int,
|
||||
bitmapHeight: Int
|
||||
): IntRect {
|
||||
// Get scale of box to width of the image
|
||||
// We need a rect that contains Bitmap bounds to pass if any child requires it
|
||||
// For a image with 100x100 px with 300x400 px container and image with crop 400x400px
|
||||
// So we need to pass top left as 0,50 and size
|
||||
val scaledBitmapX = boxWidth / imageWidth
|
||||
val scaledBitmapY = boxHeight / imageHeight
|
||||
|
||||
val topLeft = IntOffset(
|
||||
x = (bitmapWidth * (imageWidth - boxWidth) / imageWidth / 2)
|
||||
.coerceAtLeast(0f).toInt(),
|
||||
y = (bitmapHeight * (imageHeight - boxHeight) / imageHeight / 2)
|
||||
.coerceAtLeast(0f).toInt()
|
||||
)
|
||||
|
||||
val size = IntSize(
|
||||
width = (bitmapWidth * scaledBitmapX).toInt().coerceAtMost(bitmapWidth),
|
||||
height = (bitmapHeight * scaledBitmapY).toInt().coerceAtMost(bitmapHeight)
|
||||
)
|
||||
|
||||
return IntRect(offset = topLeft, size = size)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get [IntSize] of the parent or container that contains [Canvas] that draws [ImageBitmap]
|
||||
* @param bitmapWidth intrinsic width of the [ImageBitmap]
|
||||
* @param bitmapHeight intrinsic height of the [ImageBitmap]
|
||||
* @return size of parent Composable. When Modifier is assigned with fixed or finite size
|
||||
* they are used, but when any dimension is set to infinity intrinsic dimensions of
|
||||
* [ImageBitmap] are returned
|
||||
*/
|
||||
internal fun BoxWithConstraintsScope.getParentSize(
|
||||
bitmapWidth: Int,
|
||||
bitmapHeight: Int
|
||||
): IntSize {
|
||||
// Check if Composable has fixed size dimensions
|
||||
val hasBoundedDimens = constraints.hasBoundedWidth && constraints.hasBoundedHeight
|
||||
// Check if Composable has infinite dimensions
|
||||
val hasFixedDimens = constraints.hasFixedWidth && constraints.hasFixedHeight
|
||||
|
||||
// Box is the parent(BoxWithConstraints) that contains Canvas under the hood
|
||||
// Canvas aspect ratio or size might not match parent but it's upper bounds are
|
||||
// what are passed from parent. Canvas cannot be bigger or taller than BoxWithConstraints
|
||||
val boxWidth: Int = if (hasBoundedDimens || hasFixedDimens) {
|
||||
constraints.maxWidth
|
||||
} else {
|
||||
constraints.minWidth.coerceAtLeast(bitmapWidth)
|
||||
}
|
||||
val boxHeight: Int = if (hasBoundedDimens || hasFixedDimens) {
|
||||
constraints.maxHeight
|
||||
} else {
|
||||
constraints.minHeight.coerceAtLeast(bitmapHeight)
|
||||
}
|
||||
return IntSize(boxWidth, boxHeight)
|
||||
}
|
||||
Reference in New Issue
Block a user