안드로이드에서 Balloon 툴팁 구현하실 때, 아마 알고 계신 분들도 많겠지만 해당 라이브러리를 사용하는 경우가 많습니다.
https://github.com/skydoves/Balloon
기존 View 방식이나 Compose 방식을 모두 활용할 수 있기에 간단하게 사용하실 분들은 바로 이용하셔도 좋겠습니다.
저는 조금 특수하게 커스터마이징을 해야 할 일이 있어서 직접 구현에 도전해 보게 되었습니다.
구현에 앞서서 먼저 혹시 Compose에서 자체적으로 제공하는 컴포넌트가 없을까 하고 찾아보니 Materal3에서 TootipBox라는 컴포넌트를 지원하고 있더군요!
@Composable
@ExperimentalMaterial3Api
fun TooltipBox(
positionProvider: PopupPositionProvider,
tooltip: @Composable CaretScope.() -> Unit,
state: TooltipState,
modifier: Modifier = Modifier,
focusable: Boolean = true,
enableUserInput: Boolean = true,
content: @Composable () -> Unit,
) {
@Suppress("DEPRECATION")
val transition = updateTransition(state.transition, label = "tooltip transition")
var anchorBounds: LayoutCoordinates? by remember { mutableStateOf(null) }
val scope = remember {
object : CaretScope {
override fun Modifier.drawCaret(
draw: CacheDrawScope.(LayoutCoordinates?) -> DrawResult
): Modifier =
this.drawWithCache { draw(anchorBounds) }
}
}
val wrappedContent: @Composable () -> Unit = {
Box(
modifier = Modifier.onGloballyPositioned { anchorBounds = it }
) {
content()
}
}
BasicTooltipBox(
positionProvider = positionProvider,
tooltip = { Box(Modifier.animateTooltip(transition)) { scope.tooltip() } },
focusable = focusable,
enableUserInput = enableUserInput,
state = state,
modifier = modifier,
content = wrappedContent
)
}
해당 Compose 컴포넌트를 활용하여 아래와 같은 화면 기능을 구성해보겠습니다.
먼저 툴팁이 표시될 상하좌우 방향에 대해 enum으로 선언합니다.
enum class TooltipDirection {
Top, Bottom, Left, Right
}
그리고 툴팁 컴포저블 함수는 해당 enum을 파라미터로 받습니다.
@Composable
fun CustomTooltipBox(
direction: TooltipDirection
) { ... }
툴팁의 showing 상태와 실행을 지원해 줄 remember를 선언합니다.
val tooltipState = remember { TooltipState() }
val coroutineScope = rememberCoroutineScope()
이제 TooltipBox()를 구성합니다.
state에는 앞에서 선언한 tooltipState remember를, positionProvider에는 툴팁 팝업 위치 속성을 조정 가능한 PopupPositionProvider를 넣어줘야 합니다.
tooltip에는 구현하고자 하는 툴팁의 레이아웃을 구성해 주고, content에서는 툴팁 효과를 호출시킬 대상 View 또는 레이아웃울 구성합니다.
TooltipBox(
state = tooltipState,
positionProvider = remember {
CustomTooltipPositionProvider(direction)
},
tooltip = {
when (direction) {
TooltipDirection.Top -> {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(Color.DarkGray)
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(
text = "툴팁 텍스트",
modifier = Modifier.padding(8.dp),
color = Color.White,
)
}
Canvas(modifier = Modifier.size(10.dp)) {
drawTooltipArrow(this, Color.DarkGray, direction)
}
}
}
TooltipDirection.Bottom -> {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Canvas(modifier = Modifier.size(10.dp)) {
drawTooltipArrow(this, Color.DarkGray, direction)
}
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(Color.DarkGray)
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(
text = "툴팁 텍스트",
modifier = Modifier.padding(8.dp),
color = Color.White,
)
}
}
}
TooltipDirection.Left -> {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(Color.DarkGray)
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(
text = "툴팁 텍스트",
modifier = Modifier.padding(8.dp),
color = Color.White,
)
}
Canvas(modifier = Modifier.size(10.dp)) {
drawTooltipArrow(this, Color.DarkGray, direction)
}
}
}
TooltipDirection.Right -> {
Row(verticalAlignment = Alignment.CenterVertically) {
Canvas(modifier = Modifier.size(10.dp)) {
drawTooltipArrow(this, Color.DarkGray, direction)
}
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(Color.DarkGray)
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(
text = "툴팁 텍스트",
modifier = Modifier.padding(8.dp),
color = Color.White,
)
}
}
}
}
},
content = {
Box(
modifier = Modifier
.background(Color.Gray)
.padding(16.dp)
.clickable {
coroutineScope.launch { tooltipState.show() }
}
) {
Text("Hover me")
}
}
)
content는 간단하게 Text Button을 구성해 줬고,
tooltip 레이아웃은 Direction 마다 달리 가져 다양한 방향으로 툴팁이 보이도록 했습니다.
tooltip = {
when (direction) {
TooltipDirection.Top -> {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(Color.DarkGray)
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(
text = "툴팁 텍스트",
modifier = Modifier.padding(8.dp),
color = Color.White,
)
}
Canvas(modifier = Modifier.size(10.dp)) {
drawTooltipArrow(this, Color.DarkGray, direction)
}
}
}
TooltipDirection.Bottom -> {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Canvas(modifier = Modifier.size(10.dp)) {
drawTooltipArrow(this, Color.DarkGray, direction)
}
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(Color.DarkGray)
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(
text = "툴팁 텍스트",
modifier = Modifier.padding(8.dp),
color = Color.White,
)
}
}
}
TooltipDirection.Left -> {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(Color.DarkGray)
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(
text = "툴팁 텍스트",
modifier = Modifier.padding(8.dp),
color = Color.White,
)
}
Canvas(modifier = Modifier.size(10.dp)) {
drawTooltipArrow(this, Color.DarkGray, direction)
}
}
}
TooltipDirection.Right -> {
Row(verticalAlignment = Alignment.CenterVertically) {
Canvas(modifier = Modifier.size(10.dp)) {
drawTooltipArrow(this, Color.DarkGray, direction)
}
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(Color.DarkGray)
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(
text = "툴팁 텍스트",
modifier = Modifier.padding(8.dp),
color = Color.White,
)
}
}
}
}
},
화살표 모양 같은 경우에는 Compose Canvas를 활용했고, 마찬가지로 Direction을 파라미터로 받아
매번 다르게 draw 하도록 구현했습니다.
Canvas(modifier = Modifier.size(10.dp)) {
drawTooltipArrow(this, Color.DarkGray, direction)
}
.
.
.
fun DrawScope.drawTooltipArrow(scope: DrawScope, color: Color, direction: TooltipDirection) {
val path = Path().apply {
when (direction) {
TooltipDirection.Top -> {
moveTo(0f, 0f)
lineTo(size.width / 2, size.height)
lineTo(size.width, 0f)
}
TooltipDirection.Bottom -> {
moveTo(0f, size.height)
lineTo(size.width / 2, 0f)
lineTo(size.width, size.height)
}
TooltipDirection.Left -> {
moveTo(size.width, size.height / 2)
lineTo(0f, 0f)
lineTo(0f, size.height)
}
TooltipDirection.Right -> {
moveTo(0f, size.height / 2)
lineTo(size.width, 0f)
lineTo(size.width, size.height)
}
}
close()
}
scope.drawPath(
path = path,
color = color
)
}
DrawScope는 Jetpack Compose에서 제공하는 API로, 사용자 정의 그리기 작업을 수행할 수 있게 해줍니다.
DrawScope를 사용하면 Canvas 컴포저블 내에서 직접 도형을 그리거나, 복잡한 커스텀 렌더링 로직을 구현할 수 있습니다.
마지막으로 PopupPositionProvider를 상속받아 TooltipPositionProvider를 구현합니다.
TooltipPositionProvider는 Jetpack Compose에서 툴팁(Tooltip)의 위치를 결정하는 데 사용되는 인터페이스입니다.
툴팁이 어떤 기준으로 어디에 나타날지를 정의하는 역할을 합니다. 이를 통해 툴팁이 부모 요소의 상단, 하단, 좌측, 우측 등 원하는 위치에 정확히 나타나도록 제어할 수 있습니다.
class CustomTooltipPositionProvider(private val direction: TooltipDirection) : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize
): IntOffset {
val x: Int
val y: Int
when (direction) {
TooltipDirection.Top -> {
x = anchorBounds.left + (anchorBounds.width / 2) - (popupContentSize.width / 2)
y = anchorBounds.top - popupContentSize.height
}
TooltipDirection.Bottom -> {
x = anchorBounds.left + (anchorBounds.width / 2) - (popupContentSize.width / 2)
y = anchorBounds.bottom
}
TooltipDirection.Left -> {
x = anchorBounds.left - popupContentSize.width
y = anchorBounds.top + (anchorBounds.height / 2) - (popupContentSize.height / 2)
}
TooltipDirection.Right -> {
x = anchorBounds.right
y = anchorBounds.top + (anchorBounds.height / 2) - (popupContentSize.height / 2)
}
}
return IntOffset(x, y)
}
}
이제 아래와 같이 동작하는 것을 확인할 수 있습니다.