Skip to content

InfoBubble

Una InfoBubble es un componente que muestra contenido personalizado en un formato de callout adjunto a marcadores en un mapa. Proporciona un método para mostrar información detallada sobre marcadores.

Una InfoBubble se muestra encima de un marcador específico. El contenido mostrado en la InfoBubble puede ser cualquier Composable, permitiendo diseños flexibles y personalizados.

@Composable
fun InfoBubble(
marker: MarkerState,
bubbleColor: Color = Color.White,
borderColor: Color = Color.Black,
contentPadding: Dp = 8.dp,
cornerRadius: Dp = 4.dp,
tailSize: Dp = 8.dp,
content: @Composable () -> Unit
)
  • marker: El MarkerState al que se adjunta la burbuja
  • bubbleColor: Color de fondo de la burbuja (predeterminado: White)
  • borderColor: Color del borde de la burbuja (predeterminado: Black)
  • contentPadding: Espaciado interno alrededor del contenido (predeterminado: 8dp)
  • cornerRadius: Radio de las esquinas redondeadas de la burbuja (predeterminado: 4dp)
  • tailSize: Tamaño de la cola que apunta al marcador (predeterminado: 8dp)
  • content: Contenido Composable para mostrar dentro de la burbuja
@Composable
fun SimpleInfoBubbleExample() {
val center = GeoPoint.fromLatLong(52.5163, 13.3777)
val camera = MapCameraPosition(position = center, zoom = 13)
val mapViewState = rememberHereMapViewState(cameraPosition = camera)
var selectedMarker by remember { mutableStateOf<MarkerState?>(null) }
val onMarkerClick: OnMarkerEventHandler = { markerState ->
selectedMarker = markerState
}
HereMapView(
modifier = modifier,
state = mapViewState,
onMapClick = { selectedMarker = null }
) {
val markerState = MarkerState(
position = center,
icon = DefaultIcon(fillColor = Color.Green, label = "POI"),
extra = "Brandenburg Gate",
id = "poi-marker",
onClick = onMarkerClick
)
Marker(markerState)
selectedMarker?.let { marker ->
InfoBubble(
marker = marker,
contentPadding = 12.dp,
cornerRadius = 8.dp,
tailSize = 10.dp
) {
Text(
text = marker.extra as? String ?: "",
color = Color.Black,
style = MaterialTheme.typography.bodyLarge
)
}
}
}
}

data class LocationInfo(
val name: String,
val description: String,
val rating: Float,
val imageUrl: String? = null
) : java.io.Serializable
@Composable
fun RichContentBubbleExample() {
var selectedMarker by remember { mutableStateOf<MarkerState?>(null) }
val center = GeoPoint.fromLatLong(37.7694, -122.4862)
val camera = MapCameraPosition(position = center, zoom = 13)
val mapViewState = rememberHereMapViewState(cameraPosition = camera)
val onMarkerClick: OnMarkerEventHandler = { markerState ->
selectedMarker = markerState
}
HereMapView(
modifier = modifier,
state = mapViewState,
onMapClick = { selectedMarker = null }
) {
val locationInfo = LocationInfo(
name = "Golden Gate Park",
description = "A large urban park with gardens, museums, and recreational areas.",
rating = 4.5f
)
val markerState = MarkerState(
position = GeoPoint.fromLatLong(37.7694, -122.4862),
icon = DefaultIcon(fillColor = Color.Green, label = "🌳"),
extra = locationInfo,
id = "park-marker",
onClick = onMarkerClick
)
Marker(markerState)
selectedMarker?.let { marker ->
val info = marker.extra as? LocationInfo
info?.let {
InfoBubble(
marker = marker,
bubbleColor = MaterialTheme.colorScheme.background,
borderColor = Color.Gray,
contentPadding = 16.dp,
cornerRadius = 12.dp
) {
Column(modifier = Modifier.width(200.dp)) {
Text(
text = info.name,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = info.description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
repeat(5) { index ->
Icon(
Icons.Default.Star,
contentDescription = null,
tint = if (index < info.rating.toInt()) Color.Yellow else Color.Gray,
modifier = Modifier.size(16.dp)
)
}
Text(
text = " ${info.rating}/5",
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 4.dp)
)
}
}
}
}
}
}
}

