Skip to content

InfoBubble

InfoBubble es un componente que muestra contenido personalizado en una burbuja tipo llamada fijada a un marcador en el mapa. Proporciona una forma de mostrar información detallada sobre un marcador.

Descripción general

InfoBubble se muestra sobre un marcador específico. Como el contenido que se muestra dentro de InfoBubble puede proporcionarse como un Composable, puedes implementar el diseño que necesites.

Funciones Composable

@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
)

Parámetros

  • marker: El MarkerState al que se adjunta la burbuja
  • bubbleColor: El color de fondo de la burbuja (predeterminado: White)
  • borderColor: El color del borde de la burbuja (predeterminado: Black)
  • contentPadding: Padding interno alrededor del contenido (predeterminado: 8dp)
  • cornerRadius: El radio de las esquinas de la burbuja (predeterminado: 4dp)
  • tailSize: El tamaño de la cola de la burbuja que apunta al marcador (predeterminado: 8dp)
  • content: Contenido Composable para mostrar dentro de la burbuja

Ubicación sin marcador

InfoBubble también puede colocarse directamente en cualquier GeoPoint del mapa sin vincularse a un MarkerState. Cuando cambia position, la burbuja también se mueve y sigue el paneo y el zoom del mapa.

@Composable
fun MapViewScope.InfoBubble(
position: GeoPoint,
bubbleColor: Color = Color.White,
borderColor: Color = Color.Black,
contentPadding: Dp = 8.dp,
cornerRadius: Dp = 4.dp,
tailSize: Dp = 8.dp,
content: @Composable () -> Unit,
)
MapView(...) {
InfoBubble(
position = GeoPoint(35.6812, 139.7671),
bubbleColor = Color.White,
borderColor = Color.Blue,
) {
Text("Burbuja aquí")
}
}

Uso básico

Burbuja de texto simple

@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 = ColorDefaultIcon(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
)
}
}
}
}

Burbuja con estilo personalizado

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 = ColorDefaultIcon(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)
)
}
}
}
}
}
}
}

Burbuja interactiva con acciones

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 = ColorDefaultIcon(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))
}
}
}
}
}
}
}
}

Administración de varias burbujas

@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 = ColorDefaultIcon(fillColor = color, label = "${index + 1}"),
extra = name,
onClick = onMarkerClick
)
}
}
// Reemplaza MapView por un SDK de mapas 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 = "Toca para cerrar",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray
)
}
}
}
}
}
}

Diseño personalizado

Para implementar un diseño personalizado, puedes crear tu propio componente derivado de InfoBubble. El siguiente es un ejemplo de implementación detallado de una burbuja personalizada 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("Inicio del arrastre del marcador: ${markerState.id}")
}
val onMarkerDrag: OnMarkerEventHandler = { markerState ->
println("Arrastrando el marcador: ${markerState.position}")
}
val onMarkerDragEnd: OnMarkerEventHandler = { markerState ->
println("Fin del arrastre del marcador: ${markerState.id}")
}
val markerState1 by remember {
mutableStateOf(
MarkerState(
id = "marker1",
position = GeoPoint.fromLatLong(37.7749, -122.4194),
icon =
ColorDefaultIcon(
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 =
ColorDefaultIcon(
fillColor = Color.Red,
infoAnchor = Offset(0.5f, 0.25f),
label = "2",
),
onClick = onMarkerClick,
),
)
}
MapLibreMapView(
state = mapViewState,
onMapLoaded = {
println("El mapa terminó de cargar")
},
onMapClick = { geoPoint ->
selectedMarker = null // Toca el mapa para borrar la selección
}
) {
// Contenido del 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)
// Ejemplo de 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()
}
}
}

Ubicación y comportamiento

Ubicación automática

InfoBubble se coloca automáticamente sobre el marcador asociado:

  • La cola de la burbuja apunta al centro del marcador
  • La burbuja ajusta su posición para mantenerse visible dentro del viewport del mapa
  • Cuando se panea o se aplica zoom al mapa, la burbuja sigue al marcador

Ubicación personalizada

InfoBubble maneja la ubicación automáticamente, pero puedes influir en ella mediante el anclaje del icono del marcador:

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

Administración del ciclo de vida

InfoBubble administra 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

Control manual del ciclo de vida

@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")
}
}
}
}

Estilos y temas

Compatibilidad con modo oscuro

@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
)
}
}

Tema personalizado

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
)
}

Consideraciones de rendimiento

Actualizaciones eficientes

// 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
}

Administración de memoria

// Clear selected marker when navigating away
DisposableEffect(Unit) {
onDispose {
selectedMarker = null // Clear the InfoBubble
}
}

InfoBubbleCustom

Con InfoBubbleCustom, puedes dibujar libremente toda la burbuja, incluida su cola, como un Composable. tailOffset especifica qué punto dentro de la burbuja se usa como punto de conexión con el marcador mediante Offset(0f..1f, 0f..1f).

  • Offset(1f, 0.5f): Usar el centro derecho como punto de conexión
  • Offset(0.5f, 1f): Usar el centro inferior como punto de conexión
@Composable
fun MapViewScope.InfoBubbleCustom(
marker: MarkerState,
tailOffset: Offset,
content: @Composable () -> Unit,
)
InfoBubbleCustom(
marker = markerState, // Marcador
tailOffset = Offset(1f, 0.5f), // Usar el centro derecho como punto de conexión
) {
// カスタム形状の吹き出しを描画
Box(
modifier = Modifier
.background(Color.Yellow, RoundedCornerShape(8.dp))
.padding(8.dp)
) {
Text("カスタムバブル")
}
}

Mejores prácticas

Guías de diseño

  1. Contenido conciso: InfoBubble debe proporcionar información esencial sin abrumar al usuario
  2. Tamaño adecuado: Limita el ancho de la burbuja para mantener la legibilidad en dispositivos móviles
  3. Acciones claras: Si incluyes botones, deja claro su propósito
  4. Objetivos táctiles: Asegúrate de que los elementos interactivos cumplan con el tamaño mínimo de objetivo táctil

Experiencia de usuario

  1. Comportamiento de cierre: Permite que los usuarios cierren la burbuja tocando el mapa o un marcador
  2. Estados de carga: Muestra un indicador de carga para el contenido que requiere solicitudes de red
  3. Manejo de errores: Maneja adecuadamente los datos faltantes o no válidos
  4. Accesibilidad: Proporciona descripciones del contenido para lectores de pantalla

Consejos de implementación

// 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)
}

Solución de problemas

Problemas comunes

  1. La burbuja no aparece: Asegúrate de que el marcador esté configurado correctamente y de que InfoBubble esté dentro de MapViewScope
  2. La burbuja no se cierra: Verifica que el renderizado condicional responda correctamente a los cambios de estado
  3. Bajo rendimiento: Limita la cantidad de burbujas que se muestran al mismo tiempo y optimiza la composición del contenido
  4. Problemas de layout: Usa restricciones de tamaño adecuadas y prueba con distintos tamaños de pantalla