Computer >> 컴퓨터 >  >> 시스템 >> Android

Jetpack Compose에서 툴팁(Tooltip) 활용하기: 단계별 완벽 가이드

Jetpack Compose에서 툴팁(Tooltip) 활용하기: 단계별 완벽 가이드

Jetpack Compose, 드디어 툴팁을 기본 지원합니다

지난 Jetpack Compose 관련 글에서 필자는 Jetpack Compose에 기본적이라고 볼 수 있는 컴포넌트가 일부 빠져 있으며, 그중 하나가 바로 툴팁(tooltip)이라고 언급한 바 있습니다.

당시에는 툴팁을 표시할 수 있는 내장 컴포저블이 없었고, 온라인에는 여러 대체 솔루션이 떠돌고 있었습니다. 그러나 이런 솔루션들은 Jetpack Compose의 새 버전이 출시될 때마다 동작이 깨질 수 있다는 치명적인 단점이 있었습니다. 결국 커뮤니티는 언젠가 정식 툴팁 지원이 추가되기를 기대할 수밖에 없었죠.

기쁘게도 Compose Material 3 버전 1.1.0부터는 툴팁이 기본으로 지원됩니다. 👏

하지만 해당 버전이 출시된 지 1년 넘게 지났고, 그 사이 후속 버전이 발표되면서 툴팁 관련 API도 크게 변경되었습니다. 변경 로그(changelog)를 살펴보면 공개 API와 내부 API가 얼마나 많이 바뀌었는지 확인할 수 있습니다. 게다가 툴팁 관련 기능 전반이 아직 ExperimentalMaterial3Api::class 어노테이션이 붙은 실험적(experimental) 상태이므로, 이 글을 읽는 시점에는 세부 사항이 더 변경되었을 수 있다는 점을 유의하세요.

❗️ 이 글에서 사용한 Material 3 버전은 2024년 3월 6일에 출시된 1.2.1입니다.

현재 Compose는 두 가지 유형의 툴팁을 지원합니다.

  1. Plain tooltip(일반 툴팁)

  2. Rich media tooltip(리치 미디어 툴팁)

Plain Tooltip(일반 툴팁)

일반 툴팁은 아이콘 버튼의 용도가 한눈에 파악되지 않을 때 정보를 제공하는 데 사용합니다. 예를 들어, 아이콘 버튼이 무엇을 의미하는지 사용자에게 알려주는 용도로 활용할 수 있습니다.

Jetpack Compose에서 툴팁(Tooltip) 활용하기: 단계별 완벽 가이드

앱에 툴팁을 추가하려면 TooltipBox 컴포저블을 사용합니다. 이 컴포저블은 다음과 같은 여러 인자를 받습니다.

fun TooltipBox(
 positionProvider: PopupPositionProvider,
 tooltip: @Composable TooltipScope.() -> Unit,
 state: TooltipState,
 modifier: Modifier = Modifier,
 focusable: Boolean = true,
 enableUserInput: Boolean = true,
 content: @Composable () -> Unit,
)

컴포저블을 다뤄본 경험이 있다면 일부 인자는 익숙할 것입니다. 여기서는 특별한 용도를 가진 인자들을 중심으로 살펴보겠습니다.

  • positionProvider - PopupPositionProvider 타입으로, 툴팁의 위치를 계산하는 데 사용됩니다.

  • tooltip - 툴팁의 UI를 직접 디자인하는 부분입니다.

  • state - 특정 Tooltip 인스턴스와 연결된 상태를 담고 있습니다. 툴팁을 표시하거나 숨기는 메서드를 제공하며, 인스턴스 생성 시 툴팁을 persistent(지속) 모드로 설정할지 선언할 수 있습니다. persistent 모드란 툴팁 밖의 화면을 클릭하기 전까지 툴팁이 계속 표시되는 것을 의미합니다.

  • content - 툴팁이 위 또는 아래에 표시될 대상 UI입니다.

다음은 모든 필요한 인자를 채워 BasicTooltipBox를 구현한 예제입니다.

@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun BasicTooltip() {
 val tooltipPosition = TooltipDefaults.rememberPlainTooltipPositionProvider()
 val tooltipState = rememberBasicTooltipState(isPersistent = false)
 BasicTooltipBox(positionProvider = tooltipPosition,
 tooltip = { Text("Hello World") } ,
 state = tooltipState) {
 IconButton(onClick = { }) {
 Icon(imageVector = Icons.Filled.Favorite, 
 contentDescription = "Your icon's description")
 }
 }
}

Jetpack Compose에서 툴팁(Tooltip) 활용하기: 단계별 완벽 가이드

Jetpack Compose에는 TooltipDefaults라는 내장 클래스가 있습니다. 이 클래스를 활용하면 TooltipBox를 구성하는 데 필요한 인자들을 손쉽게 만들 수 있습니다. 예를 들어 TooltipDefaults.rememberPlainTooltipPositionProvider를 사용하면 앵커(anchor) 요소를 기준으로 툴팁을 올바른 위치에 배치할 수 있습니다.

Rich Tooltip(리치 미디어 툴팁)

리치 미디어 툴팁은 일반 툴팁보다 더 넓은 공간을 차지하며, 아이콘 버튼의 기능에 대한 더 풍부한 맥락을 제공할 때 유용합니다. 툴팁이 표시될 때 버튼이나 링크를 함께 넣어 추가 설명이나 정의를 제공할 수도 있습니다.