data class StoreInfo(
val name: String,
val address: String,
val phone: String,
val type: String
) : java.io.Serializable
@Composable
fun InteractiveBubbleExample(modifier: Modifier = Modifier) {
val center = GeoPoint.fromLatLong(35.70662, 139.76378)
val camera = MapCameraPosition(position = center, zoom = 16)
val mapViewState = rememberArcGISMapViewState(cameraPosition = camera)
var selectedMarker by remember { mutableStateOf<MarkerState?>(null) }
val context = LocalContext.current
val onMarkerClick: OnMarkerEventHandler = { markerState ->
selectedMarker = markerState
}
ArcGISMapView(
state = mapViewState,
modifier = modifier,
onMapClick = { selectedMarker = null }
) {
val storeInfo = StoreInfo(
name = "Coffee Shop",
address = "1-4, Hongo 4-Chome, Bunkyo-Ku",
phone = "+81 (555) 123-4567",
type = "coffee"
)
val markerState = MarkerState(
position = GeoPoint.fromLatLong(35.708106, 139.760823),
icon = DefaultIcon(fillColor = Color(0xFF8B4513), label = "☕"),
extra = storeInfo,
id = "coffee-shop-marker",
onClick = onMarkerClick
)
Marker(markerState)
selectedMarker?.let { marker ->
val store = marker.extra as? StoreInfo
store?.let {
InfoBubble(
marker = marker,
bubbleColor = Color.White,
borderColor = Color.Black,
contentPadding = 12.dp,
cornerRadius = 8.dp
) {
Column(modifier = Modifier.width(250.dp)) {
Text(
text = store.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text(
text = store.address,
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
modifier = Modifier.padding(vertical = 4.dp)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
Button(
onClick = {
val intent = Intent(Intent.ACTION_DIAL).apply {
data = Uri.parse("tel:${store.phone}")
}
context.startActivity(intent)
},
colors = ButtonDefaults.buttonColors(containerColor = Color.Blue)
) {
Icon(Icons.Default.Phone, contentDescription = "Call")
Text("Call", modifier = Modifier.padding(start = 4.dp))
}
}
}
}
}
}
}
}
@Composable
fun MultipleBubblesExample() {
val mapViewState = rememberGoogleMapViewState()
var selectedMarkers by remember { mutableStateOf(setOf<String>()) }
val onMarkerClick: OnMarkerEventHandler = { markerState ->
selectedMarkers = if (selectedMarkers.contains(markerState.id)) {
selectedMarkers - markerState.id // Deselect
} else {
selectedMarkers + markerState.id // Select
}
}
val markerData = remember {
listOf(
Triple(
GeoPoint.fromLatLong(37.7749, -122.4194),
"Restaurant A",
Color.Red
),
Triple(
GeoPoint.fromLatLong(37.7849, -122.4094),
"Hotel B",
Color.Blue
),
Triple(
GeoPoint.fromLatLong(37.7649, -122.4294),
"Shop C",
Color.Green
)
)
}
val markerStates = remember {
markerData.mapIndexed { index, (position, name, color) ->
MarkerState(
id = "marker_$index",
position = position,
icon = DefaultIcon(fillColor = color, label = "${index + 1}"),
extra = name,
onClick = onMarkerClick
)
}
}
// Reemplace MapView con el proveedor de mapas que elija, como GoogleMapView o MapboxMapView
MapView(
state = mapViewState,
onMapClick = {
selectedMarkers = emptySet() // Clear all selections
}
) {
markerStates.forEach { markerState ->
Marker(markerState)
// Show bubble if marker is selected
if (selectedMarkers.contains(markerState.id)) {
InfoBubble(
marker = markerState,
bubbleColor = Color.White,
borderColor = Color.Black
) {
Column {
Text(
text = markerState.extra as String,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold
)
Text(
text = "Toque para cerrar",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray
)
}
}
}
}
}
}

Para implementar un diseño personalizado, puede crear su propio componente basado en InfoBubble. El siguiente código y video muestran un ejemplo de una burbuja con una cola en el lado derecho.

@Composable
fun RightInfoBubbleMapExample(modifier: Modifier = Modifier) {
val center = GeoPoint.fromLatLong(37.7749, -122.4194)
val mapViewState =
rememberMapLibreMapViewState(
cameraPosition =
MapCameraPosition(
position = center,
zoom = 13,
),
)
var selectedMarker by remember { mutableStateOf<MarkerState?>(null) }
val onMarkerClick: OnMarkerEventHandler = { markerState ->
selectedMarker = markerState
}
val onMarkerDragStart: OnMarkerEventHandler = { markerState ->
println("Comenzó a arrastrar el marcador: ${markerState.id}")
}
val onMarkerDrag: OnMarkerEventHandler = { markerState ->
println("Arrastrando el marcador a: ${markerState.position}")
}
val onMarkerDragEnd: OnMarkerEventHandler = { markerState ->
println("Finalizó el arrastre del marcador: ${markerState.id}")
}
val markerState1 by remember {
mutableStateOf(
MarkerState(
id = "marker1",
position = GeoPoint.fromLatLong(37.7749, -122.4194),
icon =
DefaultIcon(
fillColor = Color.Blue,
infoAnchor = Offset(0.5f, 0.25f),
label = "1",
),
draggable = true,
onClick = onMarkerClick,
onDragStart = onMarkerDragStart,
onDrag = onMarkerDrag,
onDragEnd = onMarkerDragEnd,
),
)
}
val markerState2 by remember {
mutableStateOf(
MarkerState(
id = "marker2",
position = GeoPoint.fromLatLong(37.7849, -122.4094),
icon =
DefaultIcon(
fillColor = Color.Red,
infoAnchor = Offset(0.5f, 0.25f),
label = "2",
),
onClick = onMarkerClick,
),
)
}
MapLibreMapView(
state = mapViewState,
onMapLoaded = {
println("El mapa se cargó y está listo")
},
onMapClick = { geoPoint ->
selectedMarker = null // Borrar la selección al pulsar el mapa
}
) {
// Contenido de mapa con marcadores interactivos
Marker(markerState1)
Marker(markerState2)
// Mostrar información del marcador seleccionado
selectedMarker?.let { marker ->
val text = GeoPoint.from(marker.position).toUrlValue(6)
// Burbuja personalizada con cola en el lado derecho
InfoBubbleCustom(
marker = marker,
tailOffset = Offset(0f, 0.5f), // attach at center-left of the bubble
) {
RightTailInfoBubble(
bubbleColor = Color.White,
borderColor = Color.Black,
) {
Text(
text = text,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
@Composable
private fun RightTailInfoBubble(
bubbleColor: Color,
borderColor: Color,
contentPadding: androidx.compose.ui.unit.Dp = 8.dp,
cornerRadius: androidx.compose.ui.unit.Dp = 4.dp,
tailSize: androidx.compose.ui.unit.Dp = 8.dp,
content: @Composable () -> Unit,
) {
Box(modifier = Modifier.wrapContentSize()) {
Canvas(modifier = Modifier.matchParentSize()) {
val width = size.width
val height = size.height
val tail = tailSize.toPx()
val corner = cornerRadius.toPx()
val path =
Path().apply {
// start at top-left (after tail area)
moveTo(tail + 2 * corner, 0f)
lineTo(width - 2 * corner, 0f)
// top-right corner
arcTo(
rect =
Rect(
topLeft = Offset(width - 2 * corner, 0f),
bottomRight = Offset(width, 2 * corner),
),
startAngleDegrees = -90f,
sweepAngleDegrees = 90f,
forceMoveTo = false,
)
// right edge
lineTo(width, height - 2 * corner)
// bottom-right corner
arcTo(
rect =
Rect(
topLeft = Offset(width - 2 * corner, height - 2 * corner),
bottomRight = Offset(width, height),
),
startAngleDegrees = 0f,
sweepAngleDegrees = 90f,
forceMoveTo = false,
)
// bottom edge towards left (before tail)
lineTo(tail + 2 * corner, height)
// bottom-left corner (before tail)
arcTo(
rect =
Rect(
topLeft = Offset(tail, height - 2 * corner),
bottomRight = Offset(tail + 2 * corner, height),
),
startAngleDegrees = 90f,
sweepAngleDegrees = 90f,
forceMoveTo = false,
)
// left edge up to tail bottom
lineTo(tail, height / 2 + tail / 2)
// Tail on left side
lineTo(0f, height / 2)
lineTo(tail, height / 2 - tail / 2)
// left edge up to top-left corner
lineTo(tail, 2 * corner)
// top-left corner
arcTo(
rect =
Rect(
topLeft = Offset(tail, 0f),
bottomRight = Offset(tail + 2 * corner, 2 * corner),
),
startAngleDegrees = 180f,
sweepAngleDegrees = 90f,
forceMoveTo = false,
)
close()
}
drawPath(path, color = bubbleColor, style = Fill)
drawPath(path, color = borderColor, style = Stroke(width = 2f))
}
Box(
modifier =
Modifier
.padding(
start = contentPadding + tailSize,
top = contentPadding,
bottom = contentPadding,
end = contentPadding,
).wrapContentSize()
.clip(RoundedCornerShape(cornerRadius)),
) {
content()
}
}
}

La InfoBubble se posiciona automáticamente encima de su marcador asociado:

  • La cola de la burbuja apunta al centro del marcador
  • La burbuja ajusta su posición para permanecer visible en el viewport del mapa
  • La burbuja sigue al marcador mientras se realiza pan o zoom en el mapa

Aunque InfoBubble maneja el posicionamiento automáticamente, puede influir en él a través del ancla del icono del marcador:

// Marker with bottom-center anchor - bubble will appear above
val markerWithBottomAnchor = MarkerState(
position = position,
icon = ImageDefaultIcon(
drawable = customIcon,
anchor = Offset(0.5f, 1f) // Bottom center
)
)
// Marker with center anchor - bubble will appear above center
val markerWithCenterAnchor = MarkerState(
position = position,
icon = ImageDefaultIcon(
drawable = customIcon,
anchor = Offset(0.5f, 0.5f) // Center
)
)

La InfoBubble gestiona automáticamente su ciclo de vida:

// Bubble appears when component is composed
selectedMarker?.let { marker ->
InfoBubble(marker = marker) {
// Content
}
}
// Bubble disappears when component is removed from composition
// This automatically happens when selectedMarker becomes null
@Composable
fun ManualLifecycleExample() {
var showBubble by remember { mutableStateOf(false) }
val markerState = remember { /* marker state */ }
LaunchedEffect(showBubble) {
if (showBubble) {
delay(3000) // Show for 3 seconds
showBubble = false
}
}
// Replace MapView with your chosen map provider, such as GoogleMapView, MapboxMapView
MapView(state = mapViewState) {
Marker(markerState)
if (showBubble) {
InfoBubble(marker = markerState) {
Text("Auto-hiding bubble")
}
}
}
}
@Composable
fun DarkModeInfoBubble(marker: MarkerState) {
val isDarkTheme = isSystemInDarkTheme()
InfoBubble(
marker = marker,
bubbleColor = if (isDarkTheme) Color(0xFF2D2D2D) else Color.White,
borderColor = if (isDarkTheme) Color(0xFF555555) else Color.Black,
contentPadding = 12.dp,
cornerRadius = 8.dp
) {
Text(
text = marker.extra as? String ?: "",
color = if (isDarkTheme) Color.White else Color.Black,
style = MaterialTheme.typography.bodyMedium
)
}
}
data class BubbleTheme(
val backgroundColor: Color,
val borderColor: Color,
val textColor: Color,
val contentPadding: Dp,
val cornerRadius: Dp,
val tailSize: Dp
) : java.io.Serializable
val DefaultBubbleTheme = BubbleTheme(
backgroundColor = Color.White,
borderColor = Color.Black,
textColor = Color.Black,
contentPadding = 8.dp,
cornerRadius = 4.dp,
tailSize = 8.dp
)
val DarkBubbleTheme = BubbleTheme(
backgroundColor = Color(0xFF2D2D2D),
borderColor = Color(0xFF555555),
textColor = Color.White,
contentPadding = 12.dp,
cornerRadius = 8.dp,
tailSize = 10.dp
)
@Composable
fun ThemedInfoBubble(
marker: MarkerState,
theme: BubbleTheme = DefaultBubbleTheme,
content: @Composable () -> Unit
) {
InfoBubble(
marker = marker,
bubbleColor = theme.backgroundColor,
borderColor = theme.borderColor,
contentPadding = theme.contentPadding,
cornerRadius = theme.cornerRadius,
tailSize = theme.tailSize,
content = content
)
}
// Good: Use stable keys for markers
val markerStates = remember(markersData) {
markersData.map { data ->
MarkerState(
id = "marker_${data.id}", // Stable ID
position = data.position,
extra = data
)
}
}
// Avoid: Creating new marker state on every recomposition
val markerStates = markersData.map { data ->
MarkerState(position = data.position, extra = data) // New instance each time
}
// Clear selected marker when navigating away
DisposableEffect(Unit) {
onDispose {
selectedMarker = null // Clear the InfoBubble
}
}
  1. Contenido Conciso: La InfoBubble debe proporcionar información esencial sin abrumar al usuario
  2. Tamaño Apropiado: Limitar el ancho de la burbuja para mantener la legibilidad en dispositivos móviles
  3. Acciones Claras: Si se incluyen botones, aclarar su propósito
  4. Objetivos táctiles: Asegurar que los elementos interactivos cumplan con los tamaños mínimos de objetivo táctil
  1. Comportamiento de Cierre: Permitir que los usuarios cierren la burbuja tocando el mapa o marcadores
  2. Estado de Carga: Mostrar un indicador de carga para contenido que requiere solicitudes de red
  3. Manejo de Errores: Manejar datos faltantes o inválidos apropiadamente
  4. Accesibilidad: Proporcionar descripciones de contenido para lectores de pantalla
// Good: Stable marker reference
val markerState = remember(markerId) {
MarkerState(id = markerId, position = position)
}
// Good: Efficient content updates
LaunchedEffect(selectedMarkerId) {
if (selectedMarkerId != null) {
// Load additional data if needed
}
}
// Avoid: Heavy computation inside bubble content
InfoBubble(marker = marker) {
// Avoid expensive operations here
val processedData = remember(marker.extra) {
processData(marker.extra) // Move to remember
}
DisplayContent(processedData)
}
  1. La burbuja no aparece: Verificar que el marcador esté configurado correctamente y que la InfoBubble esté dentro de un MapViewScope
  2. La burbuja no se cierra: Verificar que el renderizado condicional responda adecuadamente a los cambios de estado
  3. Degradación del rendimiento: Limitar el número de burbujas mostradas simultáneamente y optimizar la composición del contenido
  4. Problemas de diseño: Usar restricciones de tamaño apropiadas y probar en diversos tamaños de pantalla