perf(icons): share FontFamily + TextMeasurer across Material Symbol icons
Material Symbol icons were allocating a fresh Font wrapper on every composition (Font(resource = ...) returns a new instance each call), which invalidated the remember(font) cache in rememberMaterialSymbolsFontFamily and forced a fresh TextMeasurer per call site. Tint was also baked into the TextStyle passed to measure(), so any tint change (selected / unselected reaction icons, hover states) produced a cache miss. Hoist the FontFamily + TextMeasurer to CompositionLocals provided once at the root (AmethystTheme on Android, MaterialTheme wrapper on desktop) via ProvideMaterialSymbols. Every icon in the subtree now reuses: - One FontFamily (Skia typeface cache hit for every subsequent glyph) - One TextMeasurer with a 64-entry LRU, shared across all call sites so its measurement cache is hit across 300+ MaterialSymbols usages - Tint applied via drawText(layout, color = tint) instead of TextStyle, so the measured TextLayoutResult is reused across tint variations Icon(symbol = ...) now delegates to Material3's Icon(painter = ...), restoring proper sizing/semantics without the custom Box+drawBehind workaround. autoMirror handled inside the painter's onDraw. The previously-unused MaterialSymbolPainter is now the single render path. The weight parameter is dropped from the Icon/Painter APIs since the app uses a single weight globally (MaterialSymbolsDefaults.WEIGHT). A local fallback path keeps @Preview composables that don't wrap in the theme renderable (per-composition allocation, same cost as before). https://claude.ai/code/session_01V9esGUFH72ujCQNWCAnjKN
This commit is contained in:
+8
-62
@@ -20,29 +20,11 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.icons.symbols
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.scale
|
||||
import androidx.compose.ui.graphics.drawscope.translate
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.drawText
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
private val IconDefaultSize = 24.dp
|
||||
import androidx.compose.material3.Icon as Material3Icon
|
||||
|
||||
@Composable
|
||||
fun Icon(
|
||||
@@ -50,49 +32,13 @@ fun Icon(
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
tint: Color = LocalContentColor.current,
|
||||
weight: Int = MaterialSymbolsDefaults.WEIGHT,
|
||||
) {
|
||||
val fontFamily = rememberMaterialSymbolsFontFamily(weight)
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
val density = LocalDensity.current
|
||||
val mirror = symbol.autoMirror && LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||
|
||||
val semanticsModifier =
|
||||
if (contentDescription != null) {
|
||||
Modifier.semantics {
|
||||
this.contentDescription = contentDescription
|
||||
this.role = Role.Image
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.defaultMinSize(IconDefaultSize, IconDefaultSize)
|
||||
.drawBehind {
|
||||
val sizePx = size.minDimension
|
||||
val fontSize = with(density) { sizePx.toSp() }
|
||||
val layout =
|
||||
textMeasurer.measure(
|
||||
text = symbol.glyph,
|
||||
style =
|
||||
TextStyle(
|
||||
fontFamily = fontFamily,
|
||||
fontSize = fontSize,
|
||||
color = tint,
|
||||
),
|
||||
)
|
||||
val tx = (size.width - layout.size.width) / 2f
|
||||
val ty = (size.height - layout.size.height) / 2f
|
||||
if (mirror) {
|
||||
scale(scaleX = -1f, scaleY = 1f, pivot = Offset(size.width / 2f, size.height / 2f)) {
|
||||
translate(tx, ty) { drawText(layout) }
|
||||
}
|
||||
} else {
|
||||
translate(tx, ty) { drawText(layout) }
|
||||
}
|
||||
}.then(semanticsModifier),
|
||||
Material3Icon(
|
||||
painter = rememberMaterialSymbolPainter(symbol, tint),
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
// Tint is already baked into the painter draw; pass Unspecified so Material3 doesn't
|
||||
// double-tint via a ColorFilter.
|
||||
tint = Color.Unspecified,
|
||||
)
|
||||
}
|
||||
|
||||
+20
-14
@@ -23,57 +23,63 @@ package com.vitorpamplona.amethyst.commons.icons.symbols
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.scale
|
||||
import androidx.compose.ui.graphics.drawscope.translate
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.text.TextMeasurer
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.drawText
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
|
||||
@Composable
|
||||
fun rememberMaterialSymbolPainter(
|
||||
symbol: MaterialSymbol,
|
||||
tint: Color = LocalContentColor.current,
|
||||
weight: Int = MaterialSymbolsDefaults.WEIGHT,
|
||||
): Painter {
|
||||
val fontFamily = rememberMaterialSymbolsFontFamily(weight)
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
val fontFamily = materialSymbolsFontFamily()
|
||||
val textMeasurer = materialSymbolsTextMeasurer()
|
||||
val density = LocalDensity.current
|
||||
return remember(symbol, fontFamily, tint, density) {
|
||||
MaterialSymbolPainter(symbol, fontFamily, tint, textMeasurer, density)
|
||||
val rtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||
return remember(symbol, fontFamily, textMeasurer, density, tint, rtl) {
|
||||
MaterialSymbolPainter(symbol, fontFamily, textMeasurer, density, tint, rtl)
|
||||
}
|
||||
}
|
||||
|
||||
private class MaterialSymbolPainter(
|
||||
private val symbol: MaterialSymbol,
|
||||
private val fontFamily: FontFamily,
|
||||
private val tint: Color,
|
||||
private val textMeasurer: TextMeasurer,
|
||||
private val density: Density,
|
||||
private val tint: Color,
|
||||
private val rtl: Boolean,
|
||||
) : Painter() {
|
||||
override val intrinsicSize: Size = Size.Unspecified
|
||||
|
||||
override fun DrawScope.onDraw() {
|
||||
val sizePx = size.minDimension
|
||||
val fontSize = with(density) { sizePx.toSp() }
|
||||
// TextStyle omits color; we override at draw time so TextMeasurer's cache hits across tints.
|
||||
val layout =
|
||||
textMeasurer.measure(
|
||||
text = symbol.glyph,
|
||||
style =
|
||||
TextStyle(
|
||||
fontFamily = fontFamily,
|
||||
fontSize = fontSize,
|
||||
color = tint,
|
||||
),
|
||||
style = TextStyle(fontFamily = fontFamily, fontSize = fontSize),
|
||||
)
|
||||
val tx = (size.width - layout.size.width) / 2f
|
||||
val ty = (size.height - layout.size.height) / 2f
|
||||
translate(tx, ty) { drawText(layout) }
|
||||
if (symbol.autoMirror && rtl) {
|
||||
scale(scaleX = -1f, scaleY = 1f, pivot = Offset(size.width / 2f, size.height / 2f)) {
|
||||
translate(tx, ty) { drawText(layout, color = tint) }
|
||||
}
|
||||
} else {
|
||||
translate(tx, ty) { drawText(layout, color = tint) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+56
-4
@@ -21,27 +21,79 @@
|
||||
package com.vitorpamplona.amethyst.commons.icons.symbols
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.ProvidableCompositionLocal
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.text.TextMeasurer
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontVariation
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.material_symbols_outlined
|
||||
import org.jetbrains.compose.resources.Font
|
||||
|
||||
// Held on CompositionLocal so every Material Symbol call site in the tree reuses one FontFamily
|
||||
// instance. Without this, every Icon() composition allocates a new Font wrapper, breaks its own
|
||||
// remember cache, and forces a fresh TextMeasurer per call site.
|
||||
val LocalMaterialSymbolsFontFamily: ProvidableCompositionLocal<FontFamily?> = staticCompositionLocalOf { null }
|
||||
|
||||
// Shared TextMeasurer so its internal LRU cache is hit by every icon draw in the tree.
|
||||
val LocalMaterialSymbolsTextMeasurer: ProvidableCompositionLocal<TextMeasurer?> = staticCompositionLocalOf { null }
|
||||
|
||||
private const val TEXT_MEASURER_CACHE_SIZE = 64
|
||||
|
||||
/**
|
||||
* Builds the Material Symbols FontFamily and a shared TextMeasurer once for the subtree and
|
||||
* exposes them via CompositionLocal. Wrap app roots (AmethystTheme, desktop MaterialTheme) in this.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberMaterialSymbolsFontFamily(weight: Int = MaterialSymbolsDefaults.WEIGHT): FontFamily {
|
||||
fun ProvideMaterialSymbols(content: @Composable () -> Unit) {
|
||||
val font =
|
||||
Font(
|
||||
resource = Res.font.material_symbols_outlined,
|
||||
weight = FontWeight(weight),
|
||||
weight = FontWeight(MaterialSymbolsDefaults.WEIGHT),
|
||||
variationSettings =
|
||||
FontVariation.Settings(
|
||||
FontVariation.weight(weight),
|
||||
FontVariation.weight(MaterialSymbolsDefaults.WEIGHT),
|
||||
FontVariation.Setting("FILL", MaterialSymbolsDefaults.FILL),
|
||||
FontVariation.Setting("opsz", MaterialSymbolsDefaults.OPTICAL_SIZE),
|
||||
FontVariation.Setting("GRAD", MaterialSymbolsDefaults.GRADE),
|
||||
),
|
||||
)
|
||||
return remember(font) { FontFamily(font) }
|
||||
// Keyless remember: the Font wrapper identity changes every composition but the underlying
|
||||
// resource is a compile-time constant, so one FontFamily for the lifetime of the subtree.
|
||||
val fontFamily = remember { FontFamily(font) }
|
||||
val textMeasurer = rememberTextMeasurer(cacheSize = TEXT_MEASURER_CACHE_SIZE)
|
||||
CompositionLocalProvider(
|
||||
LocalMaterialSymbolsFontFamily provides fontFamily,
|
||||
LocalMaterialSymbolsTextMeasurer provides textMeasurer,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun materialSymbolsFontFamily(): FontFamily = LocalMaterialSymbolsFontFamily.current ?: localMaterialSymbolsFontFamilyFallback()
|
||||
|
||||
@Composable
|
||||
internal fun materialSymbolsTextMeasurer(): TextMeasurer =
|
||||
LocalMaterialSymbolsTextMeasurer.current
|
||||
?: rememberTextMeasurer(cacheSize = TEXT_MEASURER_CACHE_SIZE)
|
||||
|
||||
@Composable
|
||||
private fun localMaterialSymbolsFontFamilyFallback(): FontFamily {
|
||||
val font =
|
||||
Font(
|
||||
resource = Res.font.material_symbols_outlined,
|
||||
weight = FontWeight(MaterialSymbolsDefaults.WEIGHT),
|
||||
variationSettings =
|
||||
FontVariation.Settings(
|
||||
FontVariation.weight(MaterialSymbolsDefaults.WEIGHT),
|
||||
FontVariation.Setting("FILL", MaterialSymbolsDefaults.FILL),
|
||||
FontVariation.Setting("opsz", MaterialSymbolsDefaults.OPTICAL_SIZE),
|
||||
FontVariation.Setting("GRAD", MaterialSymbolsDefaults.GRADE),
|
||||
),
|
||||
)
|
||||
return remember { FontFamily(font) }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user