본문 바로가기

Android/Jetpack Compose

[Compose] Tooltip UI를 사용해보자.

728x90

보통 실무를 진행하게 되면 디자이너의 요청에 따라 여러 가지 UI를 만들 수밖에 없는데, 이번에는 Tooltip UI를 만들어보게 되었다.

이전에 포스팅 했었던 Flexbox Layout에 들어가 있는 아이템을 클릭했을 때 Tooltip이 뜨면서 추가적인 데이터를 노출시켜주어야 하는 케이스였다.

 

그에 따라서 Tooltip을 구현할 수 있는 방법을 찾아서 적용을 해보았는데, 일반적으로 Tooltip이 들어가는 경우에는 lazy 하게 데이터가 들어가는 케이스가 드문지, Flexbox Layout 구현에 대한 이해가 부족한지 모르겠지만 다양한 이슈가 발생하였다.

직접 Tooltip을 구현해서 사용하게 되면 위치를 계산하고 레이아웃을 그려줘야 하는데 Flexbox Layout의 경우 아이템의 위치가 UI가 그려질 당시에 정해지는 것이 아닌, 데이터가 갱신되는 타이밍에 따라서 실시간으로 위치가 변경되다 보니 y 좌표에 대한 값을 정상적으로 구하기 어려웠다.

 

그래서 직접 구현하는 방법은 사용하지 않고, Material3에서 제공해 주는 Tooltip UI를 사용하였는데,

이번 글에서는 해당 UI를 사용하는 방법에 대하여 작성하고자 한다.


우선,

필자가 직접 Tooltip UI를 구현하기 위해 봤던 예제는 해당 깃허브에 올라와있다.

코드를 보면 그렇게 길지는 않지만, offest을 구하기 위한 함수가 많이 들어가 있는 것을 확인할 수 있다.

 

별로 길지 않기 때문에 해당 함수를 수정해서 쓰면 간단하겠다! 싶었는데,

해당  Material3에서 제공하는 Tooltip UI를 보면 그 생각이 바뀔 것이다.

 

Material3에서 Tooltip UI를 보면 다음과 같이 나와있다.

 

 

생각보다 깔끔하고, 우리가 원하는 형태의 Tooltip인 것을 확인할 수 있다.

 

그리고 연결된 Android Developer 페이지에서 확인해 보면 다음과 같이 나와있다.

 

 

Tooltip에 관련된 UI 2개는 클릭을 통해 이동할 수 있는 이벤트가 빠져있다.

 

이게 뭔가 싶어서 검색을 해본다.

 

 

Plain이나 Rich가 붙어있는 이름이 아닌 이와 같이 단순하게 TooltipBox라는 이름의 컴포넌트가 존재한다.

 

우선 다르긴 하지만 있긴 있으니까, Material3 라이브러리를 적용하여 사용해 보도록 하자.

 

implementation "androidx.compose.material3:material3:$versions.meterial3"

 

Gradle 파일에 Material3 라이브러리를 넣어주도록 한다.

현재 최신 버전은 1.2.0-alpha04이지만, alpha01 버전부터 Tooltip UI를 사용할 수 있다고 한다.

 

해당 라이브러리를 적용한 다음, TooltipBox UI를 선언하여 자세히 확인해 보도록 하자.

 

 

공식 문서에서 자세히 확인할 수 있는 TooltipBox는 없고, Material3 공식 문서에 있는, 공식 문서에 클릭 이벤트가 빠져있는 Plain, RichTooltipBox 컴포넌트를 사용할 수 있다.

 

PlainTooltipBox를 선언해서 컴포넌트 내부를 확인해 보면 다음과 같다.

 

@Composable
@ExperimentalMaterial3Api
fun PlainTooltipBox(
    tooltip: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    tooltipState: PlainTooltipState = rememberPlainTooltipState(),
    shape: Shape = TooltipDefaults.plainTooltipContainerShape,
    containerColor: Color = TooltipDefaults.plainTooltipContainerColor,
    contentColor: Color = TooltipDefaults.plainTooltipContentColor,
    content: @Composable TooltipBoxScope.() -> Unit
) {
    val tooltipAnchorPadding = with(LocalDensity.current) { TooltipAnchorPadding.roundToPx() }
    val positionProvider = remember { PlainTooltipPositionProvider(tooltipAnchorPadding) }

    TooltipBox(
        tooltipContent = {
            PlainTooltipImpl(
                textColor = contentColor,
                content = tooltip
            )
        },
        modifier = modifier,
        tooltipState = tooltipState,
        shape = shape,
        containerColor = containerColor,
        tooltipPositionProvider = positionProvider,
        elevation = 0.dp,
        maxWidth = PlainTooltipMaxWidth,
        content = content
    )
}

 

