Introduce LocationPinMarkers composable

This commit is contained in:
ganfra
2026-03-05 14:50:55 +01:00
parent 34aad88023
commit 3cce8caec4
7 changed files with 343 additions and 70 deletions

View File

@@ -19,7 +19,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
@@ -32,12 +31,10 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.features.location.api.internal.StaticMapPlaceholder
import io.element.android.features.location.api.internal.StaticMapUrlBuilder
import io.element.android.features.location.api.internal.centerBottomEdge
import io.element.android.libraries.designsystem.components.LocationPinMarker
import io.element.android.libraries.designsystem.components.LocationPin
import io.element.android.libraries.designsystem.components.PinVariant
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.utils.CommonDrawables
/**
* Shows a static map image downloaded via a third party service's static maps API.
@@ -98,7 +95,7 @@ fun StaticMapView(
// We apply ContentScale.Fit to scale the image to fill the AsyncImage should this be the case.
contentScale = ContentScale.Fit,
)
LocationPinMarker(variant = pinVariant, modifier = Modifier.centerBottomEdge(this))
LocationPin(variant = pinVariant, modifier = Modifier.centerBottomEdge(this))
} else {
StaticMapPlaceholder(
showProgress = collectedState.value.isLoading(),

View File

@@ -0,0 +1,188 @@
/*
* 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 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 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
import org.maplibre.compose.sources.GeoJsonOptions
import org.maplibre.compose.sources.GeoJsonSource
import org.maplibre.compose.sources.rememberGeoJsonSource
import org.maplibre.compose.util.ClickResult
import org.maplibre.spatialk.geojson.Feature
import org.maplibre.spatialk.geojson.FeatureCollection
import org.maplibre.spatialk.geojson.Point
import org.maplibre.spatialk.geojson.Position
import org.maplibre.spatialk.geojson.toJson
/**
* Data class representing a marker on the map.
*
* @param id Unique identifier for the marker
* @param location The geographic location of the marker
* @param variant The visual variant of the pin (user location, pinned, stale)
*/
data class LocationMarkerData(
val id: String,
val location: Location,
val variant: PinVariant,
)
/**
* 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.
* 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
*/
@Composable
fun LocationPinMarkers(
markers: List<LocationMarkerData>,
onMarkerClick: ((LocationMarkerData) -> Unit)? = null,
onClusterClick: ((Position) -> Unit)? = null,
) {
if (markers.isEmpty()) return
val clusterColor = ElementTheme.colors.bgAccentRest
val clusterStrokeColor = ElementTheme.colors.iconOnSolidPrimary
val clusterTextColor = ElementTheme.colors.textOnSolidPrimary
val clusterTextStyle = ElementTheme.typography.fontBodyMdMedium
// Convert markers to GeoJSON
val geoJsonString = remember(markers) {
val features = markers.map { marker ->
Feature(
id = JsonPrimitive(marker.id),
geometry = Point(Position(marker.location.lon, marker.location.lat)),
properties = mapOf(
"id" to JsonPrimitive(marker.id),
)
)
}
FeatureCollection(features).toJson()
}
// Create GeoJSON source with clustering
val markersSource = rememberGeoJsonSource(
data = GeoJsonData.JsonString(geoJsonString),
options = GeoJsonOptions(
cluster = true,
clusterMinPoints = 3,
clusterRadius = 30
),
)
// Cluster circle layer
CircleLayer(
id = "cluster-circles",
source = markersSource,
filter = feature.has("point_count"),
color = const(clusterColor),
radius = const(24.dp),
strokeWidth = const(1.dp),
strokeColor = const(clusterStrokeColor),
onClick = { features ->
features.firstOrNull()?.let { feat ->
val point = feat.geometry as? Point
if (point != null && onClusterClick != null) {
onClusterClick(point.coordinates)
ClickResult.Consume
} else {
ClickResult.Pass
}
} ?: ClickResult.Pass
},
)
// Cluster count text layer
SymbolLayer(
id = "cluster-count",
source = markersSource,
filter = feature.has("point_count"),
textField = feature["point_count_abbreviated"].asString(),
textColor = const(clusterTextColor),
textSize = const(clusterTextStyle.fontSize),
textFont = const(listOfNotNull(clusterTextStyle.fontFamily?.toString())),
textLetterSpacing = const(clusterTextStyle.letterSpacing),
)
// Individual marker layers - one per marker for unique avatars
markers.forEach { marker ->
LocationPinMarkerLayer(
marker = marker,
source = markersSource,
onMarkerClick = onMarkerClick,
)
}
}
@Composable
private fun LocationPinMarkerLayer(
marker: LocationMarkerData,
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)
}
return bitmap.asImageBitmap()
}

