Improve LocationPin rendering with caching mechanism
This commit is contained in:
parent
d53db78856
commit
fa9f0a93c0
2 changed files with 128 additions and 112 deletions
|
|
@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.produceState
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
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.createBitmap
|
||||||
import androidx.core.graphics.withSave
|
import androidx.core.graphics.withSave
|
||||||
import coil3.Image
|
import coil3.Image
|
||||||
|
import coil3.ImageLoader
|
||||||
import coil3.SingletonImageLoader
|
import coil3.SingletonImageLoader
|
||||||
|
import coil3.asImage
|
||||||
|
import coil3.memory.MemoryCache
|
||||||
import coil3.request.ImageRequest
|
import coil3.request.ImageRequest
|
||||||
import coil3.request.allowHardware
|
import coil3.request.allowHardware
|
||||||
import coil3.toBitmap
|
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.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
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.
|
* Variants of location pin markers.
|
||||||
*/
|
*/
|
||||||
|
|
@ -86,21 +97,23 @@ fun LocationPin(
|
||||||
fun rememberLocationPinBitmap(variant: PinVariant): ImageBitmap? {
|
fun rememberLocationPinBitmap(variant: PinVariant): ImageBitmap? {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
val imageLoader = SingletonImageLoader.get(context)
|
||||||
val colors = pinColors(variant)
|
val colors = pinColors(variant)
|
||||||
return produceState<ImageBitmap?>(initialValue = null, variant, colors) {
|
val cacheKey = rememberCacheKey(variant)
|
||||||
val renderer = LocationPinRenderer(context, density)
|
return produceState<ImageBitmap?>(initialValue = null, cacheKey) {
|
||||||
val bitmap = renderer.renderPin(variant, colors)
|
val memoryCacheKey = MemoryCache.Key(cacheKey)
|
||||||
value = bitmap.asImageBitmap()
|
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
|
}.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
|
@Composable
|
||||||
private fun pinColors(variant: PinVariant): PinColors {
|
private fun pinColors(variant: PinVariant): PinColors {
|
||||||
return when (variant) {
|
return when (variant) {
|
||||||
|
|
@ -148,7 +161,7 @@ private fun pinColors(variant: PinVariant): PinColors {
|
||||||
/**
|
/**
|
||||||
* Color configuration for rendering a location pin.
|
* Color configuration for rendering a location pin.
|
||||||
*/
|
*/
|
||||||
data class PinColors(
|
private data class PinColors(
|
||||||
val fill: Color,
|
val fill: Color,
|
||||||
val stroke: Color,
|
val stroke: Color,
|
||||||
val dot: Color,
|
val dot: Color,
|
||||||
|
|
@ -158,20 +171,37 @@ data class PinColors(
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders location pins to bitmaps using Canvas operations.
|
* Pre-calculated pixel dimensions for rendering a location pin.
|
||||||
* Uses Coil for avatar loading with proper memory management.
|
|
||||||
*/
|
*/
|
||||||
class LocationPinRenderer(
|
private class PinDimensions(density: Density) {
|
||||||
private val context: Context,
|
val pinWidth = with(density) { PIN_WIDTH.toPx() }
|
||||||
private val density: Density,
|
val pinHeight = with(density) { PIN_HEIGHT.toPx() }
|
||||||
) {
|
val avatarSize: Float = with(density) { AVATAR_SIZE.toPx() }
|
||||||
// Dimensions in pixels
|
val avatarOffset: Float = with(density) { CONTENT_OFFSET.toPx() }
|
||||||
private val pinWidthPx = with(density) { PIN_WIDTH.toPx() }
|
val dotRadius: Float = with(density) { DOT_RADIUS.toPx() }
|
||||||
private val pinHeightPx = with(density) { PIN_HEIGHT.toPx() }
|
val strokeWidth: Float = with(density) { STROKE_WIDTH.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() }
|
* 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.
|
* Renders a pin variant to bitmap. Suspending for async avatar loading.
|
||||||
|
|
@ -179,56 +209,50 @@ class LocationPinRenderer(
|
||||||
suspend fun renderPin(
|
suspend fun renderPin(
|
||||||
variant: PinVariant,
|
variant: PinVariant,
|
||||||
colors: PinColors,
|
colors: PinColors,
|
||||||
|
dimensions: PinDimensions,
|
||||||
|
context: Context,
|
||||||
|
imageLoader: ImageLoader,
|
||||||
): Bitmap {
|
): Bitmap {
|
||||||
val bitmap = createBitmap(pinWidthPx.toInt(), pinHeightPx.toInt())
|
val bitmap = createBitmap(dimensions.pinWidth.toInt(), dimensions.pinHeight.toInt())
|
||||||
val canvas = Canvas(bitmap)
|
val canvas = Canvas(bitmap)
|
||||||
// Draw pin shape (fill + stroke)
|
canvas.drawPinShape(colors.fill, colors.stroke, dimensions)
|
||||||
canvas.drawPinShape(colors.fill, colors.stroke)
|
|
||||||
when (variant) {
|
when (variant) {
|
||||||
is PinVariant.UserLocation -> {
|
is PinVariant.UserLocation -> {
|
||||||
val avatarImage = loadAvatarImage(variant.avatarData)
|
val avatarImage = loadAvatarImage(variant.avatarData, context, imageLoader)
|
||||||
canvas.drawAvatar(
|
canvas.drawAvatar(
|
||||||
avatarImage = avatarImage,
|
avatarImage = avatarImage,
|
||||||
avatarData = variant.avatarData,
|
avatarData = variant.avatarData,
|
||||||
borderColor = colors.avatarStroke,
|
borderColor = colors.avatarStroke,
|
||||||
backgroundColor = colors.avatarBackground,
|
backgroundColor = colors.avatarBackground,
|
||||||
foregroundColor = colors.avatarForeground
|
foregroundColor = colors.avatarForeground,
|
||||||
|
dimensions = dimensions,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
PinVariant.PinnedLocation,
|
PinVariant.PinnedLocation,
|
||||||
PinVariant.StaleLocation -> canvas.drawDot(colors.dot)
|
PinVariant.StaleLocation -> canvas.drawDot(colors.dot, dimensions)
|
||||||
}
|
}
|
||||||
return bitmap
|
return bitmap
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Canvas.drawPinShape(fillColor: Color, strokeColor: Color) {
|
private fun Canvas.drawPinShape(fillColor: Color, strokeColor: Color, dimensions: PinDimensions) {
|
||||||
val path = createPinPath()
|
val path = createPinPath(dimensions)
|
||||||
// Fill
|
fillPaint.color = fillColor.toArgb()
|
||||||
drawPath(path, Paint().apply {
|
drawPath(path, fillPaint)
|
||||||
color = fillColor.toArgb()
|
strokePaint.color = strokeColor.toArgb()
|
||||||
style = Paint.Style.FILL
|
strokePaint.strokeWidth = dimensions.strokeWidth
|
||||||
isAntiAlias = true
|
drawPath(path, strokePaint)
|
||||||
})
|
|
||||||
// Stroke
|
|
||||||
drawPath(path, Paint().apply {
|
|
||||||
color = strokeColor.toArgb()
|
|
||||||
style = Paint.Style.STROKE
|
|
||||||
strokeWidth = strokeWidthPx
|
|
||||||
isAntiAlias = true
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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).
|
* 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 svgWidth = 40f
|
||||||
val svgHeight = 48f
|
val svgHeight = 48f
|
||||||
val inset = strokeWidthPx / 2
|
val inset = dimensions.strokeWidth / 2
|
||||||
val scaleX = (pinWidthPx - strokeWidthPx) / svgWidth
|
val scaleX = (dimensions.pinWidth - dimensions.strokeWidth) / svgWidth
|
||||||
val scaleY = (pinHeightPx - strokeWidthPx) / svgHeight
|
val scaleY = (dimensions.pinHeight - dimensions.strokeWidth) / svgHeight
|
||||||
|
|
||||||
val path = Path().apply {
|
val path = Path().apply {
|
||||||
moveTo(20f, 48f)
|
moveTo(20f, 48f)
|
||||||
|
|
@ -250,7 +274,6 @@ class LocationPinRenderer(
|
||||||
cubicTo(21.1667f, 47.8965f, 20.5833f, 48f, 20f, 48f)
|
cubicTo(21.1667f, 47.8965f, 20.5833f, 48f, 20f, 48f)
|
||||||
close()
|
close()
|
||||||
}
|
}
|
||||||
// Scale and translate the path
|
|
||||||
val matrix = Matrix().apply {
|
val matrix = Matrix().apply {
|
||||||
setScale(scaleX, scaleY)
|
setScale(scaleX, scaleY)
|
||||||
postTranslate(inset, inset)
|
postTranslate(inset, inset)
|
||||||
|
|
@ -259,15 +282,16 @@ class LocationPinRenderer(
|
||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadAvatarImage(avatarData: AvatarData): Image? {
|
private suspend fun loadAvatarImage(
|
||||||
val imageLoader = SingletonImageLoader.get(context)
|
avatarData: AvatarData,
|
||||||
|
context: Context,
|
||||||
|
imageLoader: ImageLoader,
|
||||||
|
): Image? {
|
||||||
val request = ImageRequest.Builder(context)
|
val request = ImageRequest.Builder(context)
|
||||||
.data(avatarData)
|
.data(avatarData)
|
||||||
.size(avatarSizePx.toInt())
|
|
||||||
// Disable hardware rendering for Canvas
|
// Disable hardware rendering for Canvas
|
||||||
.allowHardware(false)
|
.allowHardware(false)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
return imageLoader.execute(request).image
|
return imageLoader.execute(request).image
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -277,29 +301,34 @@ class LocationPinRenderer(
|
||||||
borderColor: Color,
|
borderColor: Color,
|
||||||
backgroundColor: Color,
|
backgroundColor: Color,
|
||||||
foregroundColor: Color,
|
foregroundColor: Color,
|
||||||
|
dimensions: PinDimensions,
|
||||||
) {
|
) {
|
||||||
val centerX = pinWidthPx / 2
|
val centerX = dimensions.pinWidth / 2
|
||||||
val avatarY = avatarOffsetPx
|
val avatarY = dimensions.avatarOffset
|
||||||
val avatarRadius = avatarSizePx / 2
|
val avatarRadius = dimensions.avatarSize / 2
|
||||||
|
|
||||||
withSave {
|
withSave {
|
||||||
val clipPath = Path().apply {
|
|
||||||
addCircle(centerX, avatarY + avatarRadius, avatarRadius, Path.Direction.CW)
|
|
||||||
}
|
|
||||||
clipPath(clipPath)
|
|
||||||
if (avatarImage != null) {
|
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(
|
val destRect = RectF(
|
||||||
centerX - avatarRadius,
|
centerX - avatarRadius,
|
||||||
avatarY,
|
avatarY,
|
||||||
centerX + avatarRadius,
|
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 {
|
} else {
|
||||||
// Fallback: draw initial letter circle
|
|
||||||
drawInitialLetterAvatar(
|
drawInitialLetterAvatar(
|
||||||
avatarData = avatarData,
|
initialLetter = avatarData.initialLetter,
|
||||||
centerX = centerX,
|
centerX = centerX,
|
||||||
centerY = avatarY + avatarRadius,
|
centerY = avatarY + avatarRadius,
|
||||||
radius = avatarRadius,
|
radius = avatarRadius,
|
||||||
|
|
@ -308,56 +337,35 @@ class LocationPinRenderer(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val paintBorder = Paint().apply {
|
strokePaint.color = borderColor.toArgb()
|
||||||
color = borderColor.toArgb()
|
strokePaint.strokeWidth = dimensions.strokeWidth
|
||||||
style = Paint.Style.STROKE
|
drawCircle(centerX, avatarY + avatarRadius, avatarRadius, strokePaint)
|
||||||
strokeWidth = strokeWidthPx
|
|
||||||
isAntiAlias = true
|
|
||||||
}
|
|
||||||
drawCircle(centerX, avatarY + avatarRadius, avatarRadius, paintBorder)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Canvas.drawInitialLetterAvatar(
|
private fun Canvas.drawInitialLetterAvatar(
|
||||||
avatarData: AvatarData,
|
initialLetter: String,
|
||||||
centerX: Float,
|
centerX: Float,
|
||||||
centerY: Float,
|
centerY: Float,
|
||||||
radius: Float,
|
radius: Float,
|
||||||
foreground: Int,
|
foreground: Int,
|
||||||
background: Int,
|
background: Int,
|
||||||
) {
|
) {
|
||||||
// Draw background circle
|
fillPaint.color = background
|
||||||
drawCircle(centerX, centerY, radius, Paint().apply {
|
drawCircle(centerX, centerY, radius, fillPaint)
|
||||||
color = background
|
textPaint.color = foreground
|
||||||
style = Paint.Style.FILL
|
textPaint.textSize = radius * 1.2f
|
||||||
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
|
|
||||||
val textBounds = Rect()
|
val textBounds = Rect()
|
||||||
textPaint.getTextBounds(avatarData.initialLetter, 0, 1, textBounds)
|
textPaint.getTextBounds(initialLetter, 0, 1, textBounds)
|
||||||
val textY = centerY + textBounds.height() / 2f
|
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
|
if (dotColor == Color.Transparent) return
|
||||||
|
val centerX = dimensions.pinWidth / 2
|
||||||
val centerX = pinWidthPx / 2
|
val centerY = dimensions.avatarOffset + dimensions.avatarSize / 2
|
||||||
// Position dot in the center of the circular part of the pin
|
fillPaint.color = dotColor.toArgb()
|
||||||
val centerY = avatarOffsetPx + avatarSizePx / 2
|
drawCircle(centerX, centerY, dimensions.dotRadius, fillPaint)
|
||||||
|
|
||||||
drawCircle(centerX, centerY, dotRadiusPx, Paint().apply {
|
|
||||||
color = dotColor.toArgb()
|
|
||||||
style = Paint.Style.FILL
|
|
||||||
isAntiAlias = true
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,16 +33,10 @@ internal fun ImageAvatar(
|
||||||
forcedAvatarSize: Dp?,
|
forcedAvatarSize: Dp?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
contentDescription: String? = null,
|
contentDescription: String? = null,
|
||||||
configureRequest: (ImageRequest.Builder) -> ImageRequest.Builder = { it },
|
|
||||||
) {
|
) {
|
||||||
val size = forcedAvatarSize ?: avatarData.size.dp
|
val size = forcedAvatarSize ?: avatarData.size.dp
|
||||||
val request = ImageRequest.Builder(LocalContext.current)
|
|
||||||
.data(avatarData)
|
|
||||||
.let(configureRequest)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
SubcomposeAsyncImage(
|
SubcomposeAsyncImage(
|
||||||
model = request,
|
model = avatarData,
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue