diff --git a/build.gradle.kts b/build.gradle.kts index 47c9222..0123e3f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,4 +5,4 @@ plugins { alias(libs.plugins.android.library) apply false } -version = "0.1.20" +version = "0.2.0" diff --git a/component/src/main/java/com/blipblipcode/component/image/Icon.kt b/component/src/main/java/com/blipblipcode/component/image/Icon.kt index ee87642..d1c46a8 100644 --- a/component/src/main/java/com/blipblipcode/component/image/Icon.kt +++ b/component/src/main/java/com/blipblipcode/component/image/Icon.kt @@ -1,12 +1,17 @@ package com.blipblipcode.component.image +import android.graphics.Bitmap +import android.graphics.drawable.Drawable import androidx.compose.material3.Icon as MaterialIcon import androidx.compose.material3.LocalContentColor import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.vector.ImageVector +import android.graphics.drawable.BitmapDrawable /** * A thin wrapper around Material 3's [MaterialIcon] that adds [tintCap] and [tintStroke] @@ -30,24 +35,22 @@ fun IconComponents( tintStroke: Color? = null, tintStrokeCap: TintStroke = TintStroke.All, ) { - val recolored: ImageVector? = remember(imageVector, tint, tintCap, tintStroke, tintStrokeCap) { - val fillNeedsRebuild = !tintCap.isUndefined && tintCap !== TintCap.All - // Material Icon's `tint` only recolors the fill. To recolor the stroke we always - // need to rebuild the vector, even when tintStrokeCap is All. - val strokeNeedsRebuild = tintStroke != null && !tintStrokeCap.isUndefined + val rebuiltVector = rememberRecoloredImageVector( + imageVector = imageVector, + tint = tint, + tintCap = tintCap, + tintStroke = tintStroke, + tintStrokeCap = tintStrokeCap + ) - when { - fillNeedsRebuild || strokeNeedsRebuild -> - recolorImageVector(imageVector, tint, tintCap, tintStroke, tintStrokeCap) - else -> null - } - } + val needsRebuild = !tintCap.isUndefined && tintCap !== TintCap.All || + tintStroke != null && !tintStrokeCap.isUndefined + val effectiveVector: ImageVector = if (needsRebuild) rebuiltVector else imageVector val effectiveTint: Color = when { tintCap.isUndefined -> Color.Unspecified - recolored != null -> Color.Unspecified + needsRebuild -> Color.Unspecified else -> tint } - val effectiveVector: ImageVector = recolored ?: imageVector MaterialIcon( imageVector = effectiveVector, @@ -55,4 +58,42 @@ fun IconComponents( modifier = modifier, tint = effectiveTint ) -} \ No newline at end of file +} + +/** + * Convenience overload that renders [imageVector] into a [Drawable] (via + * [rememberImageVectorAsDrawable]) and then displays it with Material 3's [MaterialIcon]. + * + * Use this when you need the icon rasterised, e.g. when handing the icon off to APIs that + * only accept [Drawable]. + */ +@Composable +fun IconComponents( + imageVector: ImageVector, + contentDescription: String?, + modifier: Modifier = Modifier, + tint: Color = LocalContentColor.current, + tintCap: TintCap = TintCap.All, + tintStroke: Color? = null, + tintStrokeCap: TintStroke = TintStroke.All, + widthDp: Int = 24, + heightDp: Int = 24, +) { + val drawable: Drawable = rememberImageVectorAsDrawable( + imageVector = imageVector, + tint = tint, + tintCap = tintCap, + tintStroke = tintStroke, + tintStrokeCap = tintStrokeCap, + widthDp = widthDp, + heightDp = heightDp + ) + val bitmap: Bitmap = (drawable as BitmapDrawable).bitmap + val imageBitmap: ImageBitmap = bitmap.asImageBitmap() + MaterialIcon( + painter = BitmapPainter(imageBitmap), + contentDescription = contentDescription, + modifier = modifier, + tint = if (tintCap.isUndefined) Color.Unspecified else tint + ) +} diff --git a/component/src/main/java/com/blipblipcode/component/image/Image.kt b/component/src/main/java/com/blipblipcode/component/image/Image.kt index 6d9edd9..81746e7 100644 --- a/component/src/main/java/com/blipblipcode/component/image/Image.kt +++ b/component/src/main/java/com/blipblipcode/component/image/Image.kt @@ -1,15 +1,20 @@ package com.blipblipcode.component.image +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable import androidx.compose.foundation.Image as FoundationImage import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.DefaultAlpha +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.graphics.DefaultAlpha /** * A wrapper around Compose Foundation's [FoundationImage] that adds [tintCap] and @@ -49,30 +54,22 @@ fun ImageComponents( tintStroke: Color? = null, tintStrokeCap: TintStroke = TintStroke.All, ) { - val recolored: ImageVector? = remember( - imageVector, tint, tintCap, tintStroke, tintStrokeCap - ) { - val needsFillRebuild = tint != null && - !tintCap.isUndefined && - tintCap !== TintCap.All - val needsStrokeRebuild = tintStroke != null && !tintStrokeCap.isUndefined - - if (needsFillRebuild || needsStrokeRebuild) { - recolorImageVector(imageVector, tint ?: Color.Unspecified, tintCap, tintStroke, tintStrokeCap) - } else { - null - } - } + val effectiveTintColor = tint ?: Color.Unspecified + val effectiveVector = rememberRecoloredImageVector( + imageVector = imageVector, + tint = effectiveTintColor, + tintCap = tintCap, + tintStroke = tintStroke, + tintStrokeCap = tintStrokeCap + ) - val strokeNeedsRebuild = tintStroke != null && !tintStrokeCap.isUndefined val effectiveColorFilter: ColorFilter? = when { - strokeNeedsRebuild -> colorFilter + tintStroke != null -> colorFilter tint == null -> colorFilter tintCap.isUndefined -> colorFilter tintCap === TintCap.All -> colorFilter ?: ColorFilter.tint(tint) else -> colorFilter } - val effectiveVector: ImageVector = recolored ?: imageVector FoundationImage( imageVector = effectiveVector, @@ -83,4 +80,56 @@ fun ImageComponents( alpha = alpha, colorFilter = effectiveColorFilter ) -} \ No newline at end of file +} + +/** + * Convenience overload that renders [imageVector] into a [Drawable] (via + * [rememberImageVectorAsDrawable]) and then displays it with Compose Foundation's + * [FoundationImage]. + * + * Use this when you need the image rasterised, e.g. when handing the bitmap off to APIs that + * only accept [Drawable]. + */ +@Composable +fun ImageComponents( + imageVector: ImageVector, + contentDescription: String?, + modifier: Modifier = Modifier, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Fit, + alpha: Float = DefaultAlpha, + colorFilter: ColorFilter? = null, + tint: Color? = null, + tintCap: TintCap = TintCap.Undefined, + tintStroke: Color? = null, + tintStrokeCap: TintStroke = TintStroke.All, + widthDp: Int = 96, + heightDp: Int = 96, +) { + val effectiveTintColor = tint ?: Color.Unspecified + val drawable: Drawable = rememberImageVectorAsDrawable( + imageVector = imageVector, + tint = effectiveTintColor, + tintCap = tintCap, + tintStroke = tintStroke, + tintStrokeCap = tintStrokeCap, + widthDp = widthDp, + heightDp = heightDp + ) + val bitmap: Bitmap = (drawable as BitmapDrawable).bitmap + val imageBitmap: ImageBitmap = bitmap.asImageBitmap() + val effectiveColorFilter: ColorFilter? = when { + colorFilter != null -> colorFilter + tint != null && tintCap === TintCap.All -> ColorFilter.tint(tint) + else -> null + } + FoundationImage( + painter = BitmapPainter(imageBitmap), + contentDescription = contentDescription, + modifier = modifier, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = effectiveColorFilter + ) +} diff --git a/component/src/main/java/com/blipblipcode/component/image/ImageVectorRenderer.kt b/component/src/main/java/com/blipblipcode/component/image/ImageVectorRenderer.kt new file mode 100644 index 0000000..bbca161 --- /dev/null +++ b/component/src/main/java/com/blipblipcode/component/image/ImageVectorRenderer.kt @@ -0,0 +1,103 @@ +package com.blipblipcode.component.image + +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.drawscope.CanvasDrawScope +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.core.graphics.createBitmap +import androidx.core.graphics.drawable.toDrawable + +/** + * Returns an [ImageVector] equivalent to [imageVector] with the fill recoloured by [tint] on + * the layers matched by [tintCap] and the stroke recoloured by [tintStroke] on the layers + * matched by [tintStrokeCap]. Layers (or strokes) that do not match keep their original + * colours. + * + * The recoloured vector is cached in the composition and is rebuilt only when one of the + * inputs changes. + * + * @see recolorImageVector for the rules applied to each [TintCap] / [TintStroke] variant. + */ +@Composable +fun rememberRecoloredImageVector( + imageVector: ImageVector, + tint: Color = Color.Unspecified, + tintCap: TintCap = TintCap.Undefined, + tintStroke: Color? = null, + tintStrokeCap: TintStroke = TintStroke.All, +): ImageVector = remember(imageVector, tint, tintCap, tintStroke, tintStrokeCap) { + recolorImageVector( + source = imageVector, + tint = tint, + tintCap = tintCap, + strokeTint = tintStroke, + strokeTintCap = tintStrokeCap + ) +} + +/** + * Renders [imageVector] into a [Drawable] (a [BitmapDrawable] backed by an ARGB_8888 bitmap) + * sized [widthDp] × [heightDp] in density-independent pixels, applying the same tint rules as + * [rememberRecoloredImageVector]. + * + * The bitmap is created lazily the first time the composition is reached and is rebuilt only + * when one of the inputs changes. + * + * @param widthDp width of the resulting drawable, in dp. + * @param heightDp height of the resulting drawable, in dp. + */ +@Composable +fun rememberImageVectorAsDrawable( + imageVector: ImageVector, + tint: Color = Color.Unspecified, + tintCap: TintCap = TintCap.Undefined, + tintStroke: Color? = null, + tintStrokeCap: TintStroke = TintStroke.All, + widthDp: Int = 96, + heightDp: Int = 96, +): Drawable { + val density = LocalDensity.current + val resources = LocalResources.current + val effectiveVector = rememberRecoloredImageVector( + imageVector = imageVector, + tint = tint, + tintCap = tintCap, + tintStroke = tintStroke, + tintStrokeCap = tintStrokeCap + ) + val painter = rememberVectorPainter(effectiveVector) + + val bitmap = remember(effectiveVector, density, widthDp, heightDp) { + val widthPx = with(density) { widthDp.dp.toPx().toInt().coerceAtLeast(1) } + val heightPx = with(density) { heightDp.dp.toPx().toInt().coerceAtLeast(1) } + val androidBitmap = createBitmap(widthPx, heightPx) + val imageBitmap = androidBitmap.asImageBitmap() + val canvas = androidx.compose.ui.graphics.Canvas(imageBitmap) + val size = Size(widthPx.toFloat(), heightPx.toFloat()) + val drawScope = CanvasDrawScope() + + drawScope.draw( + density = density, + layoutDirection = LayoutDirection.Ltr, + canvas = canvas, + size = size + ) { + with(painter) { + draw(size) + } + } + androidBitmap + } + + return remember(bitmap) { bitmap.toDrawable(resources) } +}