Información burbuja (InfoBubble)
Los globos vinculados a marcadores se pueden escribir directamente en el código de la interfaz de usuario de cada plataforma. El contenido es un Composable en Compose, una View en SwiftUI o un elemento en React. El SDK solo se encarga de la alineación y el dibujo de la cola.
01 · Cómo funciona
Común para las 3 plataformas: la estructura consiste en mantener el marcador seleccionado como un estado y dibujar la burbuja solo mientras esté seleccionado. La apertura y cierre de la burbuja la decide el estado de la aplicación, no el SDK.
Tiene un estado de selección
Mantiene el marcador seleccionado (o ID) como state. Si se muestran varios a la vez, se usa Set.
Colocar como hijo del mapa
Coloca Marker y InfoBubble como hijos del contenedor del mapa y dibuja InfoBubble solo cuando está seleccionado.
Cerrar al tocar el mapa
Cancela la selección en onMapClick del mapa. onClick del marcador establece la selección.

02 · 4 patrones
Las 4 páginas bajo `infobubble` en la aplicación de ejemplo corresponden directamente a los 4 patrones de implementación. Al elegir un patrón y una plataforma, se muestra el código del ejemplo correspondiente.
Una línea de texto
Configuración mínima. Muestra una cadena de `extra` del marcador en una línea. Como el marco, la cola y el relleno tienen valores predeterminados, solo necesita escribir el texto del contenido.
var selectedMarker by remember { mutableStateOf<MarkerState?>(null) }
val markerState = remember {
MarkerState(
position = GeoPoint.fromLatLong(37.7749, -122.4194),
icon = DefaultMarkerIcon(fillColor = Color.Blue, label = "SF"),
extra = "San Francisco - The Golden Gate City",
onClick = { selectedMarker = it },
)
}
MapViewContainer(
modifier = Modifier.fillMaxSize(),
state = mapViewState,
onMapClick = { selectedMarker = null },
onMapLoaded = { selectedMarker = markerState },
) {
Marker(markerState)
selectedMarker?.let { marker ->
InfoBubble(marker = marker) {
Text(
text = marker.extra as? String ?: "No information",
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(4.dp),
)
}
}
}@State private var selectedMarker: MarkerState? = nil
@StateObject private var markerState = MarkerState(
position: GeoPoint(latitude: 37.7749, longitude: -122.4194),
extra: "San Francisco - The Golden Gate City",
icon: DefaultMarkerIcon(fillColor: UIColor.systemBlue, label: "SF")
)
SampleMapView(
provider: $provider,
/* ... provider states ... */
onMapClick: { _ in selectedMarker = nil }
) {
Marker(state: markerState)
if let marker = selectedMarker {
InfoBubble(marker: marker) {
Text(marker.extra as! String)
.foregroundColor(.accentColor)
.padding(4)
}
}
}
.onAppear {
markerState.onClick = { marker in selectedMarker = marker }
}const [selectedId, setSelectedId] = useState<string | null>('simple-text-bubble');
const marker = useMemo(() => createMarkerState({
id: 'simple-text-bubble',
position: createGeoPoint({ latitude: 37.7749, longitude: -122.4194 }),
icon: new ColorDefaultIcon('#2563eb', { label: 'SF', labelTextColor: '#ffffff' }),
extra: 'San Francisco - The Golden Gate City',
onClick: state => setSelectedId(state.id),
}), []);
return (
<MapViewContainer initialCamera={INIT_CAMERA} onMapClick={() => setSelectedId(null)}>
<Marker state={marker} />
{selectedId === marker.id && (
<InfoBubble marker={marker}>
<div className="bubble-content simple-text-bubble">
{marker.extra as string}
</div>
</InfoBubble>
)}
</MapViewContainer>
);La cola por defecto queda justo encima del marcador (tailOffset x:0.5 / y:1.0).
Cambiar estilo
Patrón para cambiar el estilo del marco. Si solo cambia el color y las esquinas redondeadas, Android y React usan argumentos separados, iOS pasa `InfoBubbleStyle`. Si dibuja el marco y la cola por su cuenta, las 3 plataformas usan `InfoBubbleCustom` (Compose: `Canvas`, SwiftUI: `Shape`, React: CSS `::before` / `::after`).
val markerState1 by remember {
mutableStateOf(
MarkerState(
id = "marker1",
position = GeoPoint.fromLatLong(37.7749, -122.4194),
icon = DefaultMarkerIcon(
fillColor = Color.Blue,
infoAnchor = Offset(0.5f, 0.25f),
label = "1",
),
draggable = true,
onClick = onMarkerClick,
),
)
}
selectedMarker?.let { marker ->
val text = GeoPoint.from(marker.position).toUrlValue(6)
InfoBubbleCustom(
marker = marker,
tailOffset = Offset(0f, 0.5f), // Conectado en el centro del borde izquierdo
) {
RightTailInfoBubble(
bubbleColor = Color.White,
borderColor = Color.Black,
) {
Text(text = text, color = MaterialTheme.colorScheme.primary)
}
}
}@Composable
private fun RightTailInfoBubble(
bubbleColor: Color,
borderColor: Color,
contentPadding: Dp = 8.dp,
cornerRadius: Dp = 4.dp,
tailSize: Dp = 8.dp,
content: @Composable () -> Unit,
) {
Box(modifier = Modifier.wrapContentSize()) {
Canvas(modifier = Modifier.matchParentSize()) {
val path = Path().apply {
// Dibuja un rectángulo redondeado y añade una cola triangular en el centro del borde izquierdo
lineTo(tail, height / 2 + tail / 2)
lineTo(0f, height / 2)
lineTo(tail, height / 2 - tail / 2)
close()
}
drawPath(path, color = bubbleColor, style = Fill)
drawPath(path, color = borderColor, style = Stroke(width = 2f))
}
Box(modifier = Modifier.padding(start = contentPadding + tailSize)) {
content()
}
}
}private let style = InfoBubbleStyle(
bubbleColor: Color.black.opacity(0.85),
borderColor: Color.white,
contentPadding: 10,
cornerRadius: 10,
tailSize: 10
)
SampleMapView(
provider: $provider,
/* ... provider states ... */
onMapClick: { point in markerState.position = point }
) {
Marker(state: markerState)
InfoBubble(marker: markerState, style: style) {
VStack(alignment: .leading, spacing: 6) {
Text("Night Mode")
.font(.headline)
.foregroundColor(.white)
Text("Custom style bubble")
.font(.subheadline)
.foregroundColor(.white.opacity(0.8))
}
}
// Cuando dibujas el marco entero tú mismo
Marker(state: customMarkerState)
InfoBubbleCustom(
marker: customMarkerState,
tailOffset: CGPoint(x: 0, y: 0.5) // Conectado en el centro del borde izquierdo
) {
RightTailInfoBubble(bubbleColor: .white, borderColor: .black) {
Text("Fully custom bubble")
.font(.subheadline)
.foregroundColor(.accentColor)
}
}
}El ejemplo de iOS reemplaza el color, el relleno y el tamaño de la cola a través de `InfoBubbleStyle`, en lugar de dibujar la cola hacia la derecha por su cuenta. La apariencia es diferente de las versiones de Android/React.
const marker1 = createMarkerState({
id: 'marker1',
position: createGeoPoint({ latitude: 37.7749, longitude: -122.4194 }),
icon: new ColorDefaultIcon('#2563eb', {
label: '1',
labelTextColor: '#ffffff',
infoAnchor: { x: 0.5, y: 0.25 },
}),
draggable: true,
onClick: state => setSelectedId(state.id),
});
const activeMarker = markers.find(m => m.id === selectedId);
<Markers states={markers} />
{activeMarker && (
<InfoBubbleCustom marker={activeMarker} tailOffset={{ x: 0, y: 0.5 }}>
<div className="right-tail-info-bubble">
{activeMarker.position.toUrlValue(6)}
</div>
</InfoBubbleCustom>
)}.right-tail-info-bubble {
position: relative;
width: max-content;
max-width: 220px;
padding: 8px;
margin-left: 8px;
border: 2px solid #000;
border-radius: 4px;
background: #fff;
color: #2563eb;
}
/* Usa ::before / ::after para superponer un triángulo hacia la izquierda como cola */El punto de conexión resulta de tailOffset (lado de la burbuja) y del infoAnchor del icono (lado del marcador).
Contenido enriquecido
Coloque un objeto en `extra` e incruste un diseño con título, descripción y calificación. El marco se puede ajustar con `bubbleColor` / `borderColor` / `contentPadding` / `cornerRadius`, y el código del contenido puede concentrarse solo en el diseño del contenido.
data class LocationInfo(
val name: String,
val description: String,
val rating: Float,
) : Serializable
selectedMarker?.let { marker ->
val info = marker.extra as? LocationInfo ?: return@let
InfoBubble(
marker = marker,
bubbleColor = if (isDarkTheme) Color.Black else Color.White,
borderColor = if (isDarkTheme) Color.Gray else Color.Black,
contentPadding = 16.dp,
cornerRadius = 12.dp,
) {
Column(modifier = Modifier.width(200.dp)) {
Text(info.name, style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold)
Spacer(Modifier.height(8.dp))
Text(info.description, style = MaterialTheme.typography.bodyMedium)
Spacer(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(" ${info.rating}/5", style = MaterialTheme.typography.bodySmall)
}
}
}
}@StateObject private var markerState = MarkerState(
position: GeoPoint(latitude: 37.7694, longitude: -122.4862),
extra: LocationInfo(
name: "Golden Gate Park",
description: "A large urban park with gardens, museums, ...",
rating: 4.5
),
icon: DefaultMarkerIcon(fillColor: UIColor.systemGreen, label: "P")
)
if let marker = selectedMarker,
let info = marker.extra as? LocationInfo {
InfoBubble(marker: marker, style: bubbleStyle()) {
VStack(alignment: .leading, spacing: 8) {
Text(info.name).font(.headline).fontWeight(.bold)
Text(info.description)
.font(.subheadline)
.foregroundColor(.gray)
HStack(spacing: 4) {
ForEach(0..<5, id: \.self) { index in
Image(systemName: "star.fill")
.foregroundColor(index < Int(info.rating) ? .yellow : .gray)
.font(.system(size: 12))
}
Text(String(format: " %.1f/5", info.rating)).font(.caption)
}
}
.frame(width: 200, alignment: .leading)
}
}interface LocationInfo extends Record<string, unknown> {
name: string; description: string; rating: number;
}
const marker = useMemo(() => createMarkerState({
id: 'golden-gate-park',
position: createGeoPoint({ latitude: 37.7694, longitude: -122.4862 }),
icon: new ColorDefaultIcon('#22c55e', { label: '🌳' }),
extra: { name: 'Golden Gate Park', description: '…', rating: 4.5 },
onClick: state => setSelectedId(state.id),
}), []);
const info = marker.extra as LocationInfo;
<InfoBubble
marker={marker}
bubbleColor="#ffffff"
borderColor="#000000"
contentPadding={16}
cornerRadius={12}
>
<div className="rich-location-bubble">
<strong>{info.name}</strong>
<p>{info.description}</p>
<div className="rating-row">…</div>
</div>
</InfoBubble>A large urban park with gardens, museums, and recreational areas.
Los ejemplos de Android e iOS también cambian los colores de marco y fondo para el modo oscuro.
Mostrar varios simultáneamente
Si mantiene la selección como Set, puede mantener múltiples burbujas abiertas simultáneamente. Dibuje InfoBubble solo para los marcadores abiertos y agregue operaciones de toque en el contenido para que la burbuja se cierre por sí misma.
var selectedMarkers by remember { mutableStateOf(setOf<String>()) }
val onMarkerClick: OnMarkerEventHandler = { markerState ->
selectedMarkers =
if (selectedMarkers.contains(markerState.id)) {
selectedMarkers - markerState.id
} else {
selectedMarkers + markerState.id
}
}
MapViewContainer(
state = mapViewState,
onMapClick = { selectedMarkers = emptySet() },
) {
markerStates.forEach { markerState ->
Marker(markerState)
if (selectedMarkers.contains(markerState.id)) {
InfoBubble(
marker = markerState,
bubbleColor = Color.White,
borderColor = Color.Black,
) {
Column(modifier = Modifier.clickable(true, onClick = {
selectedMarkers = selectedMarkers - markerState.id
})) {
Text(markerState.extra as String, fontWeight = FontWeight.Bold)
Text("Tap to close", color = Color.Gray)
}
}
}
}
}@State private var selectedMarkers: Set<String> = []
SampleMapView(
provider: $provider,
/* ... provider states ... */
onMapClick: { _ in selectedMarkers = [] }
) {
Marker(state: markerState1)
if selectedMarkers.contains(markerState1.id) {
InfoBubble(marker: markerState1) {
VStack(alignment: .leading, spacing: 4) {
Text(markerState1.extra as? String ?? "Unknown").font(.headline)
Text("Tap to close").font(.subheadline).foregroundColor(.gray)
}
}
}
// markerState2 y markerState3 tienen la misma forma
}
.onAppear {
[markerState1, markerState2, markerState3].forEach { marker in
marker.onClick = { clicked in
if selectedMarkers.contains(clicked.id) {
selectedMarkers.remove(clicked.id)
} else {
selectedMarkers.insert(clicked.id)
}
}
}
}const [selectedIds, setSelectedIds] = useState<Set<string>>(
() => new Set(['marker_0', 'marker_1', 'marker_2'])
);
// Alterna en onClick
onClick: state => setSelectedIds(prev => {
const next = new Set(prev);
next.has(state.id) ? next.delete(state.id) : next.add(state.id);
return next;
}),
<Markers states={markers} />
{markers.map(marker =>
selectedIds.has(marker.id) ? (
<InfoBubble key={marker.id} marker={marker}
bubbleColor="#ffffff" borderColor="#000000">
<button type="button" className="multi-bubble-content"
onClick={() => close(marker.id)}>
<strong>{marker.extra as string}</strong>
<span>Tap to close</span>
</button>
</InfoBubble>
) : null
)}En todos los ejemplos, tocar el mapa las cierra todas (vuelve a un Set vacío).
03 · API
InfoBubble es un componente de alto nivel que dibuja un marco con cola, e InfoBubbleCustom es un componente de bajo nivel que solo realiza la alineación. iOS pasa los argumentos de estilo agrupados en InfoBubbleStyle.
iOS pasa esta estructura completa a style en lugar de argumentos individuales.
InfoBubbleStyle( bubbleColor: Color, borderColor: Color, contentPadding: CGFloat, cornerRadius: CGFloat, tailSize: CGFloat )
Patrón donde el marco y la cola se dibujan por sí mismos. El SDK solo se encarga de la alineación.
marker: MarkerState tailOffset: Offset // ios: CGPoint content / children
Muestra la burbuja directamente en las coordenadas en lugar del marcador. En las 3 plataformas está unificado pasando position a InfoBubble (sobrecarga de InfoBubble(position:) en Compose/iOS).
position: GeoPoint // react: <InfoBubble position=… /> // compose / ios: InfoBubble(position:)
04 · Nota
No se superpone incluso si no se especifica un ícono
Los marcadores a los que no se les pasa icon se tratan como el pin predeterminado (48px, borde inferior como referencia) y la burbuja se desplaza por su tamaño real. No se superpondrá al marcador.
Desplazar con infoAnchor
El punto de conexión del lado del ícono se puede cambiar con infoAnchor (Offset en Compose, x/y en React). La burbuja sigue incluso durante el arrastre.
El contenido de la burbuja puede recibir eventos
Se pueden colocar directamente clickable de Compose, gestos de SwiftUI y onClick de React. Funcionan independientemente de onMapClick del mapa, por lo que la operación de cerrar puede estar del lado del contenido.