情報バブル(InfoBubble)
マーカーに紐づく吹き出しを、各プラットフォームの UI コードでそのまま書けます。中身は Compose の Composable、SwiftUI の View、React の要素。位置合わせとしっぽの描画だけを SDK が担当します。
01 · しくみ
3プラットフォーム共通で「選択中のマーカーを状態として持ち、選択されている間だけバブルを描く」という組み立てです。バブルの開閉は SDK ではなくアプリ側の state が決めます。
選択中のマーカー(または ID)を state として保持します。複数同時表示なら Set を使います。
Marker と InfoBubble を地図コンテナの子として並べ、選択中のときだけ InfoBubble を描画します。
地図の onMapClick で選択を解除します。マーカーの onClick が選択をセットします。

02 · 4つのパターン
サンプルアプリの infobubble 配下にある4ページが、そのまま4つの実装パターンになっています。パターンとプラットフォームを選ぶと、対応するサンプルのコードが表示されます。
最小構成。marker の extra に入れた文字列を1行で表示します。枠・しっぽ・余白は既定値のままなので、書くのは中身のテキストだけです。
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>
);既定のしっぽはマーカーの真上(tailOffset x:0.5 / y:1.0)に固定されます。
枠のスタイルを変えるパターン。色や角丸を変えるだけなら Android・React は個別の引数、iOS は InfoBubbleStyle で渡します。枠としっぽごと自前で描くなら 3 プラットフォームとも 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), // 左中央で接続
) {
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 {
// 角丸の矩形を描き、左辺の中央に三角のしっぽを足す
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))
}
}
// 枠ごと自前で描く場合
Marker(state: customMarkerState)
InfoBubbleCustom(
marker: customMarkerState,
tailOffset: CGPoint(x: 0, y: 0.5) // 左中央で接続
) {
RightTailInfoBubble(bubbleColor: .white, borderColor: .black) {
Text("Fully custom bubble")
.font(.subheadline)
.foregroundColor(.accentColor)
}
}
}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;
}
/* ::before / ::after で左向きの三角を重ねてしっぽにする */接続点は tailOffset(バブル側)とアイコンの infoAnchor(マーカー側)で決まります。
extra にオブジェクトを入れ、見出し・説明・評価を含むレイアウトを埋め込みます。枠は bubbleColor / borderColor / contentPadding / cornerRadius で調整でき、中身のコードは内容のレイアウトだけに集中できます。
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.
ダークテーマ時に枠と背景色を差し替える例も、Android / iOS のサンプルに含まれています。
選択を Set で持つと、複数のバブルを同時に開いたままにできます。開いているマーカーだけ InfoBubble を描き、中身にタップ操作を付ければバブル自身で閉じられます。
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 / markerState3 も同じ形
}
.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'])
);
// 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
)}地図タップで全部閉じる(空の Set に戻す)動作も各サンプルに含まれています。
03 · API
InfoBubble はしっぽ付きの枠を描く高レベルなコンポーネント、InfoBubbleCustom は位置合わせだけを行う低レベルなコンポーネントです。iOS はスタイル引数を InfoBubbleStyle にまとめて渡します。
iOS は個別の引数ではなく、この構造体をまとめて style に渡します。
InfoBubbleStyle( bubbleColor: Color, borderColor: Color, contentPadding: CGFloat, cornerRadius: CGFloat, tailSize: CGFloat )
枠もしっぽも自前で描くパターン。SDK は位置合わせだけを担当します。
marker: MarkerState tailOffset: Offset // ios: CGPoint content / children
マーカーではなく座標に直接バブルを出します。3プラットフォームとも InfoBubble に position を渡す形(Compose・iOS では InfoBubble(position:) のオーバーロード)で統一されています。
position: GeoPoint // react: <InfoBubble position=… /> // compose / ios: InfoBubble(position:)
04 · 注意点
icon を渡さないマーカーは既定のピン(48px・下端が基準)として扱われ、その実サイズ分だけバブルがずれます。マーカーと重なることはありません。
アイコン側の接続点は infoAnchor(Compose は Offset、React は x/y)で変えられます。ドラッグ中もバブルは追随します。
Compose の clickable、SwiftUI のジェスチャ、React の onClick をそのまま置けます。地図の onMapClick とは独立に動くので、閉じる操作を中身側に持たせられます。