Use android.graphic.canvas to create proper bitmap

This commit is contained in:
ganfra
2026-03-05 21:50:56 +01:00
parent 4704a6fc2a
commit d53db78856
4 changed files with 320 additions and 266 deletions

View File

@@ -28,6 +28,7 @@ dependencies {
api(projects.features.location.api) api(projects.features.location.api)
implementation(projects.features.messages.api) implementation(projects.features.messages.api)
implementation(libs.maplibre.compose) implementation(libs.maplibre.compose)
implementation(libs.coil)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.di) implementation(projects.libraries.di)

View File

@@ -9,27 +9,20 @@ package io.element.android.features.location.impl.common.ui
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.location.api.Location import io.element.android.features.location.api.Location
import io.element.android.libraries.designsystem.components.LocationPin
import io.element.android.libraries.designsystem.components.PinVariant import io.element.android.libraries.designsystem.components.PinVariant
import io.element.android.libraries.designsystem.components.rememberLocationPinBitmap
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
import org.maplibre.compose.expressions.dsl.and import org.maplibre.compose.expressions.dsl.and
import org.maplibre.compose.expressions.dsl.asNumber
import org.maplibre.compose.expressions.dsl.asString import org.maplibre.compose.expressions.dsl.asString
import org.maplibre.compose.expressions.dsl.const import org.maplibre.compose.expressions.dsl.const
import org.maplibre.compose.expressions.dsl.eq import org.maplibre.compose.expressions.dsl.eq
import org.maplibre.compose.expressions.dsl.feature import org.maplibre.compose.expressions.dsl.feature
import org.maplibre.compose.expressions.dsl.image import org.maplibre.compose.expressions.dsl.image
import org.maplibre.compose.expressions.dsl.not import org.maplibre.compose.expressions.dsl.not
import org.maplibre.compose.expressions.dsl.step
import org.maplibre.compose.expressions.value.SymbolAnchor import org.maplibre.compose.expressions.value.SymbolAnchor
import org.maplibre.compose.expressions.value.SymbolPlacement
import org.maplibre.compose.layers.CircleLayer import org.maplibre.compose.layers.CircleLayer
import org.maplibre.compose.layers.SymbolLayer import org.maplibre.compose.layers.SymbolLayer
import org.maplibre.compose.sources.GeoJsonData import org.maplibre.compose.sources.GeoJsonData
@@ -60,14 +53,12 @@ data class LocationMarkerData(
* A composable that renders location markers on a MapLibre map with clustering support. * A composable that renders location markers on a MapLibre map with clustering support.
* *
* Uses GeoJSON source with clustering enabled to group nearby markers. * Uses GeoJSON source with clustering enabled to group nearby markers.
* Individual markers are rendered using [LocationPin] composable converted to bitmaps. * Individual markers are rendered using Canvas-based pin rendering with Coil for avatar loading.
* Clusters are rendered as circles with point counts. * Clusters are rendered as circles with point counts.
* *
* Must be used within a MaplibreMap content block. * Must be used within a MaplibreMap content block.
* *
* @param markers List of markers to display on the map * @param markers List of markers to display on the map
* @param clusterRadius Radius of each cluster when clustering points (default 50)
* @param clusterMaxZoom Maximum zoom level at which to cluster points (default 14)
* @param onMarkerClick Callback when a marker is clicked * @param onMarkerClick Callback when a marker is clicked
* @param onClusterClick Callback when a cluster is clicked, provides cluster center position * @param onClusterClick Callback when a cluster is clicked, provides cluster center position
*/ */
@@ -157,36 +148,23 @@ private fun LocationPinMarkerLayer(
source: GeoJsonSource, source: GeoJsonSource,
onMarkerClick: ((LocationMarkerData) -> Unit)?, onMarkerClick: ((LocationMarkerData) -> Unit)?,
) { ) {
val imageBitmap = rememberLocationPinImage(marker.variant) val imageBitmap = rememberLocationPinBitmap(marker.variant)
SymbolLayer( if (imageBitmap != null) {
id = "pin-marker-${marker.id}", SymbolLayer(
source = source, id = "pin-marker-${marker.id}",
filter = !feature.has("point_count") and (feature["id"].asString() eq const(marker.id)), source = source,
iconImage = image(imageBitmap), filter = !feature.has("point_count") and (feature["id"].asString() eq const(marker.id)),
iconAnchor = const(SymbolAnchor.Bottom), iconImage = image(imageBitmap),
iconAllowOverlap = const(true), iconAnchor = const(SymbolAnchor.Bottom),
onClick = { features -> iconAllowOverlap = const(true),
if (features.isNotEmpty() && onMarkerClick != null) { onClick = { features ->
onMarkerClick(marker) if (features.isNotEmpty() && onMarkerClick != null) {
ClickResult.Consume onMarkerClick(marker)
} else { ClickResult.Consume
ClickResult.Pass } else {
} ClickResult.Pass
}, }
) },
}
/**
* Renders a LocationPin composable to an ImageBitmap for use in SymbolLayer.
*/
@Composable
private fun rememberLocationPinImage(variant: PinVariant): ImageBitmap {
val bitmap = rememberMarkerBitmap(variant) {
LocationPin(
variant = variant,
// Disable as it doesn't work with the rememberMarkerBitmap method
allowHardwareBitmapRendering = false
) )
} }
return bitmap.asImageBitmap()
} }

View File

@@ -1,87 +0,0 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.impl.common.ui
import android.graphics.Bitmap
import android.graphics.Canvas
import android.os.Build
import android.view.View
import android.view.ViewGroup
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionContext
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCompositionContext
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalView
import androidx.core.graphics.createBitmap
/**
* Renders a composable [content] to an Android [Bitmap].
* Useful for MapLibre SymbolLayer rendering.
*
* Uses a temporary ComposeView to render off-screen without
* adding to the visible composition tree.
*
* Note: This function provides a software-only ImageLoader to avoid
* "Software rendering doesn't support hardware bitmaps" errors when
* rendering Coil images to a Canvas.
*
* @param keys to trigger recomposition.
* @return The rendered Android [Bitmap].
*/
@Composable
fun rememberMarkerBitmap(
vararg keys: Any,
content: @Composable () -> Unit,
): Bitmap {
val parent = LocalView.current as ViewGroup
val compositionContext = rememberCompositionContext()
return remember(parent, compositionContext, *keys) {
renderComposableToBitmap(parent, compositionContext, content)
}
}
private fun renderComposableToBitmap(
parent: ViewGroup,
compositionContext: CompositionContext,
content: @Composable () -> Unit,
): Bitmap {
val composeView = ComposeView(parent.context).apply {
setParentCompositionContext(compositionContext)
setContent(content)
}
// Temporarily add to parent for measurement
parent.addView(
composeView,
ViewGroup.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
)
// Measure
composeView.measure(
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
)
val width = composeView.measuredWidth
val height = composeView.measuredHeight
// Layout
composeView.layout(0, 0, width, height)
// Draw to bitmap
val bitmap = createBitmap(width, height)
val canvas = Canvas(bitmap)
composeView.draw(canvas)
// Cleanup
parent.removeView(composeView)
return bitmap
}

View File

@@ -7,31 +7,42 @@
package io.element.android.libraries.designsystem.components package io.element.android.libraries.designsystem.components
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.Path
import android.graphics.Rect
import android.graphics.RectF
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.offset
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.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
import androidx.compose.ui.graphics.Matrix import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.drawscope.Fill import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.graphics.createBitmap
import androidx.core.graphics.withSave
import coil3.Image
import coil3.SingletonImageLoader
import coil3.request.ImageRequest
import coil3.request.allowHardware import coil3.request.allowHardware
import coil3.toBitmap
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.avatar.avatarShape
import io.element.android.libraries.designsystem.components.avatar.internal.ImageAvatar
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
@@ -48,11 +59,6 @@ sealed interface PinVariant {
data object StaleLocation : PinVariant data object StaleLocation : PinVariant
} }
private val PIN_MARKER_WIDTH = 42.dp
private val PIN_MARKER_HEIGHT = (PIN_MARKER_WIDTH * 1.2f)
private val DOT_RADIUS = 6.dp
private val CONTENT_OFFSET = 5.dp
/** /**
* A location pin composable that supports multiple variants. * A location pin composable that supports multiple variants.
* *
@@ -62,141 +68,297 @@ private val CONTENT_OFFSET = 5.dp
fun LocationPin( fun LocationPin(
variant: PinVariant, variant: PinVariant,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
allowHardwareBitmapRendering: Boolean = true,
) { ) {
val colors = LocationPinColors.fromVariant(variant) val image = rememberLocationPinBitmap(variant)
Box( Canvas(modifier = modifier.size(PIN_WIDTH, PIN_HEIGHT)) {
modifier = modifier.size(width = PIN_MARKER_WIDTH, height = PIN_MARKER_HEIGHT), if (image != null) {
) { drawImage(image)
Canvas(modifier = Modifier.matchParentSize()) {
drawPinShape(
fillColor = colors.fill,
strokeColor = colors.stroke,
strokeWidth = 1.dp.toPx(),
)
}
val avatarSize = PIN_MARKER_WIDTH - CONTENT_OFFSET * 2
val contentModifier = Modifier
.align(Alignment.TopCenter)
.offset(y = CONTENT_OFFSET)
when (variant) {
is PinVariant.UserLocation -> {
val avatarShape = AvatarType.User.avatarShape()
ImageAvatar(
avatarData = variant.avatarData,
forcedAvatarSize = avatarSize,
avatarShape = avatarShape,
modifier = contentModifier
.border(width = 1.dp, color = colors.avatarStoke, shape = avatarShape),
configureRequest = { builder ->
builder.allowHardware(allowHardwareBitmapRendering)
}
)
}
PinVariant.PinnedLocation, PinVariant.StaleLocation -> {
Canvas(
modifier = contentModifier.size(avatarSize)
) {
drawCircle(
color = colors.dotColor,
radius = DOT_RADIUS.toPx(),
center = center,
)
}
}
}
}
}
private data class LocationPinColors(
val fill: Color,
val stroke: Color,
val dotColor: Color,
val avatarStoke: Color,
) {
companion object {
@Composable
fun fromVariant(variant: PinVariant): LocationPinColors {
return when (variant) {
is PinVariant.UserLocation ->
if (variant.isLive) {
LocationPinColors(
fill = ElementTheme.colors.iconAccentPrimary,
stroke = ElementTheme.colors.bgCanvasDefault,
dotColor = Color.Transparent,
avatarStoke = ElementTheme.colors.bgCanvasDefault,
)
} else {
LocationPinColors(
fill = ElementTheme.colors.bgCanvasDefault,
stroke = ElementTheme.colors.iconQuaternaryAlpha,
dotColor = Color.Transparent,
avatarStoke = ElementTheme.colors.iconQuaternaryAlpha,
)
}
PinVariant.PinnedLocation -> LocationPinColors(
fill = ElementTheme.colors.bgCanvasDefault,
stroke = ElementTheme.colors.iconSecondaryAlpha,
dotColor = ElementTheme.colors.iconPrimary,
avatarStoke = Color.Transparent,
)
PinVariant.StaleLocation -> LocationPinColors(
fill = ElementTheme.colors.bgSubtleSecondary,
stroke = ElementTheme.colors.iconDisabled,
dotColor = ElementTheme.colors.iconDisabled,
avatarStoke = Color.Transparent,
)
}
} }
} }
} }
/** /**
* Draws a teardrop-shaped pin with smooth curves. * Renders a location pin to an [ImageBitmap] using Canvas operations.
* * @param variant The pin variant to render
* Based on SVG path with dimensions 40x48 (ratio 1:1.2). * @return The rendered [ImageBitmap], or null if still loading
* Scales automatically to fit the canvas size.
*/ */
private fun DrawScope.drawPinShape( @Composable
fillColor: Color, fun rememberLocationPinBitmap(variant: PinVariant): ImageBitmap? {
strokeColor: Color, val context = LocalContext.current
strokeWidth: Float, val density = LocalDensity.current
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()
}.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) {
is PinVariant.UserLocation -> {
val avatarColors = AvatarColorsProvider.provide(variant.avatarData.id)
if (variant.isLive) {
PinColors(
fill = ElementTheme.colors.iconAccentPrimary,
stroke = Color.Transparent,
dot = Color.Transparent,
avatarStroke = ElementTheme.colors.bgCanvasDefault,
avatarBackground = avatarColors.background,
avatarForeground = avatarColors.foreground,
)
} else {
PinColors(
fill = ElementTheme.colors.bgCanvasDefault,
stroke = ElementTheme.colors.iconQuaternaryAlpha,
dot = Color.Transparent,
avatarStroke = ElementTheme.colors.iconQuaternaryAlpha,
avatarBackground = avatarColors.background,
avatarForeground = avatarColors.foreground,
)
}
}
PinVariant.PinnedLocation -> PinColors(
fill = ElementTheme.colors.bgCanvasDefault,
stroke = ElementTheme.colors.iconSecondaryAlpha,
avatarStroke = Color.Transparent,
avatarBackground = Color.Transparent,
avatarForeground = Color.Transparent,
dot = ElementTheme.colors.iconPrimary,
)
PinVariant.StaleLocation -> PinColors(
fill = ElementTheme.colors.bgSubtleSecondary,
stroke = ElementTheme.colors.iconDisabled,
avatarStroke = Color.Transparent,
avatarBackground = Color.Transparent,
avatarForeground = Color.Transparent,
dot = ElementTheme.colors.iconDisabled,
)
}
}
/**
* Color configuration for rendering a location pin.
*/
data class PinColors(
val fill: Color,
val stroke: Color,
val dot: Color,
val avatarStroke: Color,
val avatarBackground: Color,
val avatarForeground: Color,
)
/**
* Renders location pins to bitmaps using Canvas operations.
* Uses Coil for avatar loading with proper memory management.
*/
class LocationPinRenderer(
private val context: Context,
private val density: Density,
) { ) {
val svgWidth = 40f // Dimensions in pixels
val svgHeight = 48f private val pinWidthPx = with(density) { PIN_WIDTH.toPx() }
val inset = strokeWidth / 2 private val pinHeightPx = with(density) { PIN_HEIGHT.toPx() }
val scaleX = (size.width - strokeWidth) / svgWidth private val avatarSizePx = with(density) { AVATAR_SIZE.toPx() }
val scaleY = (size.height - strokeWidth) / svgHeight private val avatarOffsetPx = with(density) { CONTENT_OFFSET.toPx() }
private val dotRadiusPx = with(density) { DOT_RADIUS.toPx() }
private val strokeWidthPx = with(density) { STROKE_WIDTH.toPx() }
val path = Path().apply { /**
moveTo(20f, 48f) * Renders a pin variant to bitmap. Suspending for async avatar loading.
cubicTo(19.4167f, 48f, 18.8333f, 47.8965f, 18.25f, 47.6895f) */
cubicTo(17.6667f, 47.4825f, 17.1458f, 47.1721f, 16.6875f, 46.7581f) suspend fun renderPin(
cubicTo(13.9792f, 44.2743f, 11.5833f, 41.8525f, 9.5f, 39.4929f) variant: PinVariant,
cubicTo(7.41667f, 37.1332f, 5.67708f, 34.8461f, 4.28125f, 32.6313f) colors: PinColors,
cubicTo(2.88542f, 30.4166f, 1.82292f, 28.2846f, 1.09375f, 26.2354f) ): Bitmap {
cubicTo(0.364583f, 24.1863f, 0f, 22.2303f, 0f, 20.3674f) val bitmap = createBitmap(pinWidthPx.toInt(), pinHeightPx.toInt())
cubicTo(0f, 14.1578f, 2.01042f, 9.21087f, 6.03125f, 5.52652f) val canvas = Canvas(bitmap)
cubicTo(10.0521f, 1.84217f, 14.7083f, 0f, 20f, 0f) // Draw pin shape (fill + stroke)
cubicTo(25.2917f, 0f, 29.9479f, 1.84217f, 33.9688f, 5.52652f) canvas.drawPinShape(colors.fill, colors.stroke)
cubicTo(37.9896f, 9.21087f, 40f, 14.1578f, 40f, 20.3674f) when (variant) {
cubicTo(40f, 22.2303f, 39.6354f, 24.1863f, 38.9062f, 26.2354f) is PinVariant.UserLocation -> {
cubicTo(38.1771f, 28.2846f, 37.1146f, 30.4166f, 35.7188f, 32.6313f) val avatarImage = loadAvatarImage(variant.avatarData)
cubicTo(34.3229f, 34.8461f, 32.5833f, 37.1332f, 30.5f, 39.4929f) canvas.drawAvatar(
cubicTo(28.4167f, 41.8525f, 26.0208f, 44.2743f, 23.3125f, 46.7581f) avatarImage = avatarImage,
cubicTo(22.8542f, 47.1721f, 22.3333f, 47.4825f, 21.75f, 47.6895f) avatarData = variant.avatarData,
cubicTo(21.1667f, 47.8965f, 20.5833f, 48f, 20f, 48f) borderColor = colors.avatarStroke,
close() backgroundColor = colors.avatarBackground,
foregroundColor = colors.avatarForeground
)
}
PinVariant.PinnedLocation,
PinVariant.StaleLocation -> canvas.drawDot(colors.dot)
}
return bitmap
}
transform(Matrix().apply { private fun Canvas.drawPinShape(fillColor: Color, strokeColor: Color) {
scale(scaleX, scaleY) val path = createPinPath()
translate(inset / scaleX, inset / scaleY) // 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
}) })
} }
drawPath(path = path, color = fillColor, style = Fill) /**
drawPath(path = path, color = strokeColor, style = Stroke(width = strokeWidth)) * Creates the teardrop-shaped pin path.
* Based on SVG path with dimensions 40x48 (ratio 1:1.2).
* Scales automatically to fit the actual size.
*/
private fun createPinPath(): Path {
val svgWidth = 40f
val svgHeight = 48f
val inset = strokeWidthPx / 2
val scaleX = (pinWidthPx - strokeWidthPx) / svgWidth
val scaleY = (pinHeightPx - strokeWidthPx) / svgHeight
val path = Path().apply {
moveTo(20f, 48f)
cubicTo(19.4167f, 48f, 18.8333f, 47.8965f, 18.25f, 47.6895f)
cubicTo(17.6667f, 47.4825f, 17.1458f, 47.1721f, 16.6875f, 46.7581f)
cubicTo(13.9792f, 44.2743f, 11.5833f, 41.8525f, 9.5f, 39.4929f)
cubicTo(7.41667f, 37.1332f, 5.67708f, 34.8461f, 4.28125f, 32.6313f)
cubicTo(2.88542f, 30.4166f, 1.82292f, 28.2846f, 1.09375f, 26.2354f)
cubicTo(0.364583f, 24.1863f, 0f, 22.2303f, 0f, 20.3674f)
cubicTo(0f, 14.1578f, 2.01042f, 9.21087f, 6.03125f, 5.52652f)
cubicTo(10.0521f, 1.84217f, 14.7083f, 0f, 20f, 0f)
cubicTo(25.2917f, 0f, 29.9479f, 1.84217f, 33.9688f, 5.52652f)
cubicTo(37.9896f, 9.21087f, 40f, 14.1578f, 40f, 20.3674f)
cubicTo(40f, 22.2303f, 39.6354f, 24.1863f, 38.9062f, 26.2354f)
cubicTo(38.1771f, 28.2846f, 37.1146f, 30.4166f, 35.7188f, 32.6313f)
cubicTo(34.3229f, 34.8461f, 32.5833f, 37.1332f, 30.5f, 39.4929f)
cubicTo(28.4167f, 41.8525f, 26.0208f, 44.2743f, 23.3125f, 46.7581f)
cubicTo(22.8542f, 47.1721f, 22.3333f, 47.4825f, 21.75f, 47.6895f)
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)
}
path.transform(matrix)
return path
}
private suspend fun loadAvatarImage(avatarData: AvatarData): Image? {
val imageLoader = SingletonImageLoader.get(context)
val request = ImageRequest.Builder(context)
.data(avatarData)
.size(avatarSizePx.toInt())
// Disable hardware rendering for Canvas
.allowHardware(false)
.build()
return imageLoader.execute(request).image
}
private fun Canvas.drawAvatar(
avatarImage: Image?,
avatarData: AvatarData,
borderColor: Color,
backgroundColor: Color,
foregroundColor: Color,
) {
val centerX = pinWidthPx / 2
val avatarY = avatarOffsetPx
val avatarRadius = avatarSizePx / 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 destRect = RectF(
centerX - avatarRadius,
avatarY,
centerX + avatarRadius,
avatarY + avatarSizePx
)
drawBitmap(avatarImage.toBitmap(), null, destRect, null)
} else {
// Fallback: draw initial letter circle
drawInitialLetterAvatar(
avatarData = avatarData,
centerX = centerX,
centerY = avatarY + avatarRadius,
radius = avatarRadius,
foreground = foregroundColor.toArgb(),
background = backgroundColor.toArgb()
)
}
}
val paintBorder = Paint().apply {
color = borderColor.toArgb()
style = Paint.Style.STROKE
strokeWidth = strokeWidthPx
isAntiAlias = true
}
drawCircle(centerX, avatarY + avatarRadius, avatarRadius, paintBorder)
}
private fun Canvas.drawInitialLetterAvatar(
avatarData: AvatarData,
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
val textBounds = Rect()
textPaint.getTextBounds(avatarData.initialLetter, 0, 1, textBounds)
val textY = centerY + textBounds.height() / 2f
drawText(avatarData.initialLetter, centerX, textY, textPaint)
}
private fun Canvas.drawDot(dotColor: Color) {
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
})
}
} }
@PreviewsDayNight @PreviewsDayNight