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.
Overview
Section titled “Overview”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 Function
Section titled “Composable Function”@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)Parameters
Section titled “Parameters”marker: TheMarkerStateto 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.
Basic Usage
Section titled “Basic Usage”Simple Text Bubble
Section titled “Simple Text Bubble”@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 = 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 ) } } }}
Custom Styled Bubble
Section titled “Custom Styled Bubble”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 = 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) ) } } } } } }}
Interactive Bubble with Actions
Section titled “Interactive Bubble with Actions”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 = 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)) } } } } } } }}Managing Multiple Bubbles
Section titled “Managing Multiple Bubbles”@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 = 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 ) } } } } }}Custom Designs
Section titled “Custom Designs”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.
@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("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, ) } } } }}
@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() } }}Positioning and Behavior
Section titled “Positioning and Behavior”Automatic Positioning
Section titled “Automatic Positioning”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.
Custom Positioning
Section titled “Custom Positioning”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 aboveval markerWithBottomAnchor = MarkerState( position = position, icon = ImageDefaultIcon( drawable = customIcon, anchor = Offset(0.5f, 1f) // Bottom center ))
// Marker with center anchor - bubble will appear above centerval markerWithCenterAnchor = MarkerState( position = position, icon = ImageDefaultIcon( drawable = customIcon, anchor = Offset(0.5f, 0.5f) // Center ))Lifecycle Management
Section titled “Lifecycle Management”InfoBubble automatically manages its lifecycle:
// 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 nullManual Lifecycle Control
Section titled “Manual Lifecycle Control”@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") } } }}Styling and Theming
Section titled “Styling and Theming”Dark Mode Support
Section titled “Dark Mode Support”@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 ) }}Custom Themes
Section titled “Custom Themes”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 )}Performance Considerations
Section titled “Performance Considerations”Efficient Updates
Section titled “Efficient Updates”// 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}Memory Management
Section titled “Memory Management”// Clear selected marker when navigating awayDisposableEffect(Unit) { onDispose { selectedMarker = null // Clear the InfoBubble }}Best Practices
Section titled “Best Practices”Design Guidelines
Section titled “Design Guidelines”- Keep content concise: InfoBubbles should provide essential information without overwhelming users.
- Use appropriate sizing: Limit bubble width to maintain readability on mobile devices.
- Provide clear actions: If you include buttons, make their purpose obvious.
- Consider touch targets: Ensure interactive elements meet minimum touch target sizes.
User Experience
Section titled “User Experience”- Dismiss behavior: Allow users to dismiss bubbles by tapping the map or marker.
- Loading states: Show loading indicators for content that requires network requests.
- Error handling: Gracefully handle missing or invalid data.
- Accessibility: Provide content descriptions for screen readers.
Implementation Tips
Section titled “Implementation Tips”// 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)}Troubleshooting
Section titled “Troubleshooting”Common Issues
Section titled “Common Issues”- Bubble not appearing: Verify the marker is properly configured and InfoBubble is inside
MapViewScope. - Bubble not dismissing: Check that conditional rendering properly responds to state changes.
- Poor performance: Limit the number of simultaneous bubbles and optimize content composition.
- Layout issues: Use appropriate sizing constraints and test on different screen sizes.