View File

@@ -1,37 +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 androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import org.maplibre.compose.camera.CameraState
import org.maplibre.spatialk.geojson.Position
@Composable
fun MapProjected(
target: Position,
cameraState: CameraState,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Box(
modifier = modifier
.graphicsLayer {
cameraState.position
val offset = cameraState.projection?.screenLocationFromPosition(target)
if (offset != null) {
translationX = offset.x.toPx() - size.width / 2
translationY = offset.y.toPx() - size.height
}
}
) {
content()
}
}

View File

@@ -0,0 +1,87 @@
/*
* 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

@@ -40,7 +40,7 @@ import io.element.android.features.location.impl.common.PermissionRationaleDialo
import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton
import io.element.android.features.location.impl.common.ui.MapBottomSheetScaffold
import io.element.android.features.location.impl.common.ui.UserLocationPuck
import io.element.android.libraries.designsystem.components.LocationPinMarker
import io.element.android.libraries.designsystem.components.LocationPin
import io.element.android.libraries.designsystem.components.PinVariant
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton
@@ -160,7 +160,7 @@ fun ShareLocationView(
} else {
PinVariant.PinnedLocation
}
LocationPinMarker(
LocationPin(
variant = variant,
modifier = Modifier.centerBottomEdge(this),
)

View File

@@ -8,8 +8,6 @@
package io.element.android.features.location.impl.show
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetValue
@@ -17,25 +15,23 @@ import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.compound.tokens.generated.TypographyTokens
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.ShowLocationMode
import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.features.location.impl.common.PermissionDeniedDialog
import io.element.android.features.location.impl.common.PermissionRationaleDialog
import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton
import io.element.android.features.location.impl.common.ui.LocationMarkerData
import io.element.android.features.location.impl.common.ui.LocationPinMarkers
import io.element.android.features.location.impl.common.ui.MapBottomSheetScaffold
import io.element.android.features.location.impl.common.ui.MapProjected
import io.element.android.features.location.impl.common.ui.UserLocationPuck
import io.element.android.libraries.designsystem.components.LocationPinMarker
import io.element.android.libraries.designsystem.components.PinVariant
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@@ -44,7 +40,6 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.ui.strings.CommonStrings
@@ -56,6 +51,10 @@ import org.maplibre.compose.location.rememberDefaultLocationProvider
import org.maplibre.compose.location.rememberNullLocationProvider
import org.maplibre.compose.location.rememberUserLocationState
import org.maplibre.spatialk.geojson.Position
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
import kotlin.random.Random
import kotlin.time.Duration.Companion.minutes
@OptIn(ExperimentalMaterial3Api::class)
@@ -138,8 +137,6 @@ fun ShowLocationView(
locationState = userLocationState,
trackUserLocation = state.isTrackMyLocation
)
},
overlayContent = {
when (val mode = state.mode) {
is ShowLocationMode.Static -> {
val pinVariant = if (mode.assetType == AssetType.PIN) {
@@ -147,23 +144,64 @@ fun ShowLocationView(
} else {
PinVariant.UserLocation(
avatarData = AvatarData(mode.senderId.value, mode.senderName, mode.senderAvatarUrl, AvatarSize.UserListItem),
isLive = false
isLive = true
)
}
val position = Position(
latitude = mode.location.lat,
longitude = mode.location.lon
)
MapProjected(target = position, cameraState = cameraState) {
LocationPinMarker(variant = pinVariant)
// Generate test markers around the original location
val testMarkers = remember {
buildList {
// Add the original marker
add(
LocationMarkerData(
id = "original",
location = mode.location,
variant = pinVariant
)
)
// Generate 10 random points within 50 meters
val radiusInMeters = 50.0
val metersPerDegreeLat = 111_320.0
val metersPerDegreeLon = 111_320.0 * cos(Math.toRadians(mode.location.lat))
val variants = listOf(
PinVariant.StaleLocation,
PinVariant.UserLocation(AvatarData("@alice", "Alice", null, AvatarSize.TimelineSender), isLive = true),
PinVariant.UserLocation(AvatarData("@bob", "Bob", null, AvatarSize.TimelineSender), isLive = true),
PinVariant.UserLocation(AvatarData("@cassy", "Cassy", null, AvatarSize.TimelineSender), isLive = true),
PinVariant.UserLocation(AvatarData("@daisy", "Daisy", null, AvatarSize.TimelineSender), isLive = true),
PinVariant.UserLocation(AvatarData("@en", "G", null, AvatarSize.TimelineSender), isLive = true),
PinVariant.UserLocation(AvatarData("@f", "H", null, AvatarSize.TimelineSender), isLive = true),
PinVariant.UserLocation(AvatarData("@g", "I", null, AvatarSize.TimelineSender), isLive = true),
PinVariant.UserLocation(AvatarData("@h", "J", null, AvatarSize.TimelineSender), isLive = true),
PinVariant.UserLocation(AvatarData("@i", "K", null, AvatarSize.TimelineSender), isLive = true),
)
repeat(10) { index ->
// Random point in a circle using sqrt for uniform distribution
val angle = Random.nextDouble() * 2 * Math.PI
val distance = sqrt(Random.nextDouble()) * radiusInMeters
val latOffset = (distance * cos(angle)) / metersPerDegreeLat
val lonOffset = (distance * sin(angle)) / metersPerDegreeLon
add(
LocationMarkerData(
id = "test_$index",
location = Location(
lat = mode.location.lat + latOffset,
lon = mode.location.lon + lonOffset
),
variant = variants[index % (variants.size-1)]
)
)
}
}
}
LocationPinMarkers(testMarkers)
}
ShowLocationMode.Live -> {
// TODO: Show pins for all active live location sharers
}
}
},
overlayContent = {
LocationFloatingActionButton(
isMapCenteredOnUser = state.isTrackMyLocation,
onClick = { state.eventSink(ShowLocationEvents.TrackMyLocation(true)) },

View File

@@ -53,12 +53,12 @@ private val DOT_RADIUS = 6.dp
private val CONTENT_OFFSET = 5.dp
/**
* A location pin marker composable that supports multiple variants.
* A location pin composable that supports multiple variants.
*
* Based on Figma design: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=4665-2890&m=dev
*/
@Composable
fun LocationPinMarker(
fun LocationPin(
variant: PinVariant,
modifier: Modifier = Modifier,
) {
@@ -195,7 +195,7 @@ private fun DrawScope.drawPinShape(
@PreviewsDayNight
@Composable
internal fun LocationPinMarkerPreview() = ElementPreview {
internal fun LocationPinPreview() = ElementPreview {
val sampleAvatarData = AvatarData(
id = "@alice:matrix.org",
name = "Alice",
@@ -208,18 +208,18 @@ internal fun LocationPinMarkerPreview() = ElementPreview {
horizontalAlignment = Alignment.CenterHorizontally,
) {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
LocationPinMarker(
LocationPin(
variant = PinVariant.UserLocation(avatarData = sampleAvatarData, isLive = false),
)
LocationPinMarker(
LocationPin(
variant = PinVariant.UserLocation(avatarData = sampleAvatarData, isLive = true),
)
}
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
LocationPinMarker(
LocationPin(
variant = PinVariant.PinnedLocation,
)
LocationPinMarker(
LocationPin(
variant = PinVariant.StaleLocation,
)
}