문서 / 기본 / Info Bubble

정보 버블(InfoBubble)

마커에 연결되는 말풍선을, 각 플랫폼의 UI 코드로 그대로 쓸 수 있습니다. 내용은 Compose의 Composable, SwiftUI의 View, React의 요소. 위치 맞추기와 꼬리의 렌더링만을 SDK가 담당합니다.

ANDROID
com.mapconductor.compose.info
iOS
MapConductorCore
REACT
@mapconductor/js-sdk-react

01 · 구조

3개 플랫폼 공통으로 「선택 중인 마커를 상태로 가지고, 선택되어 있는 동안만 버블을 그린다」는 조립입니다. 버블의 열고 닫힘은 SDK가 아니라 앱 쪽의 state가 정합니다.

선택 상태를 가진다

STEP 01

선택 중인 마커(또는 ID)를 state로 보관합니다. 여러 개 동시 표시라면 Set을 씁니다.

지도의 자식으로 놓는다

STEP 02

Marker와 InfoBubble을 지도 컨테이너의 자식으로 나란히 두고, 선택 중일 때만 InfoBubble을 렌더링합니다.

지도 탭으로 닫는다

STEP 03

지도의 onMapClick으로 선택을 해제합니다. 마커의 onClick이 선택을 설정합니다.

샘플 · 마커를 선택해 버블을 연 모습

02 · 4가지 패턴

샘플 앱의 infobubble 아래에 있는 4페이지가, 그대로 4가지 구현 패턴이 되어 있습니다. 패턴과 플랫폼을 고르면, 대응하는 샘플의 코드가 표시됩니다.

플랫폼

텍스트 1행

최소 구성. marker의 extra에 넣은 문자열을 1행으로 표시합니다. 테두리・꼬리・여백은 기본값 그대로이므로, 쓰는 것은 내용의 텍스트뿐입니다.

SimpleTextBubblePage.kt
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),
            )
        }
    }
}
보이는 모습
San Francisco - The Golden Gate City
SF

기본 꼬리는 마커 바로 위(tailOffset x:0.5 / y:1.0)에 고정됩니다.

스타일 변경

테두리의 스타일을 바꾸는 패턴. 색이나 둥근 모서리를 바꾸기만 한다면 Android・React는 개별 인수, iOS는 InfoBubbleStyle로 넘깁니다. 테두리와 꼬리까지 직접 그린다면 3개 플랫폼 모두 InfoBubbleCustom을 씁니다(Compose는 Canvas, SwiftUI는 Shape, React는 CSS의 ::before / ::after).

StyledInfoBubblePage.kt
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)
        }
    }
}
StyledInfoBubblePage.kt · RightTailInfoBubble
@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()
        }
    }
}
StyledInfoBubblePage.tsx
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>
)}
index.css
.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로 왼쪽을 향한 삼각형을 겹쳐 꼬리로 쓴다 */
보이는 모습
1
37.7749,-122.419404

연결점은 tailOffset(버블 쪽)과 아이콘의 infoAnchor(마커 쪽)로 정해집니다.

리치 콘텐츠

extra에 객체를 넣고, 제목・설명・평가를 포함하는 레이아웃을 넣습니다. 테두리는 bubbleColor / borderColor / contentPadding / cornerRadius로 조정할 수 있고, 내용의 코드는 내용의 레이아웃에만 집중할 수 있습니다.

RichContentBubblePage.kt
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)
            }
        }
    }
}
보이는 모습
Golden Gate Park

A large urban park with gardens, museums, and recreational areas.

★★★★4.5/5
🌳

다크 테마일 때 테두리와 배경색을 바꾸는 예도 Android / iOS 샘플에 들어 있습니다.

복수 동시 표시

선택을 Set으로 가지면, 여러 버블을 동시에 열어 둘 수 있습니다. 열려 있는 마커만 InfoBubble을 그리고, 내용에 탭 조작을 붙이면 버블 자신으로 닫을 수 있습니다.

MultipleBubblesPage.kt
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)
                }
            }
        }
    }
}
보이는 모습
Restaurant ATap to close
1
Hotel BTap to close
2
Shop CTap to close
3

지도를 탭하면 전부 닫히는(빈 Set으로 돌아가는) 동작도 각 샘플에 들어 있습니다.

03 · API

InfoBubble은 꼬리가 붙은 테두리를 그리는 고수준 컴포넌트, InfoBubbleCustom은 위치 맞추기만을 하는 저수준 컴포넌트입니다. iOS는 스타일 인수를 InfoBubbleStyle에 모아서 넘깁니다.

PARAM
ANDROID
REACT
설명
marker
필수
필수
버블을 연결할 마커의 상태. 위치와 아이콘 크기에서 연결점을 계산합니다.
bubbleColor
Color.White
'#ffffff'
버블의 배경색.
borderColor
Color.Black
'#000000'
테두리선의 색.
contentPadding
8.dp
8
내용의 안쪽 여백.
cornerRadius
4.dp
4
둥근 모서리의 반지름.
tailSize
8.dp
8
꼬리의 크기.
content / children
필수
필수
버블의 내용. 각 플랫폼의 UI 코드를 그대로 넘깁니다.
InfoBubbleStyle · iOS

iOS는 개별 인수가 아니라, 이 구조체를 한꺼번에 style에 넘깁니다.

InfoBubbleStyle(
  bubbleColor: Color,
  borderColor: Color,
  contentPadding: CGFloat,
  cornerRadius: CGFloat,
  tailSize: CGFloat
)
InfoBubbleCustom

테두리도 꼬리도 직접 그리는 패턴. SDK는 위치 맞추기만을 담당합니다.

marker: MarkerState
tailOffset: Offset   // ios: CGPoint
content / children
InfoBubble (position)

마커가 아니라 좌표에 직접 버블을 냅니다. 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과는 독립적으로 동작하므로, 닫는 조작을 내용 쪽에 가지게 할 수 있습니다.

관련 페이지