정보 버블(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가지 구현 패턴이 되어 있습니다. 패턴과 플랫폼을 고르면, 대응하는 샘플의 코드가 표시됩니다.
텍스트 1행
최소 구성. 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)
}
}
}iOS의 샘플은 오른쪽을 향한 꼬리를 직접 그리는 것이 아니라, InfoBubbleStyle로 색・여백・꼬리 크기를 교체하는 접근입니다. Android / React 판과 보이는 모습이 다릅니다.
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로 어긋나게 하기
아이콘 쪽의 연결점은 infoAnchor(Compose는 Offset, React는 x/y)로 바꿀 수 있습니다. 드래그 중에도 버블은 따라옵니다.
버블의 내용은 이벤트를 받을 수 있다
Compose의 clickable, SwiftUI의 제스처, React의 onClick을 그대로 놓을 수 있습니다. 지도의 onMapClick과는 독립적으로 동작하므로, 닫는 조작을 내용 쪽에 가지게 할 수 있습니다.