내부에 TooltipBox가 있는 것으로 보아, 해당 컴포넌트를 사용하여 더 쉽게 사용할 수 있도록 만들어둔 컴포넌트로 보인다.

물론, RichTooltipBox도 동일하게 내부에 TooltipBox를 선언해서 사용하고 있다.

 

RichTooltipBox를 사용할 때 넣어주는 변수 값 자체는 간단하다.

여기서 설명이 필요한 부분이라고 하면, tooltip, tooltipState, content 정도이고 나머지는 이름 그대로의 속성 값이라고 생각하면 된다.

 

  1. tooltip : 툴팁이 활성화되었을 때 보여줄 Compose 함수.
  2. tooltipState : 툴팁 visible 옵션을 컨트롤할 상태 값.
  3. content : 툴팁을 가지고 있는 Compose 함수.

 

간단하게 구현하여 예로 든다면,

 

 

info Icon이 Content,

검은색 말풍선 아이템이 Tooltip,

말풍선 자체의 색상이 containerColor,

말풍선 내 텍스트의 색상이 ContentColor

말풍선의 모양이 Shape

 

가 된다.

 

 

Material3의 tooltip을 사용했을 때 좋은 점은, 기존에 다른 예제를 보고 Tooltip을 구현하게 되면 Tooltip에서 사용되는 아이템들의 위치를 전부 계산해서 넣어주어야 하는데, Material3을 사용하게 되면 위치를 지정하는 작업을 하나도 하지 않아도 된다는 것이다.

 

즉,

 

val tooltipState = rememberPlainTooltipState()
val scope = rememberCoroutineScope()
PlainTooltipBox(
    shape = RoundedCornerShape(8.dp),
    containerColor = Color.Black,
    tooltip = {
        Text(
            text = "TooltipBox Text Sample",
            textAlign = TextAlign.Start,
            color = Color.White,
        )
    },
    tooltipState = tooltipState
) {
    GlideImage(
        modifier = Modifier
            .size(36.dp)
            .noRippleSingleClickable {
                scope.launch {
                    tooltipState.show()
                }
            },
        model = Icons.Filled.Info,
        contentDescription = null,
        contentScale = ContentScale.Fit
    )
}

 

이런 식으로 구현만 해도 쉽게 Tooltip을 구현할 수 있게 된다.

물론, tooltip에 들어갈 Compose 함수 자체는 Text 만 들어가는 것이 아니라 원하는 대로 커스텀 되어서 들어가게 된다.

 

여기서 아직 설명을 하지 않았던 부분은 tootlipState이다.

 

앞서 언급했다시피, tooltipState는 tooltip의 visible 옵션을 컨트롤하는 상태 값이라고 할 수 있다.

그리고 Recompose 됐을 때도 해당 상태 값을 기억해야 하기 때문에 당연히 remember로 선언을 해주어야 하고, Material3에서 제공해 주는 rememberPlainTooltipState를 통해 쉽게 사용이 가능하다.

 

tooltipState는

 

/**
 * The state that is associated with an instance of a tooltip.
 * Each instance of tooltips should have its own [TooltipState].
 */
@Stable
@ExperimentalMaterial3Api
interface TooltipState {
    /**
     * [Boolean] that will be used to update the visibility
     * state of the associated tooltip.
     */
    val isVisible: Boolean

    /**
     * Show the tooltip associated with the current [TooltipState].
     * When this method is called all of the other tooltips currently
     * being shown will dismiss.
     */
    suspend fun show()

    /**
     * Dismiss the tooltip associated with
     * this [TooltipState] if it's currently being shown.
     */
    fun dismiss()

    /**
     * Clean up when the this state leaves Composition.
     */
    fun onDispose()
}

 

이처럼 단순하게 show, dismiss, onDispose 함수를 제공하고,

isVisible를 사용하여 현재 보이고 있는지를 판단한다.

 

여기서 show()는 suspend 함수이기 때문에 CoroutineScope 내부에서 사용이 필요하므로, rememberCoroutineScope를 선언하여 해당 scope 내부에서 사용하면 된다.


