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
@Composablefun 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: ElMarkerStateal que se adjunta la burbujabubbleColor: 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.
@Composablefun 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
@Composablefun 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
@Composablefun 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
@Composablefun 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
@Composablefun 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.
@Composablefun 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, ) } } } }}
@Composableprivate 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 aboveval markerWithBottomAnchor = MarkerState( position = position, icon = ImageColorDefaultIcon( drawable = customIcon, anchor = Offset(0.5f, 1f) // Bottom center ))
// Marker with center anchor - bubble will appear above centerval 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 composedselectedMarker?.let { marker -> InfoBubble(marker = marker) { // Content }}
// Bubble disappears when component is removed from composition// This automatically happens when selectedMarker becomes nullControl manual del ciclo de vida
@Composablefun 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
@Composablefun 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)
@Composablefun 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 markersval 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 recompositionval markerStates = markersData.map { data -> MarkerState(position = data.position, extra = data) // New instance each time}Administración de memoria
// Clear selected marker when navigating awayDisposableEffect(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ónOffset(0.5f, 1f): Usar el centro inferior como punto de conexión
@Composablefun 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
- Contenido conciso: InfoBubble debe proporcionar información esencial sin abrumar al usuario
- Tamaño adecuado: Limita el ancho de la burbuja para mantener la legibilidad en dispositivos móviles
- Acciones claras: Si incluyes botones, deja claro su propósito
- Objetivos táctiles: Asegúrate de que los elementos interactivos cumplan con el tamaño mínimo de objetivo táctil
Experiencia de usuario
- Comportamiento de cierre: Permite que los usuarios cierren la burbuja tocando el mapa o un marcador
- Estados de carga: Muestra un indicador de carga para el contenido que requiere solicitudes de red
- Manejo de errores: Maneja adecuadamente los datos faltantes o no válidos
- Accesibilidad: Proporciona descripciones del contenido para lectores de pantalla
Consejos de implementación
// Good: Stable marker referenceval markerState = remember(markerId) { MarkerState(id = markerId, position = position)}
// Good: Efficient content updatesLaunchedEffect(selectedMarkerId) { if (selectedMarkerId != null) { // Load additional data if needed }}
// Avoid: Heavy computation inside bubble contentInfoBubble(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
- La burbuja no aparece: Asegúrate de que el marcador esté configurado correctamente y de que InfoBubble esté dentro de MapViewScope
- La burbuja no se cierra: Verifica que el renderizado condicional responda correctamente a los cambios de estado
- Bajo rendimiento: Limita la cantidad de burbujas que se muestran al mismo tiempo y optimiza la composición del contenido
- Problemas de layout: Usa restricciones de tamaño adecuadas y prueba con distintos tamaños de pantalla