아티클 목록으로 가기

Balloon 2.0.0: Compose Multiplatform에서 동작하는 툴팁

skydovesJaewoong Eum (skydoves)||11분 소요

Balloon 2.0.0: Compose Multiplatform에서 동작하는 툴팁

안드로이드와 iOS, 데스크톱에서 동작하는 Balloon 2.0.0

Balloon은 2018년에 PopupWindowFrameLayout, 그리고 XML 애니메이션 뭉치 위에 올린 안드로이드 툴팁(tooltip) 라이브러리로 출발했습니다. 이 토대 덕에 안드로이드에서는 잘 굴러갔지만, 그 대가로 다른 어디에서도 돌아갈 수 없었습니다. 방금 나열한 조각들이 하나같이 android.로 시작하기 때문입니다. 2.0.0은 이 전부를 Compose Multiplatform 구현으로 갈아치웠고, 안드로이드와 iOS, 데스크톱, 웹을 아티팩트 하나로 함께 지원합니다. 공개 API는 쓰던 이름을 그대로 물려받아서 1.x의 빌더 블록은 대체로 예전과 똑같이 읽히지만, 그 아래에서 살아남은 것은 하나도 없습니다.

이번 글에서는 이 재작성에 실제로 무엇이 필요했는지를 깊이 파고듭니다. 툴팁이 왜 대부분 윈도우 문제인지, 오버레이 스크림(scrim)이 왜 balloon과 같은 윈도우에 살 수 없는지, 화살표가 예전과 똑같은 픽셀에 내려앉도록 붙잡아 주는 박스 모델은 어떤 모양인지, 배치가 미끄러지는 대신 어떻게 뒤집히는지, 그리고 새로 쓴 구현을 자신이 대체한 구현과 견주어 어떻게 검증했는지를 차례로 살펴봅니다.

근본적인 문제: 툴팁은 대부분 윈도우 문제다

툴팁은 자그마한 그리기 작업처럼 보입니다. 둥근 사각형을 그리고, 한쪽 변에 삼각형을 붙이고, 안에 내용을 채워 넣으면 그만입니다. 그런데 그리기는 쉬운 쪽입니다. 툴팁을 어렵게 만드는 것은 부모를 빠져나가야 한다는 점입니다.

balloon을 앵커(anchor)의 평범한 형제로 렌더링하면 어떻게 되는지 살펴보겠습니다.

Column(modifier = Modifier.height(44.dp)) {
    Button(onClick = { }) { Text("Edit profile") }
    if (visible) {
        TooltipBody()
    }
}

이렇게 하면 툴팁은 Column에 잘리고, Column의 제약 조건에 따라 크기가 정해지며, 나타나는 순간 버튼을 밀어냅니다. 높이가 44dp인 부모 아래에서는 툴팁도 높이 44dp가 됩니다. 게다가 부모 바깥에는 아무것도 그릴 수 없습니다. 정작 툴팁은 그 일을 하려고 존재하는데도 말입니다.

그러다 보니 제대로 된 툴팁은 예외 없이 별도의 윈도우에 렌더링됩니다. 그리고 윈도우를 따로 쓰는 순간 네 가지 문제가 한꺼번에 밀려옵니다. 다른 윈도우에 사는 앵커를 기준으로 그 윈도우를 어디에 놓을지 계산해야 하고, 공간이 모자랄 때 어떻게 할지도 정해야 합니다. 두 윈도우 사이에서 터치가 어디로 갈지를 정리하는 일도 남습니다. 마지막으로 뒤쪽 화면을 어둡게 덮고 싶다면, 그 윈도우가 닿을 수 없는 영역까지 포함해 화면 전체를 가리는 또 다른 표면이 필요합니다.

1.x 구현은 이 넷을 전부 안드로이드 API로 풀었습니다. 배치는 PopupWindow.showAsDropDown이, 오버레이는 두 번째 PopupWindow가, 터치 라우팅은 FLAG_NOT_TOUCH_MODAL이, 셰이프는 ViewOutlineProvider가 맡았습니다. Compose Multiplatform이 쥐여 주는 것은 PopupPopupPositionProvider, 그리고 Shape뿐이고, 나머지는 직접 만들어야 합니다.