다음으로는,

RichTooltipBox를 확인해 보자.

 

샘플 UI를 먼저 보고 생각하자.

 

 

동일하게 PlainTooltipBox로도 구현이 가능하지만, RichTooltipBox의 경우 title, description, text button의 조합을 가지고 있는 케이스에서 쉽게 사용할 수 있도록 구현해 둔 것이다.

Tooltip을 사용하는 대표적인 2가지 UI를 구현해 둔 느낌이다.

 

val tooltipState2 = rememberRichTooltipState(isPersistent = true)
val scope = rememberCoroutineScope()

RichTooltipBox(
    title = {
        Row {
            Text(text = "RichTooltipBox Title")
        }
    },
    text = {
        Column {
            Text(text = "Supporting line text lorem ipsum dolor sit amet, consectetur")
        }
    },
    action = {
        TextButton(
            onClick = {
                scope.launch { tooltipState2.dismiss() } // dismiss the tooltip
            }
        ) {
            Text(text = "Learn more")
        }
    },
    tooltipState = tooltipState2
) {
    GlideImage(
        modifier = Modifier
            .size(36.dp)
            .noRippleSingleClickable {
                scope.launch {
                    tooltipState2.show()
                }
            },
        model = Icons.Filled.Info,
        contentDescription = null,
        contentScale = ContentScale.Fit
    )
}

 

PlainTooltipBox에서 다른 부분만 사용하여 간단하게 구현한 RichTooltipBox이다.

 

title과 text, action이 추가된 것을 볼 수 있는데, 위의 샘플과 함께 보면 어떤 것을 의미하는지 쉽게 알 수 있을 것이다.

 

  1. title :  말 그대로 tooltip의 title. 최 상단에 위치한다.
  2. text : Tooltip의 description. 중앙에 위치한다.
  3. action : tooltip에서 가장 하단 button 영역에 위치하는 아이템.

 

여기서 가장 처음 생각하는 것으로, 위 3개의 아이템들 중 Compose 함수를 넣지 않으면 영역이 완전히 안 보이는가?라는 생각을 할 것이다.

그렇게 된다면 정말 쉽게 다양한 UI를 대응할 수 있기 때문이다.

 

 

그에 따라, title과 action을 제거하고 호출을 해보았는데, 아쉽게도 해당 영역은 반드시 잡혀있는 것으로 보인다.

따라서, 뭔가 tooltip을 위의 UI 디자인이 아닌 다른 디자인으로 커스텀해서 쓰고 싶다면 RichTooltipBox를 사용하기보다는 PlainTooltipBox를 사용하는 게 좋아 보인다.

 

UI가 고정된다는 것을 제외하고 PlainTooltip과 RichTooltip은 동일해 보이는데, 한 가지 더 다른 부분이 있다.

그것은 tooltipState 부분인데 선언 부분을 보면 다음과 같다.

 

val tooltipState = rememberCustomPlainTooltipState()
val tooltipState2 = rememberRichTooltipState(isPersistent = true)

 

Plain과 Rich가 각각 명시되어 있음을 알 수 있고, Rich의 경우 추가적인 파라미터가 들어간다.

해당 값은 Tooltip을 보여주는 시간을 제한하고 있는데, isPersistent가 true면 시간제한이 없으며, false면 동일하게 1.5초 동안 보여주고 사라지게 된다.

 

이것을 제외하고는 디자인 규격이 각각 다르게 지정되어 있다는 것을 제외하고 동일한 것으로 보인다.


바로 위에서 언급한 Tooltip 시간제한을 컨트롤할 수 있는 변수는 TooltipState에 들어가 있는데, 해당 함수를 확인하면 다음과 같다.

 

@Composable
@ExperimentalMaterial3Api
fun rememberRichTooltipState(
    isPersistent: Boolean,
    mutatorMutex: MutatorMutex = TooltipDefaults.GlobalMutatorMutex
): RichTooltipState =
    remember { RichTooltipStateImpl(isPersistent, mutatorMutex) }

 

