Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
c6d7056
feat: add tintCap support for Icon and Image components
LeandroLCD Aug 14, 2026
f722b79
ci: add release pipeline and fix artifactId
LeandroLCD Aug 14, 2026
7efa511
fix(tintCap): resolve fixture overlaps and stabilise UI tests
LeandroLCD Aug 14, 2026
a9d76b1
refactor(image): rename Icon → IconComponents and Image → ImageCompon…
LeandroLCD Aug 14, 2026
4be663a
test(ui): add instrumented UI tests for Linear, Slider and RangeSlider
LeandroLCD Aug 14, 2026
034af79
ci: split pipeline into ci + release and protect master
LeandroLCD Aug 14, 2026
7f13738
remove branch protection configuration and update README documentation
LeandroLCD Aug 14, 2026
32cb4d2
ci: unify Gradle cache key across ci + release workflows
LeandroLCD Aug 14, 2026
9296be0
add pipeline discord notification
LeandroLCD Aug 14, 2026
d32d7a6
chore: bump version to 0.1.1
LeandroLCD Aug 14, 2026
b4c9d27
Merge branch 'master' into develop
LeandroLCD Aug 14, 2026
d8a76c5
ci: add branch argument to release workflow
LeandroLCD Aug 14, 2026
d87e082
Merge remote-tracking branch 'origin/develop' into develop
LeandroLCD Aug 14, 2026
f9049e6
Merge branch 'master' into develop
LeandroLCD Aug 14, 2026
b5063ae
chore: update version to 0.1.11
LeandroLCD Aug 14, 2026
a2f5fbd
chore: simplify Gradle cache configuration and standardize keys in Gi…
LeandroLCD Aug 14, 2026
b66e86f
chore: simplify Gradle cache configuration and standardize keys in Gi…
LeandroLCD Aug 14, 2026
e5e41e5
feat: add `tintStroke` support to `IconComponents` and `ImageComponents`
LeandroLCD Aug 14, 2026
221c7de
chore: suppress KDocUnresolvedReference warning in ImageVectorTinterTest
LeandroLCD Aug 14, 2026
79fd466
feat: add TintStroke for selective layer stroke tinting in IconCompon…
LeandroLCD Aug 14, 2026
bd47092
chore: upgrade version to 0.1.20
LeandroLCD Aug 14, 2026
d381c23
Merge branch 'master' into develop
LeandroLCD Aug 14, 2026
1ac3f16
chore: upgrade version to 0.1.20
LeandroLCD Aug 14, 2026
7f99f0f
feat: add rememberRecoloredImageVector and rememberImageVectorAsDrawa…
LeandroLCD Aug 18, 2026
cc5f7a1
chore: bump version to 0.2.0
LeandroLCD Aug 18, 2026
c2c2d7b
Merge branch 'master' into develop
LeandroLCD Aug 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ plugins {
alias(libs.plugins.android.library) apply false
}

version = "0.1.20"
version = "0.2.0"
71 changes: 56 additions & 15 deletions component/src/main/java/com/blipblipcode/component/image/Icon.kt
Original file line number Diff line number Diff line change
@@ -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]
Expand All @@ -30,29 +35,65 @@ 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,
contentDescription = contentDescription,
modifier = modifier,
tint = effectiveTint
)
}
}

/**
* 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
)
}
89 changes: 69 additions & 20 deletions component/src/main/java/com/blipblipcode/component/image/Image.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -83,4 +80,56 @@ fun ImageComponents(
alpha = alpha,
colorFilter = effectiveColorFilter
)
}
}

/**
* 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
)
}
Original file line number Diff line number Diff line change
@@ -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) }
}