From 3f9db4751fbe9dc08561ed81065e53d1c3334fb1 Mon Sep 17 00:00:00 2001 From: MM20 <15646950+MM2-0@users.noreply.github.com> Date: Mon, 15 May 2023 16:49:10 +0200 Subject: [PATCH] Prerender icon layers to bitmap --- .../ui/component/ShapedLauncherIcon.kt | 131 ++++++++++++++---- core/base/build.gradle.kts | 1 + .../de/mm20/launcher2/icons/LauncherIcon.kt | 104 +++++++++++++- 3 files changed, 207 insertions(+), 29 deletions(-) diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/component/ShapedLauncherIcon.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/component/ShapedLauncherIcon.kt index ae02e393..7c65025d 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/component/ShapedLauncherIcon.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/component/ShapedLauncherIcon.kt @@ -1,5 +1,7 @@ package de.mm20.launcher2.ui.component +import android.graphics.Bitmap +import android.graphics.BitmapShader import android.graphics.Matrix import android.graphics.Path import android.graphics.PorterDuff @@ -37,11 +39,16 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.toRect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shader +import androidx.compose.ui.graphics.ShaderBrush import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.asComposePath +import androidx.compose.ui.graphics.drawOutline import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.drawscope.scale import androidx.compose.ui.graphics.drawscope.withTransform @@ -52,6 +59,7 @@ import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -63,6 +71,7 @@ import de.mm20.launcher2.icons.ColorLayer import de.mm20.launcher2.icons.DynamicLauncherIcon import de.mm20.launcher2.icons.LauncherIcon import de.mm20.launcher2.icons.LauncherIconLayer +import de.mm20.launcher2.icons.LauncherIconRenderSettings import de.mm20.launcher2.icons.StaticIconLayer import de.mm20.launcher2.icons.StaticLauncherIcon import de.mm20.launcher2.icons.TextLayer @@ -72,7 +81,10 @@ import de.mm20.launcher2.icons.TransparentLayer import de.mm20.launcher2.ktx.drawWithColorFilter import de.mm20.launcher2.preferences.Settings.IconSettings.IconShape import de.mm20.launcher2.ui.base.LocalTime +import de.mm20.launcher2.ui.ktx.toPixels import de.mm20.launcher2.ui.locals.LocalDarkTheme +import de.mm20.launcher2.ui.locals.LocalGridSettings +import de.mm20.launcher2.ui.modifier.scale import kotlinx.coroutines.launch import palettes.TonalPalette import java.time.Instant @@ -80,6 +92,7 @@ import java.time.ZoneId import kotlin.math.abs import kotlin.math.pow import kotlin.math.roundToInt +import android.graphics.Shader as PlatformShader @Composable fun ShapedLauncherIcon( @@ -104,6 +117,24 @@ fun ShapedLauncherIcon( ) } + val defaultIconSize = LocalGridSettings.current.iconSize.dp + + val renderSettings = LauncherIconRenderSettings( + size = defaultIconSize.toPixels().toInt(), + fgThemeColor = MaterialTheme.colorScheme.onPrimaryContainer.toArgb(), + bgThemeColor = MaterialTheme.colorScheme.primaryContainer.toArgb(), + fgTone = if (LocalDarkTheme.current) 90 else 10, + bgTone = if (LocalDarkTheme.current) 30 else 90, + ) + + var currentBitmap by remember { + mutableStateOf(currentIcon?.getCachedBitmap(renderSettings)) + } + + LaunchedEffect(currentIcon, renderSettings) { + currentBitmap = currentIcon?.render(renderSettings) + } + if (_icon is DynamicLauncherIcon) { val time = LocalTime.current LaunchedEffect(time) { @@ -118,10 +149,6 @@ fun ShapedLauncherIcon( Box( modifier = Modifier .fillMaxSize() - .graphicsLayer { - clip = currentIcon?.backgroundLayer !is TransparentLayer - this.shape = shape - } .then( if (onClick != null || onLongClick != null) { Modifier.pointerInput(onClick, onLongClick) { @@ -134,19 +161,64 @@ fun ShapedLauncherIcon( ), contentAlignment = Alignment.Center ) { - currentIcon?.let { - IconLayer( - it.backgroundLayer, - size, - colorTone = if (LocalDarkTheme.current) 30 else 90, - MaterialTheme.colorScheme.primaryContainer - ) - IconLayer( - it.foregroundLayer, - size, - colorTone = if (LocalDarkTheme.current) 90 else 10, - MaterialTheme.colorScheme.onPrimaryContainer - ) + val bmp = currentBitmap + val ic = currentIcon + if (bmp != null && ic != null) { + Canvas(modifier = Modifier + .size(defaultIconSize) + .scale(size / defaultIconSize, TransformOrigin.Center) + ) { + val brush = BitmapShaderBrush(bmp) + if (ic.backgroundLayer is TransparentLayer) { + drawRect(brush) + } else { + val outline = + shape.createOutline(this.size, layoutDirection, Density(density, fontScale)) + drawOutline(outline, brush) + } + } + // Background layer is always static layer, color layer, or transparent layer + val fg = ic.foregroundLayer + when(fg) { + is ClockLayer -> { + ClockLayer( + sublayers = fg.sublayers, + defaultMinute = fg.defaultMinute, + defaultHour = fg.defaultHour, + defaultSecond = fg.defaultSecond, + scale = fg.scale, + tintColor = null, + ) + } + is TintedClockLayer -> { + ClockLayer( + sublayers = fg.sublayers, + defaultMinute = fg.defaultMinute, + defaultHour = fg.defaultHour, + defaultSecond = fg.defaultSecond, + scale = fg.scale, + tintColor = if (fg.color == 0) { + Color(renderSettings.fgThemeColor) + } else { + Color(getTone(fg.color, renderSettings.fgTone)) + }, + ) + } + is TextLayer -> { + Text( + text = fg.text, + style = MaterialTheme.typography.headlineSmall.copy( + fontSize = 20.sp * (size / 48.dp) + ), + color = if (fg.color == 0) { + Color(renderSettings.fgThemeColor) + } else { + Color(getTone(fg.color, renderSettings.fgTone)) + }, + ) + } + else -> {} + } } } val _badge = badge() @@ -306,16 +378,12 @@ private fun IconLayer( if (layer.color == 0) defaultTintColor.toArgb() else getTone(layer.color, colorTone) Canvas(modifier = Modifier.fillMaxSize()) { - withTransform({ - this.scale(layer.scale) - }) { - drawIntoCanvas { - layer.icon.bounds = this.size.toRect().toAndroidRect() - layer.icon.drawWithColorFilter( - it.nativeCanvas, - PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN) - ) - } + drawIntoCanvas { + layer.icon.bounds = this.size.toRect().toAndroidRect() + layer.icon.drawWithColorFilter( + it.nativeCanvas, + PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN) + ) } } } @@ -409,6 +477,15 @@ private fun ClockLayer( } } +class BitmapShaderBrush( + val bitmap: Bitmap, +) : ShaderBrush() { + override fun createShader(size: Size): Shader { + return BitmapShader(bitmap, PlatformShader.TileMode.CLAMP, PlatformShader.TileMode.CLAMP) + } + +} + val LocalIconShape = compositionLocalOf { CircleShape } fun getShape(iconShape: IconShape): Shape { diff --git a/core/base/build.gradle.kts b/core/base/build.gradle.kts index 9ad39643..698602fd 100644 --- a/core/base/build.gradle.kts +++ b/core/base/build.gradle.kts @@ -51,5 +51,6 @@ dependencies { implementation(project(":core:ktx")) implementation(project(":core:i18n")) + implementation(project(":libs:material-color-utilities")) } \ No newline at end of file diff --git a/core/base/src/main/java/de/mm20/launcher2/icons/LauncherIcon.kt b/core/base/src/main/java/de/mm20/launcher2/icons/LauncherIcon.kt index 3113d2ef..c4f9e15f 100644 --- a/core/base/src/main/java/de/mm20/launcher2/icons/LauncherIcon.kt +++ b/core/base/src/main/java/de/mm20/launcher2/icons/LauncherIcon.kt @@ -1,12 +1,112 @@ package de.mm20.launcher2.icons +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter +import android.graphics.PorterDuffXfermode +import android.graphics.Rect +import androidx.core.graphics.withScale +import de.mm20.launcher2.ktx.drawWithColorFilter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext +import palettes.TonalPalette + sealed interface LauncherIcon +data class LauncherIconRenderSettings( + val size: Int, + val fgThemeColor: Int, + val bgThemeColor: Int, + val fgTone: Int, + val bgTone: Int, +) + data class StaticLauncherIcon( val foregroundLayer: LauncherIconLayer, val backgroundLayer: LauncherIconLayer, -): LauncherIcon +) : LauncherIcon { + private var cachedBitmap: Bitmap? = null + private var cachedRenderSettings: LauncherIconRenderSettings? = null + private var renderSemaphore = Semaphore(1) -interface DynamicLauncherIcon: LauncherIcon { + fun getCachedBitmap(settings: LauncherIconRenderSettings): Bitmap? { + return if (cachedRenderSettings == settings) cachedBitmap else null + } + + /** + * Render this icon to a bitmap. + */ + suspend fun render(settings: LauncherIconRenderSettings): Bitmap { + val cachedBmp = cachedBitmap + if (cachedRenderSettings == settings && cachedBmp != null) return cachedBmp + val bmp = withContext(Dispatchers.Default) { + renderSemaphore.withPermit { + val bmp = + if (cachedBmp == null || cachedBmp.width != settings.size || cachedBmp.height != settings.size) { + Bitmap.createBitmap(settings.size, settings.size, Bitmap.Config.ARGB_8888)!! + } else cachedBmp + val canvas = Canvas(bmp) + canvas.drawRect( + Rect(0, 0, canvas.width, canvas.height), Paint().apply { + xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) + }) + renderLayer(canvas, backgroundLayer, settings.bgThemeColor, settings.bgTone) + renderLayer(canvas, foregroundLayer, settings.fgThemeColor, settings.fgTone) + cachedBitmap = bmp + cachedRenderSettings = settings + bmp + } + } + return bmp + } + + private fun renderLayer(canvas: Canvas, layer: LauncherIconLayer, themeColor: Int, tone: Int) { + when(layer) { + is ColorLayer -> { + val paint = Paint() + paint.color = if (layer.color == 0) themeColor else getTone(layer.color, tone) + canvas.drawRect(Rect(0, 0, canvas.width, canvas.height), paint) + } + is StaticIconLayer -> { + canvas.withScale( + layer.scale, + layer.scale, + canvas.width / 2f, + canvas.height / 2f, + ) { + layer.icon.bounds = Rect(0, 0, canvas.width, canvas.height) + layer.icon.draw(canvas) + } + } + is TintedIconLayer -> { + val color = if (layer.color == 0) themeColor else getTone(layer.color, tone) + canvas.withScale( + layer.scale, + layer.scale, + canvas.width / 2f, + canvas.height / 2f, + ) { + layer.icon.bounds = Rect(0, 0, canvas.width, canvas.height) + layer.icon.drawWithColorFilter(canvas, + PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN) + ) + } + } + else -> {} + } + } + + private fun getTone(argb: Int, tone: Int): Int { + return TonalPalette + .fromInt(argb) + .tone(tone) + } +} + +interface DynamicLauncherIcon : LauncherIcon { suspend fun getIcon(time: Long): StaticLauncherIcon } \ No newline at end of file