Last active
March 26, 2025 00:47
-
-
Save iTaysonLab/c6a442f8b6bd243dc647a4af6c2f384d to your computer and use it in GitHub Desktop.
Multiplatform Coil Blur Transformer imitating iOS blurring materials
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)" | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package bruhcollective.itaysonlab.libvibrancy | |
import coil3.transform.Transformation | |
expect fun createVibrancyTransformation(material: VibrancyMaterial): Transformation |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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