Improve LocationPin rendering with caching mechanism

This commit is contained in:
ganfra
2026-03-06 13:19:09 +01:00
parent d53db78856
commit fa9f0a93c0
2 changed files with 128 additions and 112 deletions

View File

@@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -35,7 +36,10 @@ import androidx.compose.ui.unit.dp
import androidx.core.graphics.createBitmap
import androidx.core.graphics.withSave
import coil3.Image
import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.asImage
import coil3.memory.MemoryCache
import coil3.request.ImageRequest
import coil3.request.allowHardware
import coil3.toBitmap
@@ -46,6 +50,13 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
private val PIN_WIDTH = 42.dp
private val PIN_HEIGHT = PIN_WIDTH * 1.2f
private val AVATAR_SIZE = PIN_WIDTH - 10.dp
private val CONTENT_OFFSET = 5.dp
private val DOT_RADIUS = 6.dp
private val STROKE_WIDTH = 1.dp
/**
* Variants of location pin markers.
*/
@@ -86,21 +97,23 @@ fun LocationPin(
fun rememberLocationPinBitmap(variant: PinVariant): ImageBitmap? {
val context = LocalContext.current
val density = LocalDensity.current
val imageLoader = SingletonImageLoader.get(context)
val colors = pinColors(variant)
return produceState<ImageBitmap?>(initialValue = null, variant, colors) {
val renderer = LocationPinRenderer(context, density)
val bitmap = renderer.renderPin(variant, colors)
value = bitmap.asImageBitmap()
val cacheKey = rememberCacheKey(variant)
return produceState<ImageBitmap?>(initialValue = null, cacheKey) {
val memoryCacheKey = MemoryCache.Key(cacheKey)
val cached = imageLoader.memoryCache?.get(memoryCacheKey)
if (cached != null) {
value = cached.image.toBitmap().asImageBitmap()
} else {
val dimensions = PinDimensions(density)
val bitmap = LocationPinRenderer.renderPin(variant, colors, dimensions, context, imageLoader)
imageLoader.memoryCache?.set(memoryCacheKey, MemoryCache.Value(bitmap.asImage()))
value = bitmap.asImageBitmap()
}
}.value
}
private val PIN_WIDTH = 42.dp
private val PIN_HEIGHT = PIN_WIDTH * 1.2f
private val AVATAR_SIZE = PIN_WIDTH - 10.dp
private val CONTENT_OFFSET = 5.dp
private val DOT_RADIUS = 6.dp
private val STROKE_WIDTH = 1.dp
@Composable
private fun pinColors(variant: PinVariant): PinColors {
return when (variant) {
@@ -148,7 +161,7 @@ private fun pinColors(variant: PinVariant): PinColors {
/**
* Color configuration for rendering a location pin.
*/
data class PinColors(
private data class PinColors(
val fill: Color,
val stroke: Color,
val dot: Color,
@@ -158,20 +171,37 @@ data class PinColors(
)
/**
* Renders location pins to bitmaps using Canvas operations.
* Uses Coil for avatar loading with proper memory management.
* Pre-calculated pixel dimensions for rendering a location pin.
*/
class LocationPinRenderer(
private val context: Context,
private val density: Density,
) {
// Dimensions in pixels
private val pinWidthPx = with(density) { PIN_WIDTH.toPx() }
private val pinHeightPx = with(density) { PIN_HEIGHT.toPx() }
private val avatarSizePx = with(density) { AVATAR_SIZE.toPx() }
private val avatarOffsetPx = with(density) { CONTENT_OFFSET.toPx() }
private val dotRadiusPx = with(density) { DOT_RADIUS.toPx() }
private val strokeWidthPx = with(density) { STROKE_WIDTH.toPx() }
private class PinDimensions(density: Density) {
val pinWidth = with(density) { PIN_WIDTH.toPx() }
val pinHeight = with(density) { PIN_HEIGHT.toPx() }
val avatarSize: Float = with(density) { AVATAR_SIZE.toPx() }
val avatarOffset: Float = with(density) { CONTENT_OFFSET.toPx() }
val dotRadius: Float = with(density) { DOT_RADIUS.toPx() }
val strokeWidth: Float = with(density) { STROKE_WIDTH.toPx() }
}
/**
* Renders location pins to bitmaps using Canvas operations.
* Uses Coil for avatar loading.
* Paint objects are shared across all renders.
*/
private object LocationPinRenderer {
// Shared Paint objects to avoid allocations
private val fillPaint = Paint().apply {
style = Paint.Style.FILL
isAntiAlias = true
}
private val strokePaint = Paint().apply {
style = Paint.Style.STROKE
isAntiAlias = true
}
private val textPaint = Paint().apply {
textAlign = Paint.Align.CENTER
isAntiAlias = true
isFakeBoldText = true
}
/**
* Renders a pin variant to bitmap. Suspending for async avatar loading.
@@ -179,56 +209,50 @@ class LocationPinRenderer(
suspend fun renderPin(
variant: PinVariant,
colors: PinColors,
dimensions: PinDimensions,
context: Context,
imageLoader: ImageLoader,
): Bitmap {
val bitmap = createBitmap(pinWidthPx.toInt(), pinHeightPx.toInt())
val bitmap = createBitmap(dimensions.pinWidth.toInt(), dimensions.pinHeight.toInt())
val canvas = Canvas(bitmap)
// Draw pin shape (fill + stroke)
canvas.drawPinShape(colors.fill, colors.stroke)
canvas.drawPinShape(colors.fill, colors.stroke, dimensions)
when (variant) {
is PinVariant.UserLocation -> {
val avatarImage = loadAvatarImage(variant.avatarData)
val avatarImage = loadAvatarImage(variant.avatarData, context, imageLoader)
canvas.drawAvatar(
avatarImage = avatarImage,
avatarData = variant.avatarData,
borderColor = colors.avatarStroke,
backgroundColor = colors.avatarBackground,
foregroundColor = colors.avatarForeground
foregroundColor = colors.avatarForeground,
dimensions = dimensions,
)
}
PinVariant.PinnedLocation,
PinVariant.StaleLocation -> canvas.drawDot(colors.dot)
PinVariant.StaleLocation -> canvas.drawDot(colors.dot, dimensions)
}
return bitmap
}
private fun Canvas.drawPinShape(fillColor: Color, strokeColor: Color) {
val path = createPinPath()
// Fill
drawPath(path, Paint().apply {
color = fillColor.toArgb()
style = Paint.Style.FILL
isAntiAlias = true
})
// Stroke
drawPath(path, Paint().apply {
color = strokeColor.toArgb()
style = Paint.Style.STROKE
strokeWidth = strokeWidthPx
isAntiAlias = true
})
private fun Canvas.drawPinShape(fillColor: Color, strokeColor: Color, dimensions: PinDimensions) {
val path = createPinPath(dimensions)
fillPaint.color = fillColor.toArgb()
drawPath(path, fillPaint)
strokePaint.color = strokeColor.toArgb()
strokePaint.strokeWidth = dimensions.strokeWidth
drawPath(path, strokePaint)
}
/**
* Creates the teardrop-shaped pin path.
* Updates the teardrop-shaped pin path to match dimensions.
* Based on SVG path with dimensions 40x48 (ratio 1:1.2).
* Scales automatically to fit the actual size.
*/
private fun createPinPath(): Path {
private fun createPinPath(dimensions: PinDimensions): Path {
val svgWidth = 40f
val svgHeight = 48f
val inset = strokeWidthPx / 2
val scaleX = (pinWidthPx - strokeWidthPx) / svgWidth
val scaleY = (pinHeightPx - strokeWidthPx) / svgHeight
val inset = dimensions.strokeWidth / 2
val scaleX = (dimensions.pinWidth - dimensions.strokeWidth) / svgWidth
val scaleY = (dimensions.pinHeight - dimensions.strokeWidth) / svgHeight
val path = Path().apply {
moveTo(20f, 48f)
@@ -250,7 +274,6 @@ class LocationPinRenderer(
cubicTo(21.1667f, 47.8965f, 20.5833f, 48f, 20f, 48f)
close()
}
// Scale and translate the path
val matrix = Matrix().apply {
setScale(scaleX, scaleY)
postTranslate(inset, inset)
@@ -259,15 +282,16 @@ class LocationPinRenderer(
return path
}
private suspend fun loadAvatarImage(avatarData: AvatarData): Image? {
val imageLoader = SingletonImageLoader.get(context)
private suspend fun loadAvatarImage(
avatarData: AvatarData,
context: Context,
imageLoader: ImageLoader,
): Image? {
val request = ImageRequest.Builder(context)
.data(avatarData)
.size(avatarSizePx.toInt())
// Disable hardware rendering for Canvas
.allowHardware(false)
.build()
return imageLoader.execute(request).image
}
@@ -277,29 +301,34 @@ class LocationPinRenderer(
borderColor: Color,
backgroundColor: Color,
foregroundColor: Color,
dimensions: PinDimensions,
) {
val centerX = pinWidthPx / 2
val avatarY = avatarOffsetPx
val avatarRadius = avatarSizePx / 2
val centerX = dimensions.pinWidth / 2
val avatarY = dimensions.avatarOffset
val avatarRadius = dimensions.avatarSize / 2
withSave {
val clipPath = Path().apply {
addCircle(centerX, avatarY + avatarRadius, avatarRadius, Path.Direction.CW)
}
clipPath(clipPath)
if (avatarImage != null) {
// Draw the loaded avatar image
val bitmap = avatarImage.toBitmap()
// Calculate centered square crop (ContentScale.Crop behavior)
val srcSize = minOf(bitmap.width, bitmap.height)
val srcX = (bitmap.width - srcSize) / 2
val srcY = (bitmap.height - srcSize) / 2
val srcRect = Rect(srcX, srcY, srcX + srcSize, srcY + srcSize)
val destRect = RectF(
centerX - avatarRadius,
avatarY,
centerX + avatarRadius,
avatarY + avatarSizePx
avatarY + dimensions.avatarSize
)
drawBitmap(avatarImage.toBitmap(), null, destRect, null)
val clipPath = Path().apply {
addCircle(centerX, avatarY + avatarRadius, avatarRadius, Path.Direction.CW)
}
clipPath(clipPath)
drawBitmap(bitmap, srcRect, destRect, null)
} else {
// Fallback: draw initial letter circle
drawInitialLetterAvatar(
avatarData = avatarData,
initialLetter = avatarData.initialLetter,
centerX = centerX,
centerY = avatarY + avatarRadius,
radius = avatarRadius,
@@ -308,56 +337,35 @@ class LocationPinRenderer(
)
}
}
val paintBorder = Paint().apply {
color = borderColor.toArgb()
style = Paint.Style.STROKE
strokeWidth = strokeWidthPx
isAntiAlias = true
}
drawCircle(centerX, avatarY + avatarRadius, avatarRadius, paintBorder)
strokePaint.color = borderColor.toArgb()
strokePaint.strokeWidth = dimensions.strokeWidth
drawCircle(centerX, avatarY + avatarRadius, avatarRadius, strokePaint)
}
private fun Canvas.drawInitialLetterAvatar(
avatarData: AvatarData,
initialLetter: String,
centerX: Float,
centerY: Float,
radius: Float,
foreground: Int,
background: Int,
) {
// Draw background circle
drawCircle(centerX, centerY, radius, Paint().apply {
color = background
style = Paint.Style.FILL
isAntiAlias = true
})
// Draw initial letter
val textPaint = Paint().apply {
color = foreground
textSize = radius * 1.2f
textAlign = Paint.Align.CENTER
isAntiAlias = true
isFakeBoldText = true
}
// Center text vertically
fillPaint.color = background
drawCircle(centerX, centerY, radius, fillPaint)
textPaint.color = foreground
textPaint.textSize = radius * 1.2f
val textBounds = Rect()
textPaint.getTextBounds(avatarData.initialLetter, 0, 1, textBounds)
textPaint.getTextBounds(initialLetter, 0, 1, textBounds)
val textY = centerY + textBounds.height() / 2f
drawText(avatarData.initialLetter, centerX, textY, textPaint)
drawText(initialLetter, centerX, textY, textPaint)
}
private fun Canvas.drawDot(dotColor: Color) {
private fun Canvas.drawDot(dotColor: Color, dimensions: PinDimensions) {
if (dotColor == Color.Transparent) return
val centerX = pinWidthPx / 2
// Position dot in the center of the circular part of the pin
val centerY = avatarOffsetPx + avatarSizePx / 2
drawCircle(centerX, centerY, dotRadiusPx, Paint().apply {
color = dotColor.toArgb()
style = Paint.Style.FILL
isAntiAlias = true
})
val centerX = dimensions.pinWidth / 2
val centerY = dimensions.avatarOffset + dimensions.avatarSize / 2
fillPaint.color = dotColor.toArgb()
drawCircle(centerX, centerY, dimensions.dotRadius, fillPaint)
}
}
@@ -393,3 +401,17 @@ internal fun LocationPinPreview() = ElementPreview {
}
}
}
@Composable
private fun rememberCacheKey(variant: PinVariant): String {
val isLightTheme = ElementTheme.isLightTheme
val density = LocalDensity.current.density
return remember(isLightTheme, density, variant) {
val pinVariant = when (variant) {
PinVariant.PinnedLocation -> "pin_pinned"
PinVariant.StaleLocation -> "pin_stale"
is PinVariant.UserLocation -> "pin_user_${variant.avatarData.id}_${variant.isLive}"
}
"${pinVariant}_{$isLightTheme}_{$density}"
}
}

View File

@@ -33,16 +33,10 @@ internal fun ImageAvatar(
forcedAvatarSize: Dp?,
modifier: Modifier = Modifier,
contentDescription: String? = null,
configureRequest: (ImageRequest.Builder) -> ImageRequest.Builder = { it },
) {
val size = forcedAvatarSize ?: avatarData.size.dp
val request = ImageRequest.Builder(LocalContext.current)
.data(avatarData)
.let(configureRequest)
.build()
SubcomposeAsyncImage(
model = request,
model = avatarData,
contentDescription = contentDescription,
contentScale = ContentScale.Crop,
modifier = modifier