구현 방식은 일반 툴팁과 유사하게 TooltipBox 안에서 인스턴스화하지만, 이번에는 RichTooltip 컴포저블을 사용합니다.

TooltipBox(positionProvider = tooltipPosition,
 tooltip = {
 RichTooltip(
 title = { Text("RichTooltip") },
 caretSize = caretSize,
 action = {
 TextButton(onClick = {
 scope.launch {
 tooltipState.dismiss()
 tooltipState.onDispose()
 }
 }) {
 Text("Dismiss")
 }
 }
 ) {
 Text("This is where a description would go.")
 }
 },
 state = tooltipState) {
 IconButton(onClick = {
 /* Icon button's click event */
 }) {
 Icon(imageVector = tooltipIcon,
 contentDescription = "Your icon's description",
 tint = iconColor)
 }
 }

리치 툴팁에서 주목할 만한 특징은 다음과 같습니다.

  1. 캐럿(caret, 꼬리표)을 지원합니다.

  2. 사용자가 더 많은 정보를 찾을 수 있도록 툴팁 안에 액션(버튼)을 추가할 수 있습니다.

  3. 툴팁을 닫는(dismiss) 로직을 직접 구현할 수 있습니다.

Jetpack Compose에서 툴팁(Tooltip) 활용하기: 단계별 완벽 가이드

Jetpack Compose에서 툴팁(Tooltip) 활용하기: 단계별 완벽 가이드

엣지 케이스(Edge Cases): 툴팁 상태가 초기화되지 않는 문제

툴팁 상태를 persistent로 설정하면, 사용자가 툴팁을 띄우는 UI와 상호작용한 이후 화면의 다른 곳을 누를 때까지 툴팁이 계속 표시됩니다.

앞선 리치 툴팁 예제를 잘 살펴보셨다면, 클릭 시 툴팁을 닫는 버튼을 추가했다는 점을 눈치채셨을 겁니다.

그런데 사용자가 이 버튼을 누르면 문제가 발생합니다. dismiss 동작이 툴팁 쪽에서 수행되기 때문에, 이후 사용자가 툴팁을 호출하는 UI 요소를 다시 길게 눌러도 툴팁이 나타나지 않습니다. 즉, 툴팁 상태가 '닫힘(dismissed)' 상태로 고정되어 버리는 것입니다. 그렇다면 어떻게 해결해야 할까요?

Jetpack Compose에서 툴팁(Tooltip) 활용하기: 단계별 완벽 가이드

툴팁 상태를 '재설정'하려면, 툴팁 상태 객체가 노출하는 onDispose 메서드를 호출해야 합니다. 이렇게 하면 툴팁 상태가 초기화되어, 사용자가 UI 요소를 다시 길게 눌렀을 때 툴팁이 정상적으로 표시됩니다.

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RichTooltip() {
 val tooltipPosition = TooltipDefaults.rememberRichTooltipPositionProvider()
 val tooltipState = rememberTooltipState(isPersistent = true)
 val scope = rememberCoroutineScope()
 TooltipBox(positionProvider = tooltipPosition,
 tooltip = {
 RichTooltip(
 title = { Text("RichTooltip") },
 caretSize = TooltipDefaults.caretSize,
 action = {
 TextButton(onClick = {
 scope.launch {
 tooltipState.dismiss()
 tooltipState.onDispose() /// <---- HERE
 }
 }) {
 Text("Dismiss")
 }
 }
 ) {
 }
 },
 state = tooltipState) {
 IconButton(onClick = { }) {
 Icon(imageVector = Icons.Filled.Call, contentDescription = "Your icon's description")
 }
 }
}

Jetpack Compose에서 툴팁(Tooltip) 활용하기: 단계별 완벽 가이드

툴팁 상태가 초기화되지 않는 또 다른 시나리오도 있습니다. 사용자의 액션에 따라 개발자가 직접 dismiss 메서드를 호출하는 것이 아니라, 사용자가 툴팁 바깥 영역을 클릭해 툴팁이 닫히는 경우입니다. 이때는 내부적으로 dismiss 메서드가 호출되어 툴팁 상태가 dismissed로 설정되지만, 우리가 작성한 onDispose 호출 로직은 실행되지 않습니다. 결과적으로 UI 요소를 다시 길게 눌러도 아무 반응이 없습니다.

Jetpack Compose에서 툴팁(Tooltip) 활용하기: 단계별 완벽 가이드

이런 경우 툴팁 상태를 어떻게 초기화할 수 있을까요?

필자도 현재까지 명확한 해결책을 찾지 못했습니다. 툴팁 내부의 MutatorMutex와 관련이 있을 가능성이 있고, 향후 릴리스에서 이를 위한 별도 API가 제공될 수도 있습니다. 다만 실험해 보니 화면에 다른 툴팁이 존재하고 그 툴팁을 클릭하면, 이전에 클릭했던 툴팁의 상태가 초기화되는 현상은 확인할 수 있었습니다.

Jetpack Compose에서 툴팁(Tooltip) 활용하기: 단계별 완벽 가이드

이 글에서 소개한 코드 전체는 GitHub 저장소에서 확인할 수 있으며, 실제 앱에서 툴팁이 동작하는 모습도 함께 살펴볼 수 있습니다.

참고 자료

  • Material3 Tooltip Overview

  • Tooltip Defaults

  • Tooltip Source Code

무료로 코딩을 배워보세요. freeCodeCamp의 오픈소스 커리큘럼은 이미 4만 명 이상이 개발자로 취업하는 데 도움을 주었습니다. 지금 바로 시작해 보세요!