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)
implementation(projects.features.messages.api)
implementation(libs.maplibre.compose)
implementation(libs.coil)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
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.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.sp
import io.element.android.compound.theme.ElementTheme
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.rememberLocationPinBitmap
import kotlinx.serialization.json.JsonPrimitive
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.const
import org.maplibre.compose.expressions.dsl.eq
import org.maplibre.compose.expressions.dsl.feature
import org.maplibre.compose.expressions.dsl.image
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.SymbolPlacement
import org.maplibre.compose.layers.CircleLayer
import org.maplibre.compose.layers.SymbolLayer
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.
*
* 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.
*
* Must be used within a MaplibreMap content block.
*
* @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 onClusterClick Callback when a cluster is clicked, provides cluster center position
*/
@@ -157,36 +148,23 @@ private fun LocationPinMarkerLayer(
source: GeoJsonSource,
onMarkerClick: ((LocationMarkerData) -> Unit)?,
) {
val imageBitmap = rememberLocationPinImage(marker.variant)
SymbolLayer(
id = "pin-marker-${marker.id}",
source = source,
filter = !feature.has("point_count") and (feature["id"].asString() eq const(marker.id)),
iconImage = image(imageBitmap),
iconAnchor = const(SymbolAnchor.Bottom),
iconAllowOverlap = const(true),
onClick = { features ->
if (features.isNotEmpty() && onMarkerClick != null) {
onMarkerClick(marker)
ClickResult.Consume
} 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
val imageBitmap = rememberLocationPinBitmap(marker.variant)
if (imageBitmap != null) {
SymbolLayer(
id = "pin-marker-${marker.id}",
source = source,
filter = !feature.has("point_count") and (feature["id"].asString() eq const(marker.id)),
iconImage = image(imageBitmap),
iconAnchor = const(SymbolAnchor.Bottom),
iconAllowOverlap = const(true),
onClick = { features ->
if (features.isNotEmpty() && onMarkerClick != null) {
onMarkerClick(marker)
ClickResult.Consume
} else {
ClickResult.Pass
}
},
)
}
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
}