コンテンツにスキップ

InfoBubble

InfoBubble は、地図上のマーカーに吹き出し形式でカスタムコンテンツを表示するコンポーネントです。マーカーに関する詳細情報を表示する方法を提供します。

InfoBubble は、特定のマーカーの上に表示されます。InfoBubble内に表示するコンテンツはComposableを与えることができるので、自由なデザインを実現できます。

@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: バブルが付加される MarkerState
  • bubbleColor: バブルの背景色(デフォルト: White)
  • borderColor: バブルの境界線の色(デフォルト: Black)
  • contentPadding: コンテンツ周りの内部パディング(デフォルト: 8dp)
  • cornerRadius: バブルの角丸の半径(デフォルト: 4dp)
  • tailSize: マーカーを指す吹き出しの尾のサイズ(デフォルト: 8dp)
  • content: バブル内に表示する Composable コンテンツ
@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)
)
}
}
}
}
}
}
}

アクション付きのインタラクティブバブル

Section titled “アクション付きのインタラクティブバブル”
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
)
}
}
// MapView を GoogleMapView、MapboxMapView などのマップ地図SDKに置き換えてください
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 = "タップして閉じる",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray
)
}
}
}
}
}
}

カスタムデザインを実装するには、独自の InfoBubble 派生コンポーネントを作成できます。以下は、右側に尾を持つカスタム吹き出しの詳細な実装例です。

@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("マーカーのドラッグを開始: ${markerState.id}")
}
val onMarkerDrag: OnMarkerEventHandler = { markerState ->
println("マーカーをドラッグ中: ${markerState.position}")
}
val onMarkerDragEnd: OnMarkerEventHandler = { markerState ->
println("マーカーのドラッグが終了: ${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("地図の読み込みが完了しました")
},
onMapClick = { geoPoint ->
selectedMarker = null // 地図クリックで選択解除
}
) {
// インタラクティブなマーカーを持つ地図コンテンツ
Marker(markerState1)
Marker(markerState2)
// 選択されたマーカーの情報を表示
selectedMarker?.let { marker ->
val text = GeoPoint.from(marker.position).toUrlValue(6)
// 右側に尾を持つカスタム吹き出しの例
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 は関連するマーカーの上に自動的に配置されます:

  • バブルの尾がマーカーの中心を指します
  • バブルは地図のビューポート内で可視状態を維持するように位置を調整します
  • 地図がパンまたはズームされると、バブルはマーカーに追従します

InfoBubble は自動的に配置を処理しますが、マーカーアイコンのアンカーを通じて影響を与えることができます:

// 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 は自動的にライフサイクルを管理します:

// 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. 簡潔なコンテンツ: InfoBubble はユーザーを圧倒することなく、本質的な情報を提供するべきです
  2. 適切なサイズ: モバイルデバイスでの可読性を維持するために、バブルの幅を制限してください
  3. 明確なアクション: ボタンを含める場合は、その目的を明確にしてください
  4. タッチターゲット: インタラクティブな要素が最小タッチターゲットサイズを満たしていることを確認してください
  1. 閉じる動作: ユーザーが地図やマーカーをタップしてバブルを閉じることができるようにしてください
  2. 読み込み状態: ネットワークリクエストが必要なコンテンツについては、読み込みインジケーターを表示してください
  3. エラー処理: 不足または無効なデータを適切に処理してください
  4. アクセシビリティ: スクリーンリーダーにコンテンツの説明を提供してください
// 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. バブルが表示されない: マーカーが適切に構成されており、InfoBubble が MapViewScope 内にあることを確認してください
  2. バブルが閉じない: 条件付きレンダリングが状態の変化に適切に反応しているか確認してください
  3. パフォーマンスの低下: 同時に表示するバブルの数を制限し、コンテンツのコンポジションを最適化してください
  4. レイアウトの問題: 適切なサイズ制約を使用し、さまざまな画面サイズでテストしてください