지금의 API는 어떤 모습인가

진입점(entry point)은 스타일과 상태, 그리고 앵커를 감싸는 컴포저블 이렇게 셋입니다.

val style = rememberBalloonBuilder {
    setArrowSize(10.dp)
    setPadding(12.dp)
    setCornerRadius(8.dp)
    setBackgroundColor(Color(0xFF785EF0))
    setBalloonAnimation(BalloonAnimation.ELASTIC)
}
val balloonState = rememberBalloonState(style)

Balloon(
    state = balloonState,
    balloonContent = { Text(text = "Now you can edit your profile", color = Color.White) },
) {
    Button(onClick = { balloonState.showAlignTop() }) {
        Text(text = "Edit profile")
    }
}

빌더는 1.x가 쓰던 그 플루언트(fluent) 객체 그대로이며, 57개의 setter가 예전에 지니던 이름과 기본값을 고스란히 들고 있습니다. 달라진 쪽은 콘텐츠입니다. setText도, TextForm도, IconForm도, 레이아웃 리소스를 받는 setLayout도 없습니다. 안드로이드 바깥에서는 어느 것도 의미가 없기 때문입니다. 본문은 컴포저블 슬롯이라서, 아이콘 한 줄과 닫기 버튼을 담은 balloon도 그저 RowButton이면 됩니다.

표시 여부와 배치는 BalloonState가 쥐고 있습니다. 자기 앵커를 이미 알고 있으므로 showAlignTop()은 인자를 받지 않습니다. 호출할 때마다 View를 다시 넘겨받아야 했던 1.x와는 다른 부분입니다. 방향마다 대응하는 show 호출이 있고, 여기에 시작 가장자리에 맞추는 showAsDropDown과 앵커 중심을 가리키는 showAtCenter가 더해집니다.

showAlignTopshowAlignBottomshowAlignStartshowAlignEnd
showAlignTopshowAlignBottomshowAlignStartshowAlignEnd

balloon을 붙이는 두 가지 방법

앵커를 감싸면 컨테이너에 따라 레이아웃 위치가 달라지기도 하므로, 제자리에서 꾸며 주는 두 번째 형태를 따로 두었습니다.

Icon(
    imageVector = Icons.Default.Info,
    contentDescription = null,
    modifier = Modifier.balloon(
        state = balloonState,
        balloonContent = { Text("This is what the icon means") },
    ),
)

Modifier.balloon은 앵커 주위에 레이아웃 노드를 덧붙이는 대신, 가장 가까운 호스트에 앵커를 등록합니다. 두 형태 모두 같은 balloon을 만들어 냅니다.

balloon에 호스트가 필요한 이유

2.0.0이 1.x와 달리 하나 요구하는 구조가 BalloonHost입니다.

BalloonHost {
    // 여러분의 화면
}

이유는 오버레이에 있습니다. setIsVisibleOverlay(true)는 화면을 어둡게 덮은 뒤 그 어두운 영역에서 앵커만 오려 냅니다. 스포트라이트 투어는 이렇게 만듭니다. 오려 내는 모양은 직접 고를 수 있습니다. 강조 영역을 사각형이나 타원, 원, 모서리마다 반지름을 따로 준 둥근 사각형 가운데 하나로 만들면 됩니다.

사각형타원둥근 사각형
overlay rectoverlay ovaloverlay circleoverlay round rect

이 스크림은 상태 표시줄과 내비게이션 바까지 포함해 윈도우 전체를 덮어야 하는데, Popup으로는 그 일을 할 수 없습니다. 팝업 윈도우는 자기 콘텐츠 크기에 맞춰지고, 플랫폼이 내어 준 영역 안에만 자리를 잡기 때문입니다.

그래서 스크림은 호스트가 그립니다. 그리는 자리는 애플리케이션 자신의 윈도우 안, balloon 팝업보다 아래쪽입니다. 오버레이를 원하는 balloon은 저마다 가장 가까운 호스트에 요청을 등록하고, 호스트가 그 요청들을 모아 스크림을 한꺼번에 그립니다.

