Skip to content

InfoBubble

InfoBubble is a component that displays custom content in a speech bubble attached to a marker on the map. It provides a way to show detailed information about markers without cluttering the map interface.

InfoBubble appears above a specific marker. Because the content inside InfoBubble is provided as a composable, you can design the bubble freely to match your app’s UI.

@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: The MarkerState to which the bubble is attached.
  • bubbleColor: Background color of the bubble (default: White).
  • borderColor: Border color of the bubble (default: Black).
  • contentPadding: Internal padding around the content (default: 8dp).
  • cornerRadius: Radius of the bubble’s rounded corners (default: 4dp).
  • tailSize: Size of the speech bubble tail pointing to the marker (default: 8dp).
  • content: Composable content to display inside the bubble.
@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
)
}
}
// Replace MapView with your chosen map provider, such as GoogleMapView or 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 = "Tap to close",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray
)
}
}
}
}
}
}

To implement a custom design, you can build your own InfoBubble-based component. The following video shows an example of a bubble with a tail on the right side.

@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("Started dragging marker: ${markerState.id}")
}
val onMarkerDrag: OnMarkerEventHandler = { markerState ->
println("Dragging marker to: ${markerState.position}")
}
val onMarkerDragEnd: OnMarkerEventHandler = { markerState ->
println("Finished dragging marker: ${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("Map loaded and ready")
},
onMapClick = { geoPoint ->
selectedMarker = null // Clear selection when the map is clicked
}
) {
// Interactive map content with markers
Marker(markerState1)
Marker(markerState2)
// Show information for the selected marker
selectedMarker?.let { marker ->
val text = GeoPoint.from(marker.position).toUrlValue(6)
// Custom bubble with right-side tail
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()
}
}
}

InfoBubble automatically handles its positioning:

  • The bubble tail points to the center of the marker.
  • The bubble is kept visible within the map viewport as much as possible.
  • When the map is panned or zoomed, the bubble follows the marker.

InfoBubble handles most positioning automatically, but you can influence the result by adjusting the marker icon’s anchor:

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

InfoBubble automatically manages its lifecycle:

// 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. Keep content concise: InfoBubbles should provide essential information without overwhelming users.
  2. Use appropriate sizing: Limit bubble width to maintain readability on mobile devices.
  3. Provide clear actions: If you include buttons, make their purpose obvious.
  4. Consider touch targets: Ensure interactive elements meet minimum touch target sizes.
  1. Dismiss behavior: Allow users to dismiss bubbles by tapping the map or marker.
  2. Loading states: Show loading indicators for content that requires network requests.
  3. Error handling: Gracefully handle missing or invalid data.
  4. Accessibility: Provide content descriptions for screen readers.
// 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. Bubble not appearing: Verify the marker is properly configured and InfoBubble is inside MapViewScope.
  2. Bubble not dismissing: Check that conditional rendering properly responds to state changes.
  3. Poor performance: Limit the number of simultaneous bubbles and optimize content composition.
  4. Layout issues: Use appropriate sizing constraints and test on different screen sizes.