Informationsblase (InfoBubble)
Mit Marker verknüpfte Sprechblasen können Sie direkt im UI-Code der jeweiligen Plattform schreiben. Der Inhalt ist ein Composable in Compose, ein View in SwiftUI oder ein Element in React. Das SDK übernimmt nur die Ausrichtung und das Zeichnen des Schweifs.
01 · Funktionsweise
Gemeinsam für die 3 Plattformen: Der Aufbau besteht darin, den ausgewählten Marker als Zustand zu halten und die Blase nur zu zeichnen, solange er ausgewählt ist. Das Öffnen und Schließen der Blase wird vom state der App entschieden, nicht vom SDK.
Hat einen Auswahlzustand
Behält den aktuell ausgewählten Marker (oder ID) als state. Bei gleichzeitiger Anzeige mehrerer wird Set verwendet.
Als Kind der Karte platzieren
Platziert Marker und InfoBubble nebeneinander als Kinder des Kartencontainers und rendert InfoBubble nur, wenn sie ausgewählt ist.
Beim Antippen der Karte schließen
Hebt die Auswahl in onMapClick der Karte auf. onClick des Markers legt die Auswahl fest.

02 · 4 Muster
Die 4 Seiten unter `infobubble` in der Beispiel-App entsprechen direkt den 4 Implementierungsmustern. Wenn Sie ein Muster und eine Plattform wählen, wird der Code des entsprechenden Beispiels angezeigt.
Eine Textzeile
Minimale Konfiguration. Zeigt eine Zeichenkette aus `extra` des Markers in einer Zeile an. Da Rahmen, Schweif und Abstände Standardwerte haben, müssen Sie nur den Text des Inhalts schreiben.
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>
);Der Standardzipfel sitzt direkt über dem Marker (tailOffset x:0.5 / y:1.0).
Stil ändern
Muster zum Ändern des Rahmenstils. Wenn Sie nur Farbe und abgerundete Ecken ändern, verwenden Android und React separate Argumente, iOS übergibt `InfoBubbleStyle`. Wenn Sie Rahmen und Schweif selbst zeichnen, verwenden alle 3 Plattformen `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), // Verbunden in der Mitte der linken Kante
) {
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 {
// Ein abgerundetes Rechteck zeichnen und in der Mitte der linken Kante einen dreieckigen Schwanz ansetzen
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))
}
}
// Wenn Sie den ganzen Rahmen selbst zeichnen
Marker(state: customMarkerState)
InfoBubbleCustom(
marker: customMarkerState,
tailOffset: CGPoint(x: 0, y: 0.5) // Verbunden in der Mitte der linken Kante
) {
RightTailInfoBubble(bubbleColor: .white, borderColor: .black) {
Text("Fully custom bubble")
.font(.subheadline)
.foregroundColor(.accentColor)
}
}
}Das iOS-Beispiel ersetzt Farbe, Abstand und Schweifgröße über `InfoBubbleStyle`, anstatt den nach rechts gerichteten Schweif selbst zu zeichnen. Das Erscheinungsbild unterscheidet sich von den Android-/React-Versionen.
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;
}
/* Mit ::before / ::after ein nach links zeigendes Dreieck als Schwanz darüberlegen */Der Verbindungspunkt ergibt sich aus tailOffset (Blasenseite) und dem infoAnchor des Icons (Markerseite).
Rich Content
Legen Sie ein Objekt in `extra` und betten Sie ein Layout mit Überschrift, Beschreibung und Bewertung ein. Der Rahmen kann über `bubbleColor` / `borderColor` / `contentPadding` / `cornerRadius` angepasst werden, und der Code des Inhalts kann sich nur auf das Layout des Inhalts konzentrieren.
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.
Die Beispiele für Android und iOS tauschen außerdem Rahmen- und Hintergrundfarbe für den Dunkelmodus.
Mehrere gleichzeitig anzeigen
Wenn Sie die Auswahl als Set speichern, können Sie mehrere Blasen gleichzeitig geöffnet halten. Zeigen Sie InfoBubble nur für geöffnete Marker an und fügen Sie Tap-Operationen im Inhalt hinzu, damit die Blase selbst geschlossen werden kann.
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 und markerState3 haben dieselbe Form
}
.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'])
);
// In onClick umschalten
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
)}In jedem Beispiel schließt ein Klick auf die Karte alle wieder (zurück zu einem leeren Set).
03 · API
InfoBubble ist eine High-Level-Komponente, die einen Rahmen mit Schwanz zeichnet, und InfoBubbleCustom ist eine Low-Level-Komponente, die nur die Positionierung vornimmt. iOS übergibt Stilargumente zusammengefasst in InfoBubbleStyle.
iOS übergibt diese Struktur komplett an style statt einzelner Argumente.
InfoBubbleStyle( bubbleColor: Color, borderColor: Color, contentPadding: CGFloat, cornerRadius: CGFloat, tailSize: CGFloat )
Muster, bei dem Rahmen und Schweif selbst gezeichnet werden. Das SDK übernimmt nur die Positionierung.
marker: MarkerState tailOffset: Offset // ios: CGPoint content / children
Zeigt die Blase direkt an Koordinaten statt am Marker. Auf allen drei Plattformen ist es einheitlich, indem position an InfoBubble übergeben wird (Überladung von InfoBubble(position:) in Compose/iOS).
position: GeoPoint // react: <InfoBubble position=… /> // compose / ios: InfoBubble(position:)
04 · Hinweis
Überlappt sich nicht, auch wenn kein Symbol angegeben ist
Marker, denen kein icon übergeben wird, werden als Standard-Pin behandelt (48px, unterer Kante als Bezug) und die Blase wird um deren tatsächliche Größe verschoben. Sie überlappt nicht den Marker.
Mit infoAnchor verschieben
Der Verbindungspunkt auf der Symbolseite kann mit infoAnchor (Offset in Compose, x/y in React) geändert werden. Die Blase folgt auch während des Ziehens.
Der Inhalt der Blase kann Ereignisse empfangen
clickable von Compose, Gesten von SwiftUI und onClick von React können direkt platziert werden. Sie arbeiten unabhängig von onMapClick der Karte, sodass der Schließvorgang auf der Inhaltsseite erfolgen kann.