@OptIn(ExperimentalMaterial3Api::class)
@Stable
internal class RichTooltipStateImpl(
    override val isPersistent: Boolean,
    private val mutatorMutex: MutatorMutex
) : RichTooltipState {

    ...
    override suspend fun show() {
        val cancellableShow: suspend () -> Unit = {
            suspendCancellableCoroutine { continuation ->
                isVisible = true
                job = continuation
            }
        }

        mutatorMutex.mutate(MutatePriority.Default) {
            try {
                if (isPersistent) {
                    cancellableShow()
                } else {
                    withTimeout(androidx.compose.material3.TooltipDuration) {
                        cancellableShow()
                    }
                }
            } finally {
                isVisible = false
            }
        }
    }
    ...
}

 

여기서 보면, isPersistent 값에 따라서 Timeout이 있고 없고를 확인할 수 있는데,

Rich가 아닌 Plain으로 확인해 보면 

 

override suspend fun show() {
    mutatorMutex.mutate {
        try {
            withTimeout(TooltipDuration) {
                suspendCancellableCoroutine { continuation ->
                    isVisible = true
                    job = continuation
                }
            }
        } finally {
            isVisible = false
        }
    }
}

 

이와 같이 되어있다.

 

즉, Plain은 기본으로 사용하면 1.5초의 시간 후에 사라지게 되는 것이고, Rich는 조건에 따라 1.5초 혹은 제한 없음으로 사용할 수 있는 것이다.

하지만, 사용하는 용도에 따라서 Tooltip의 visible 시간을 변경하고 싶을 수 있는데 해당 함수는 internal로 선언되어 있기 때문에 코드를 가져와서 수정하게 되면 원하는 대로 시간을 커스텀할 수 있다.

 

예로 들어 5초의 시간 동안 Tooltip을 보여주고 싶다면 다음과 같이 수정하면 된다.

 

val tooltipState = rememberCustomPlainTooltipState(visibleDuration = 5000L)

 

Override 키워드를 선언하여 사용하면 좋겠지만, 그게 되지 않기 때문에 함수를 Override 하는 것과 동일하게 작업을 한다.

위와 같이 선언한 후, 기존에 선언된 것들을 그대로 가져와 사용한다.

fun rememberPlainTooltipState(
    visibleDuration: Long = 1500L,
    mutatorMutex: MutatorMutex = TooltipDefaults.GlobalMutatorMutex,
): PlainTooltipState =
    remember {
        PlainTooltipStateImpl(
            visibleDuration = visibleDuration,
            mutatorMutex = mutatorMutex
        )
    }

 

internal class PlainTooltipStateImpl(
    private val visibleDuration: Long,
    private val mutatorMutex: MutatorMutex,
) : PlainTooltipState {

    ...
    override suspend fun show() {
        mutatorMutex.mutate {
            try {
                withTimeout(visibleDuration) {
                    suspendCancellableCoroutine { continuation ->
                        isVisible = true
                        job = continuation
                    }
                }
            } finally {
                isVisible = false
            }
        }
    }

    ...
}

 

이렇게 수정을 함으로써 편하게 보이는 시간을 커스텀할 수 있게 된다.


Material3의 Tooltip을 사용하는 경우, 생각보다 커스텀이 자유롭고 아주 간단하고 큰 작업 없이 Tooltip을 사용할 수 있다는 것이 큰 장점이라고 생각된다.

 

실제로 Tooltip을 구현하는 코드를 짜고, 해당 코드를 전부 이해해서 커스텀한다면 훨씬 더 높은 자유도를 가지고 있는 툴팁 디자인을 구현할 수 있겠지만, 제한된 시간 내에 원하는 UI를 그리기 위해서는 아무래도 제공해 주는 것을 사용하는 게 편하다 보니 사용했던 것 같다.

 

하지만, offset을 계산하고 하는 작업이 없다 보니, 말풍선처럼 anchor를 주기 위해서는 해당 컴포넌트를 사용해도 툴팁이 나오는 위치를 계산하고 그려주는 작업은 별도로 필요하다는 점이 좀 아쉽다고 생각이 들었고, 추후에 tooltip이 조금 더 업데이트된다면 해당 기능도 지원해주지 않을까?라는 생각을 한다.

 

또한,

처음 해당 UI를 처음 구현할 때 당연히 이런 것들은 직접 구현하는 것이라고 생각했는데, 의외로 Material3에서 제공하는 컴포넌트가 있어서 놀랐다.

생각보다 쓸만한 컴포넌트들이 많다고 보여서, 시간이 될 때 Material3 공식 문서를 보면서 쓸만 하거나 자주 사용될 것 같은 컴포넌트가 있으면 학습을 좀 해야겠다.

 

728x90