val registry = LocalBalloonRegistry.current
if (style.isVisibleOverlay) {
    val request = remember(state) { BalloonOverlayRequest(state) }
    request.anchorBounds = anchorBounds
    DisposableEffect(registry, request) {
        registry.registerOverlay(request)
        onDispose { registry.unregisterOverlay(request) }
    }
}

오려 내기는 두 번째 모양을 그리는 일이 아니라 블렌드 모드 연산입니다. 호스트는 자기 영역 전체를 오버레이 색으로 채운 다음, CompositingStrategy.Offscreen을 쓰는 레이어 안에서 BlendMode.Clear로 앵커 모양을 지웁니다. 마스킹이 아니라 지우기를 택한 덕에 setOverlayPaddingColor가 패딩이 벌려 놓은 틈에 링을 그릴 수 있습니다. 패딩이 적용된 모양을 채우고 패딩 없는 모양을 지우면, 남는 부분이 바로 그 둘 사이의 띠입니다.

지오메트리를 어긋나지 않게 붙잡는 박스 모델

재작성에서 가장 손이 많이 간 곳은 그리기가 아니라 그 주변의 산수였습니다. 1.x의 balloon은 PopupWindow였고, 그 너비와 높이에는 마진과 그림자 인셋이 포함되어 있었으며, 크기 관련 setter는 전부 이 바깥 박스를 기준으로 쟀습니다. setWidthRatio(0.5f)는 카드가 아니라 윈도우를 화면의 절반으로 만든다는 뜻이었습니다.

2.0.0은 이 모델을 그대로 지킵니다. 여기에 손을 대면 이식된 balloon의 크기가 조용히 전부 달라져 버리기 때문입니다. 팝업 박스는 마진에 reserve와 카드를 더한 크기이고, 카드는 패딩에 여러분의 콘텐츠를 더한 크기입니다.

reserve는 눈에 보이는 카드 주위의 공간 가운데, 팝업 안에는 있지만 카드에는 속하지 않는 영역입니다. 세 부분으로 나뉩니다. 화살표가 놓인 쪽의 화살표 돌출분, 그 반대쪽에 들어가는 같은 크기의 여백 또는 elevation 인셋, 그리고 나머지 두 변의 elevation 인셋입니다.

이식 작업이 유난히 예민해지는 지점이 바로 이 돌출분입니다.

internal fun arrowProtrusionPx(arrowHeightPx: Float): Float =
  (arrowHeightPx - ARROW_BOUNDARY_PX).coerceAtLeast(0f)

ARROW_BOUNDARY_PX1f입니다. View 구현은 삼각형과 본문 사이에 이음매가 드러나지 않도록 화살표를 카드 안쪽으로 1픽셀 밀어 넣었고, 그 결과 12dp짜리 화살표가 실제로 튀어나오는 길이는 12dp가 아니라 12dp에서 1픽셀을 뺀 값이 됩니다. 이 픽셀 하나가 이식된 레이아웃이 딱 맞아떨어지느냐, 아니면 모든 balloon에서 1픽셀씩 어긋나느냐를 가릅니다.

카드 자체는 Outline.Generic을 만들어 내는 Shape 하나여서, 둥근 사각형과 화살표는 배경 위에 삼각형을 겹쳐 놓은 형태가 아니라 하나의 경로(path)로 이어집니다.

path.lineTo(rectRight - radius, rectTop)
path.quadraticTo(rectRight, rectTop, rectRight, rectTop + radius)
path.lineTo(arrowCenterX + halfArrow, rectTop)
path.lineTo(arrowCenterX, tipY)
path.lineTo(arrowCenterX - halfArrow, rectTop)

경로를 하나로 만드는 선택은 두 가지 면에서 중요합니다. Modifier.border가 아웃라인을 따라 선을 긋기 때문에, 테두리가 본문에서 끊기지 않고 화살표까지 따라갑니다. 그리고 Modifier.clip이 콘텐츠를 그 경로에 맞춰 잘라 내므로, 여백 없이 꽉 찬 본문이 둥근 배경 위에 각진 모서리를 그려 버리는 일도 없습니다. 방금 말한 이 동작은 1.x에서 실제 버그였는데, 커스텀 레이아웃이 모서리를 되찾으려면 setIsClipArrowEnabled(true)가 필요했습니다.

