Skip to content

Instantly share code, notes, and snippets.

@iTaysonLab
Last active March 26, 2025 00:47
Show Gist options
  • Save iTaysonLab/c6a442f8b6bd243dc647a4af6c2f384d to your computer and use it in GitHub Desktop.
Save iTaysonLab/c6a442f8b6bd243dc647a4af6c2f384d to your computer and use it in GitHub Desktop.
Multiplatform Coil Blur Transformer imitating iOS blurring materials
package bruhcollective.itaysonlab.libvibrancy
import android.graphics.Bitmap
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Paint
import androidx.core.graphics.applyCanvas
import bruhcollective.itaysonlab.libvibrancy.VibrancyMaterial
import coil3.size.Size
import coil3.transform.Transformation
import com.google.android.renderscript.Toolkit
actual fun createVibrancyTransformation(material: VibrancyMaterial): Transformation {
return ToolkitVibrancyTransformation(material)
}
private class ToolkitVibrancyTransformation(
private val material: VibrancyMaterial
) : Transformation() {
private val saturationMatrix = if (material.saturation != 1f) {
allocateSaturationColorMatrixToolkit(material)
} else {
Toolkit.identityMatrix
}
override val cacheKey: String = "vibrantBlur-${material.hashCode()}"
override suspend fun transform(input: Bitmap, size: Size): Bitmap {
val blurred = Toolkit.blur(input, material.radius.coerceIn(0..25))
// 2. Saturation
val saturated = if (material.saturation != 1f) {
Toolkit.colorMatrix(blurred, saturationMatrix).also { blurred.recycle() }
// blurred
} else {
blurred
}
// 3. Apply overlay
return saturated.applyCanvas {
for (overlay in material.overlays) {
drawPaint(
Paint().apply {
color = Color(overlay.color)
blendMode = overlay.blendMode
}.asFrameworkPaint()
)
}
}
}
// Following lines were present in the original Coil's BlurTransformation that was used as a foundation
// Probably can be removed without any issues
override fun equals(other: Any?): Boolean {
if (this === other) return true
return other is VibrantBlurTransformation && material == other.material
}
override fun hashCode(): Int {
return material.hashCode()
}
override fun toString(): String {
return "BlurTransformation(material=$material)"
}
}
package bruhcollective.itaysonlab.libvibrancy
import coil3.transform.Transformation
expect fun createVibrancyTransformation(material: VibrancyMaterial): Transformation
package bruhcollective.itaysonlab.libvibrancy
import coil3.Bitmap
import coil3.size.Size
import coil3.transform.Transformation
import org.jetbrains.skia.Canvas
import org.jetbrains.skia.ColorFilter
import org.jetbrains.skia.ColorMatrix
import org.jetbrains.skia.ColorType
import org.jetbrains.skia.FilterTileMode
import org.jetbrains.skia.Image
import org.jetbrains.skia.ImageFilter
import org.jetbrains.skia.Paint
import org.jetbrains.skia.impl.Managed
import org.jetbrains.skia.impl.use
actual fun createVibrancyTransformation(material: VibrancyMaterial): Transformation {
return SkiaVibrancyTransformation(material)
}
private class SkiaVibrancyTransformation(
private val material: VibrancyMaterial
) : Transformation() {
private var saturationMatrix = ColorMatrix(*allocateSaturationColorMatrixSkia(material.saturation))
private var managedCacheIdx = 0
private var managedCache = arrayOfNulls<Managed>(getFilterChainManagedSize())
private fun Managed.registerGc() {
managedCache[managedCacheIdx] = this
managedCacheIdx++
}
private fun ImageFilter.register() = apply { registerGc() }
private fun ColorFilter.register() = apply { registerGc() }
private fun runGc() {
for (i in managedCache.indices) {
managedCache[i] = managedCache[i]?.let { m ->
if (m.isClosed.not()) m.close()
null
}
}
managedCacheIdx = 0
}
override val cacheKey: String = "skiaVibrancy-${material.radius},${material.saturation},${
material.overlays.joinToString(separator = ",") { o -> "${o.color}-${o.blendMode}" }
}"
override suspend fun transform(input: Bitmap, size: Size): Bitmap {
// println("~transform ${input.isImmutable} ${input.alphaType} ${input.colorType} ${input.imageInfo}")
Canvas(input).use { canvas ->
Image.makeFromBitmap(input).use { image ->
Paint().use { paint ->
paint.imageFilter = createFilterChain(image)
canvas.drawPaint(paint)
}
}
}
runGc()
return input
}
// OverlayFilters... [ SaturationFilter [ BlurFilter [ ImageFilter ] ] ]
private fun getFilterChainManagedSize(): Int {
return (material.overlays.size * 2) + // ImageFilter + ColorFilter
(if (material.saturation != 1f) 2 else 0) + // ImageFilter + ColorFilter
2 // ImageFilter[image] + ImageFilter[makeBlur]
}
private fun createFilterChain(image: Image): ImageFilter {
var previousFilter: ImageFilter? = null
// Base Image
previousFilter = ImageFilter.makeImage(image).register()
// Blur
previousFilter = ImageFilter.makeBlur(
sigmaX = material.radius.toFloat(),
sigmaY = material.radius.toFloat(),
mode = FilterTileMode.CLAMP,
input = previousFilter
).register()
// Color Filter - Saturation
if (material.saturation != 1f && (image.colorType == ColorType.RGBA_8888 || image.colorType == ColorType.BGRA_8888)) {
previousFilter = ImageFilter.makeColorFilter(
f = ColorFilter.makeMatrix(saturationMatrix).register(),
input = previousFilter,
crop = null
).register()
}
for (overlay in material.overlays) {
previousFilter = ImageFilter.makeColorFilter(
f = ColorFilter.makeBlend(
color = overlay.color,
mode = overlay.blendMode.toSkia()
).register(), input = previousFilter, crop = null
).register()
}
return previousFilter
}
}
package bruhcollective.itaysonlab.libvibrancy
import androidx.compose.ui.graphics.BlendMode
internal fun BlendMode.toSkia() = when (this) {
BlendMode.Clear -> org.jetbrains.skia.BlendMode.CLEAR
BlendMode.Src -> org.jetbrains.skia.BlendMode.SRC
BlendMode.Dst -> org.jetbrains.skia.BlendMode.DST
BlendMode.SrcOver -> org.jetbrains.skia.BlendMode.SRC_OVER
BlendMode.DstOver -> org.jetbrains.skia.BlendMode.DST_OVER
BlendMode.SrcIn -> org.jetbrains.skia.BlendMode.SRC_IN
BlendMode.DstIn -> org.jetbrains.skia.BlendMode.DST_IN
BlendMode.SrcOut -> org.jetbrains.skia.BlendMode.SRC_OUT
BlendMode.DstOut -> org.jetbrains.skia.BlendMode.DST_OUT
BlendMode.SrcAtop -> org.jetbrains.skia.BlendMode.SRC_ATOP
BlendMode.DstAtop -> org.jetbrains.skia.BlendMode.DST_ATOP
BlendMode.Xor -> org.jetbrains.skia.BlendMode.XOR
BlendMode.Plus -> org.jetbrains.skia.BlendMode.PLUS
BlendMode.Modulate -> org.jetbrains.skia.BlendMode.MODULATE
BlendMode.Screen -> org.jetbrains.skia.BlendMode.SCREEN
BlendMode.Overlay -> org.jetbrains.skia.BlendMode.OVERLAY
BlendMode.Darken -> org.jetbrains.skia.BlendMode.DARKEN
BlendMode.Lighten -> org.jetbrains.skia.BlendMode.LIGHTEN
BlendMode.ColorDodge -> org.jetbrains.skia.BlendMode.COLOR_DODGE
BlendMode.ColorBurn -> org.jetbrains.skia.BlendMode.COLOR_BURN
BlendMode.Hardlight -> org.jetbrains.skia.BlendMode.HARD_LIGHT
BlendMode.Softlight -> org.jetbrains.skia.BlendMode.SOFT_LIGHT
BlendMode.Difference -> org.jetbrains.skia.BlendMode.DIFFERENCE
BlendMode.Exclusion -> org.jetbrains.skia.BlendMode.EXCLUSION
BlendMode.Multiply -> org.jetbrains.skia.BlendMode.MULTIPLY
BlendMode.Hue -> org.jetbrains.skia.BlendMode.HUE
BlendMode.Saturation -> org.jetbrains.skia.BlendMode.SATURATION
BlendMode.Color -> org.jetbrains.skia.BlendMode.COLOR
BlendMode.Luminosity -> org.jetbrains.skia.BlendMode.LUMINOSITY
// Always fallback to default blendmode of src over
else -> org.jetbrains.skia.BlendMode.SRC_OVER
}
package bruhcollective.itaysonlab.libvibrancy
import androidx.annotation.ColorInt
import androidx.compose.ui.graphics.BlendMode
/**
* Defines a vibrancy material.
*
* @param radius blurring radius
* @param overlays a list of overlays to be applied on top of blurred image.
* @param saturation a saturation modifier (1f == 0%, 0f == -100%, 2f == +100%)
*/
data class VibrancyMaterial (
val radius: Int = 20,
val overlays: List<VibrancyColor>,
val saturation: Float = 1f
) {
/*val saturationColorMatrix: FloatArray by lazy {
if (saturation != 1f) {
allocateSaturationColorMatrix(saturation)
} else {
Toolkit.identityMatrix
}
}*/
data class VibrancyColor (
@ColorInt val color: Int,
val blendMode: BlendMode = BlendMode.SrcOver
)
// Source: Apple's iOS UI Kit for Sketch, Plus Dark changed to Plus
companion object {
// === DARK ===
// Thick: 50 Blur + #000000 60%
val DarkThick = VibrancyMaterial(radius = 50, overlays = listOf(VibrancyColor(0x99000000.toInt())), saturation = 1f)
// Regular: 50 Blur + #000000 41%
val DarkRegular = VibrancyMaterial(radius = 50, overlays = listOf(VibrancyColor(0x69000000.toInt())), saturation = 1f)
// Thin: 50 Blur + #000000 26%
val DarkThin = VibrancyMaterial(radius = 50, overlays = listOf(VibrancyColor(0x42000000.toInt())), saturation = 1f)
// Ultra Thin: 50 Blur + #000000 10%
val DarkUltraThin = VibrancyMaterial(radius = 50, overlays = listOf(VibrancyColor(0x1A000000.toInt())), saturation = 1f)
// Chroma Tab: 20 Blur + 30% Saturation + #161616 80%
val DarkChromaTab = VibrancyMaterial(radius = 20, overlays = listOf(VibrancyColor(0xCC161616.toInt())), saturation = 1.3f)
// Chroma Nav: 20 Blur + 30% Saturation + #1D1D1D 94%
val DarkChromaNav = VibrancyMaterial(radius = 20, overlays = listOf(VibrancyColor(0xEF1D1D1D.toInt())), saturation = 1.3f)
// === LIGHT ===
// Thick: 50 Blur + #FFFFFF 84% Normal + #FFFFFF 34% Plus Darken
val LightThick = VibrancyMaterial(radius = 50, overlays = listOf(VibrancyColor(0xD6FFFFFF.toInt()), VibrancyColor(0x57FFFFFF.toInt(), blendMode = BlendMode.Plus)), saturation = 1f)
// Regular: 50 Blur + 21% Saturation + #FFFFFF 60% Normal + #FFFFFF 25% Plus Darken
val LightRegular = VibrancyMaterial(radius = 50, overlays = listOf(VibrancyColor(0x99FFFFFF.toInt()), VibrancyColor(0x40FFFFFF.toInt(), blendMode = BlendMode.Plus)), saturation = 1.21f)
// Thin: 50 Blur + 45% Saturation + #FFFFFF 40% Normal + #FFFFFF 5% Normal
val LightThin = VibrancyMaterial(radius = 50, overlays = listOf(VibrancyColor(0x66FFFFFF.toInt()), VibrancyColor(0x0DFFFFFF.toInt())), saturation = 1.45f)
// Ultra Thin: 30 Blur + #FFFFFF 12% Normal + #FFFFFF 11% Normal
val LightUltraThin = VibrancyMaterial(radius = 30, overlays = listOf(VibrancyColor(0x1FFFFFFF.toInt()), VibrancyColor(0x1CFFFFFF.toInt())), saturation = 1f)
// Chroma Tab: 20 Blur + 100% Saturation + #F7F7F7 80%
val LightChromaTab = VibrancyMaterial(radius = 20, overlays = listOf(VibrancyColor(0xCCF7F7F7.toInt())), saturation = 2f)
// Chroma Nav: 20 Blur + 100% Saturation + #F9F9F9 94%
val LightChromaNav = VibrancyMaterial(radius = 20, overlays = listOf(VibrancyColor(0xEFF9F9F9.toInt())), saturation = 2f)
}
}
// Saturation color matrix for RenderScript Toolkit
@Suppress("LocalVariableName")
internal fun allocateSaturationColorMatrixToolkit(factor: Float): FloatArray {
val invSat = 1 - factor
val R = 0.213f * invSat
val G = 0.715f * invSat
val B = 0.072f * invSat
return floatArrayOf(
R + factor, R, R, 0f,
G, G + factor, G, 0f,
B, B, B + factor, 0f,
0f, 0f, 0f, 1f
)
}
// Saturation color matrix for Skia
@Suppress("LocalVariableName")
internal fun allocateSaturationColorMatrixSkia(factor: Float): FloatArray {
val invSat = 1 - factor
val R = 0.213f * invSat
val G = 0.715f * invSat
val B = 0.072f * invSat
return floatArrayOf(
R + factor, G, B, 0f, 0f,
R, G + factor, B, 0f, 0f,
R, G, B + factor, 0f, 0f,
0f, 0f, 0f, 1f, 0f
)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment