Use android.graphic.canvas to create proper bitmap
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user