이식하면서 일부러 맞추지 않은 곳도 한 군데 있습니다. elevation은 공간을 예약하고 너비 계산에도 관여하지만, 그림자는 그려지지 않습니다. Compose는 볼록한 아웃라인에서만 그림자를 드리울 수 있는데, 한쪽 변에 삼각형이 붙은 사각형은 볼록하지 않기 때문입니다. 그림자가 필요하시다면 슬롯 안에서 Modifier.shadow를 써서 본문에 직접 그리시면 됩니다.

배치: 미끄러뜨리는 대신 뒤집기

배치는 PopupPositionProvider 안에서 이뤄집니다. Compose가 앵커 경계와 윈도우 크기, 그리고 측정된 팝업 크기를 넘기며 이 프로바이더를 호출합니다. 재미있는 대목은 잘 맞아떨어지는 경우가 아니라, balloon이 들어가지 못할 때 벌어지는 일입니다.

이 대목에서 1.x는 PopupWindow의 동작을 물려받았습니다. 세로로는 뒤집혔는데, showAsDropDown이 뒤집기 때문입니다. 가로로는 넘침이 사라질 때까지 balloon을 윈도우 가장자리를 따라 미끄러뜨렸고, 그 결과 balloon이 자기가 가리키던 앵커 위에 올라앉는 일이 심심찮게 벌어졌습니다.

2.0.0은 두 축 모두에서 뒤집습니다. 요청한 방향에 자리가 없고 반대쪽에 자리가 있으면 balloon이 그쪽으로 옮겨 가고 화살표도 함께 따라갑니다. 다만 ArrowOrientationRules.ALIGN_FIXED로 화살표를 원하는 자리에 고정해 두었다면 그대로 남습니다. 자리를 재는 계산에는 호출자가 준 오프셋도 들어가므로, yOffset만큼 아래로 밀린 balloon은 오프셋이 없었다면 필요했을 공간이 아니라 지금 실제로 필요한 공간을 기준으로 뒤집힙니다.

뒤집기가 끝나고, 팝업을 화면 안에 붙잡아 두는 마지막 clamp까지 끝나면, 화살표는 balloon이 실제로 내려앉은 위치를 기준으로 다시 맞춰집니다. ArrowPositionRules.ALIGN_ANCHOR가 동작하는 근거가 바로 여기에 있습니다. 본문이 옆으로 밀려났더라도 화살표는 계속 앵커를 가리킵니다.

이렇게 확정된 위치는 프로바이더가 도는 레이아웃 패스에서 셰이프를 만드는 컴포지션까지 되돌아가야 합니다. 그 값은 팝업 레이어가 소유한 작은 홀더에 담깁니다.

@Stable
internal class BalloonArrowPlacement {
  var orientation: ArrowOrientation? by mutableStateOf(null)
  var centerPx: Float? by mutableStateOf(null)
}

홀더를 상태가 아니라 팝업 레이어마다 하나씩 두는 것은 사소한 선택이 아닙니다. 이 값이 BalloonState에 얹혀 있던 시절에는, 상태 하나를 공유하는 앵커 두 개가 같은 홀더에 서로 다른 값을 써 넣고 매 프레임 서로를 무효화했습니다. 컴포지션은 한 번도 쉬지 못했고 앱은 멈춰 버렸습니다. 팝업마다 홀더를 하나씩 두면 그런 상황이 구조적으로 불가능해집니다.

스타일은 값이고, 리스너는 값이 아니다

BalloonStyle은 프로퍼티 43개를 가진 불변 data class이며, 값으로 비교되도록 일부러 그렇게 설계했습니다. 똑같은 스타일 둘은 같다고 판정되므로 변경 여부를 가볍게 감지할 수 있습니다. 덕분에 rememberBalloonState는 balloon을 숨겼다 다시 띄우지 않고도 리컴포지션(Recomposition)마다 스타일을 다시 적용할 수 있습니다. 애니메이션이 붙은 스타일이 동작하는 것도 같은 이유에서입니다.

val color by animateColorAsState(if (selected) Color(0xFF785EF0) else Color(0xFF444444))
val balloonState = rememberBalloonState(style.derive { setBackgroundColor(color) })

리스너가 스타일에 얹혀 있지 않은 이유도 이 값 동등성 때문입니다. 람다는 구조적 동등성(structural equality)을 깨뜨리므로, onBalloonClickonDismiss, onOverlayClick은 빌더의 setter가 아니라 BalloonState의 프로퍼티로 자리를 잡았습니다.

derive도 짚어 둘 만합니다. data class를 쓰면 copy가 공짜로 딸려 오는데, 프로퍼티가 43개인 클래스에서 copy는 그 43개를 전부 공개 바이너리 인터페이스에 새겨 넣습니다. 이후 2.x 어느 릴리스에서든 44번째 옵션을 하나 추가하는 순간, 2.0.0에 맞춰 컴파일된 호출자가 전부 깨집니다. 그래서 생성자와 copy는 internal로 막아 두고, deriverememberBalloonBuilder와 똑같은 빌더 블록을 받되 기본값이 아니라 기존 스타일에서 출발하도록 했습니다. 람다 매개변수 하나뿐이라 시그니처가 바뀔 일이 없습니다.

새로 쓴 구현을 원래 구현과 견주어 검증하기

이미 많은 사람이 의존하고 있는 라이브러리를 재작성할 때는 뻔한 실패 방식이 하나 있습니다. 컴파일도 되고 데모에서도 멀쩡해 보이는데, 정작 모두의 툴팁이 4픽셀씩 옮겨 가 있는 경우입니다.

그래서 두 구현을 나란히 렌더링해 놓고 차이를 비교했습니다. 두 데모 앱 모두에 한 번에 정확히 한 가지 설정만 그리는 화면을 넣었고, 그 설정은 인텐트 엑스트라로 지정했으며, 앵커와 balloon 본문은 텍스트 대신 센티널(sentinel) 색으로 칠했습니다. 텍스트를 쓰면 비교할 때마다 폰트 메트릭이 딸려 들어오는 데다, AndroidX와 JetBrains Compose 런타임은 텍스트를 재는 방식이 미묘하게 다릅니다. 단색으로 칠해 두면 카드 사각형과 화살표 삼각형, 콘텐츠 사각형, 테두리, 오버레이로 오려 낸 영역을 스크린샷에서 각각 되살릴 수 있습니다. 픽셀 하나하나를 가장 가까운 센티널 색으로 분류하기만 하면 됩니다.

정렬, 모서리 반지름, 두 가지 위치 규칙에 따른 화살표 크기와 위치, 패딩, 마진, 모든 너비와 높이 지정 방식, 오프셋, 윈도우 가장자리, 테두리, 알파, 오버레이 모양까지 아우르는 89가지 설정을 같은 에뮬레이터에서 양쪽 스택으로 캡처해 수치로 비교했습니다. 남은 차이는 앞서 설명한 의도적인 것들뿐이고, 하나하나 이유와 함께 기록해 두었습니다.

이 검증 하니스(harness)가 답하는 질문은 '1.7.6과 일치하는가'인데, 2.0.0이 출시되고 나면 더 이상 유효한 질문이 아닙니다. 그래서 그 자리를 대신할 질문에 답하는 두 번째 테스트 스위트(suite)를 따로 두었습니다. 골든 스크린샷 212장이 고정된 씬 크기와 밀도로 runSkikoComposeUiTest를 거쳐 렌더링되고, 저장된 PNG와 비교됩니다. 라이브러리가 그리는 결과를 바꾸는 변경은 무엇이든 여기서 실패하며, 어긋난 픽셀 수와 경계 상자, 그리고 차이를 기록한 이미지가 함께 남습니다.

이 케이스들을 쓰면서 한 번쯤 되새길 만한 교훈도 얻었습니다. 지오메트리를 따져 보지 않고 setter만 바꿔 가며 만든 골든은 결코 실패할 수 없는 테스트가 되기도 합니다. ALIGN_ANCHOR의 패딩 띠는 화살표가 카드 한쪽 끝 가까이에 놓이려 할 때에만 효력이 생기는데, 화살표 위치 기본값인 0.5f에서는 setArrowAlignAnchorPadding에 어떤 값을 넣어도 똑같은 이미지가 나왔습니다. 아홉 개의 케이스가 전부 초록불이었지만 증명한 것은 하나도 없었습니다. 해법은 clamp가 실제로 관여하는 띠 안쪽으로 화살표 위치를 옮기는 것이었습니다.

지원하는 타겟

아티팩트는 하나이고, 배리언트는 Gradle이 알아서 고릅니다.

kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation("com.github.skydoves:balloon:2.0.0")
        }
    }
}

배포되는 목록은 balloon-android, balloon-desktop, balloon-iosarm64, balloon-iossimulatorarm64, balloon-iosx64, balloon-wasm-js입니다. 안드로이드에서도 마찬가지로 의존성 한 줄이면 끝이고, Compose Multiplatform 설정은 따로 필요하지 않습니다.

구현의 거의 전부가 공통 코드입니다. 플랫폼별로 갈리는 파일은 PopupProperties를 만드는 파일 하나뿐입니다. 안드로이드와 Skia, 두 계열이 좌표를 두고 서로 다른 이야기를 하기 때문에 이 파일이 남아 있습니다. 안드로이드에서는 clipping을 꺼서, 프레임워크가 시스템 바를 제외한 디스플레이 프레임이 아니라 윈도우 전체 경계를 보고하도록 만듭니다. boundsInWindow가 재는 공간이 바로 그 윈도우 전체 경계입니다. Skia 타겟에서는 같은 이유로 usePlatformInsets를 끕니다. 프로바이더가 가공되지 않은 윈도우 좌표계에서 위치를 잡는데, 그대로 두면 skiko가 인셋을 한 번 더 더해 버리기 때문입니다.

1.x에서 마이그레이션하기

View 구현은 어디로도 사라지지 않습니다. 1.7.6으로 계속 배포되고, 여전히 잘 동작하며, 2.0.0이 그 자리를 덮어쓰지도 않습니다. 같은 메이븐 좌표(Maven coordinates) 아래 서로 다른 API가 놓인 셈이니, 원하시는 버전을 고정해서 쓰시면 됩니다.

옮겨 오실 분들을 위해 마이그레이션 가이드에 1.x의 모든 setter와 그에 대응하는 항목을 정리해 두었습니다. 없어진 setter와 그 대체 수단까지 함께 담았습니다. 짧게 요약하면 빌더 블록은 약간만 손보면 옮겨 올 수 있고, 콘텐츠는 컴포저블이 되며, 리스너는 빌더에서 상태로 자리를 옮기고, show 호출은 더 이상 View를 받지 않습니다.

결론

이제 막 써 보기 시작하셨다면, 텍스트나 아이콘을 설정하던 setter를 찾는 대신 컴포저블 슬롯부터 꺼내 드는 습관을 일찍 들이시는 편이 좋습니다. 1.x balloon을 이식할 때 겪는 마찰은 대부분 setTextForm을 찾아 헤매다 아무것도 나오지 않는 데서 옵니다. 이제 본문은 여러분의 몫이고, 라이브러리는 그 둘레의 상자만 책임집니다. 화면은 호스트로 한 번 감싸 두고, balloon마다 상태를 하나씩 두고, 스타일은 바꿔 가며 쓰는 객체가 아니라 변형을 파생시킬 수 있는 값으로 다루세요.

재작성을 하며 제게 남은 것은, 툴팁이라는 물건이 알고 보면 그리기보다 산수에 훨씬 가깝다는 사실이었습니다. 경로를 그리는 코드는 서른 줄입니다. 정말 손이 많이 간 쪽은 화살표가 카드 안으로 파고드는 1픽셀, 어디에도 들어가지 않을 때 뒤집을지 clamp할지 정하는 판단, 그리고 레이아웃에서 계산한 숫자를 컴포지션에서 만드는 셰이프까지, balloon 두 개가 서로 붙들고 교착에 빠지는 일 없이 돌려보내는 일이었습니다. 삼각형이 붙은 둥근 사각형을 그리는 것은 그래픽스 문제입니다. 네 플랫폼 모두에서 그 도형이 예전 구현과 똑같은 픽셀에 내려앉게 만드는 일은 측정 문제입니다.

아티클 목록으로 가기