[Android] Jetpack Compose 에서 Snackbar Duration 을 Custom 하는 방법

이지훈·2024년 4월 18일
2
post-custom-banner

TL;DR

코루틴의 Job 을 통해 Snackbar 의 Duration 을 Custom 할 수 있다.

서두

일반적으로 Snackbar 의 Duration 설정은 3가지의 enum 값으로 밖에 설정할 수 없다.

이번 글에선 Snackbar 를 내가 원하는 시간 만큼만 화면에 노출하고, 시간이 지나면 화면에서 사라지게 하는 방법을 알아보고자 한다.

Snackbar 의 장단점

내가 여태 Compose 에서 Snackbar 를 사용하면서 느꼈던 장단점은 다음과 같았다.

장점

커스텀이 용이하다!

Snackbar 에 대해 얘기하려고 하면, Toast 를 언급하지 않을 수 없는데,
https://developer.android.com/about/versions/11/behavior-changes-11#text-toast-api-changes

어떤 의도에선지 모르겠지만 Android 11 이상 부터는 Toast 의 노출 위치를 변경시키는 것을 구글측에서 막았다. Toast 는 정해진 위치에서만 띄우도록 하는게 구글의 의도인 것 같기도 하다.

따라서 Toast 의 사용할 때, Toast의 위치를 옮기거나, 내부 색상등을 변경하기 위해선, 별도의 라이브러리를 사용하는 방법만 남게 되었다.

Toast 관련 라이브러리는 https://github.com/GrenderG/Toasty 외 다수 존재한다.

하지만

Snackbar 의 경우, Toast 와 다르게, Custom 에 용이하다. 특히 Compose 에서는 modifier 를 통해, 노출되는 위치를 자유롭게 커스텀 가능하며, 내부에 색상 변경, undo 버튼 등을 추가하기 매우 간단해졌다.

val height = LocalConfiguration.current.screenHeightDp

// 위치 및 내부 컴포넌트 커스텀 예시 
Scaffold(
    snackbarHost = {
        SnackbarHost(
            modifier = Modifier
                .padding(bottom = (height - 96).dp)
                .height(36.dp),
            hostState = snackbarHostState,
            snackbar = {
                Card(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(horizontal = 40.dp),
                    shape = RoundedCornerShape(50.dp),
                    colors = CardDefaults.cardColors(containerColor = White),
                    elevation = CardDefaults.cardElevation(8.dp),
                ) {
                    Box(Modifier.fillMaxSize()) {
                        Text(
                            text = it.visuals.message,
                            color = Gray700,
                            fontSize = 12.sp,
                            fontWeight = FontWeight.W600,
                            modifier = Modifier.align(Alignment.Center),
                            letterSpacing = -(0.24).sp,
                        )
                    }
                }
            },
        )
    },
) {}

// undo 사용 예시
val context = LocalContext.current
val scaffoldState = rememberBottomSheetScaffoldState()

LaunchedEffect(key1 = data.deleteWordComplete) {
     if (data.deleteWordComplete) {
         data.deletedWord?.let {
             val snackbarResult = scaffoldState.snackbarHostState
                 .showSnackbar(
                     message = context.getString(
                            R.string.delete_word_complete,
                            it.eng
                     ),
                     actionLabel = context.getString(R.string.undo),
                     duration = SnackbarDuration.Short,
                 )
             when (snackbarResult) {
                 SnackbarResult.ActionPerformed -> {
                     onWordRestore(data.deletedWord)
                 }

                 SnackbarResult.Dismissed -> Unit
             }
         }
     }
     onWordDeleteCompleteUpdate(false)
 }

따라서, 사용자에게 메세지를 전달하는 경우에
1) 기본 디자인이 아닌, 디자이너가 만든 새로운 디자인인 경우
2) 하단 중앙이 아닌, 다른 위치에서 메세지를 노출시켜야 하는 경우

Toast 가 아닌, Snackbar 를 선택하는 것이 하나의 해결책이 될 수 있다.

그 외에 Toast 와 Snackbar 의 차이에 대해선 아래의 글을 참고해보면 좋을 것 같다.
Android Snackbar vs Toast - Usage and Difference

단점

노출되는 기본 시간이 너무 길다...!

앞서 언급 했던 것 처럼 Snackbar 의 Duration은 다음과 같이 3개 이며, 각각의 실제 지속 시간은 내부 코드를 확인해보면 알 수 있다.

/**
 * Possible durations of the [Snackbar] in [SnackbarHost]
 */
enum class SnackbarDuration {
    /**
     * Show the Snackbar for a short period of time
     */
    Short,

    /**
     * Show the Snackbar for a long period of time
     */
    Long,

    /**
     * Show the Snackbar indefinitely until explicitly dismissed or action is clicked
     */
    Indefinite
}

// TODO: magic numbers adjustment
internal fun SnackbarDuration.toMillis(
    hasAction: Boolean,
    accessibilityManager: AccessibilityManager?
): Long {
    val original = when (this) {
        SnackbarDuration.Indefinite -> Long.MAX_VALUE
        SnackbarDuration.Long -> 10000L
        SnackbarDuration.Short -> 4000L
    }
    if (accessibilityManager == null) {
        return original
    }
    return accessibilityManager.calculateRecommendedTimeoutMillis(
        original,
        containsIcons = true,
        containsText = true,
        containsControls = hasAction
    )
}

Short 가 무려... 4초다

https://stackoverflow.com/questions/2220560/can-an-android-toast-be-longer-than-toast-length-long/5079536#5079536

Toast 의 경우의 duration time 은 위의 글에서 확인할 수 있었는데,

private static final int LONG_DELAY = 3500; // 3.5 seconds
private static final int SHORT_DELAY = 2000; // 2 seconds

Long 이 3.5초이다... Snackbar 의 Short 는 Toast의 Long 보다 길다!
내 입장에는 이 시간이 너무나도 길게 느껴졌다.

문제 해결

Snackbar 의 Duration 을 짧게 만들기 위해 내가 선택한 방법은 코루틴 의 Job을 이용한 방법이다.

Snackbar 를 화면에 노출시키려면 코루틴을 사용해야 하는데,
그 이유는

    suspend fun showSnackbar(
        message: String,
        actionLabel: String? = null,
        withDismissAction: Boolean = false,
        duration: SnackbarDuration =
            if (actionLabel == null) SnackbarDuration.Short else SnackbarDuration.Indefinite
    ): SnackbarResult =
        showSnackbar(SnackbarVisualsImpl(message, actionLabel, withDismissAction, duration))

showSnackbar 함수가 suspend 함수이기 때문이다.
따라서 rememberCoroutineScope 를 통해 Compose 생명주기에 바인딩된 코루틴 스코프를 얻어, 그 내부에서 showSnackbar 함수를 호출해야 한다.

이때 그 코루틴 스코프에서 launch 함수를 통해 새로운 코루틴을 시작하는 것이다.
코루틴에서 launch 는 async 와 다르게 코루틴 스코프 내에서 실행한 비동기 작업에 대한 반환하지 않는다. 다만, 그 실행을 제어할 수 있는 Job 객체를 반환한다.
Job 객체가 제공하는 join(), cancel() 등을 통해 개발자는 비동기 작업의 실행을 컨트룰 할 수 있다.

정확히는 launch 와 async 모두 Job 객체를 새롭게 생성한다. async가 생성하는 Deffered 객체도 await() 기능이 부여된 특수한 형태의 Job 이라고 할 수 있다.

private const val SnackbarDuration = 1000L
...

// Composable 함수 내
val scope = rememberCoroutineScope()

scope.launch {
	val job = launch {
    	onShowSnackbar(message)
    }
    delay(SnackbarDuration)
    job.cancel()
}

다음과 같이 코드를 작성할 경우 Snackbar 가 호출되는 동안, delay 함수가 호출된다. delay 함수가 지정된 시간동안 호출된 뒤에 종료되면, 스낵바를 호출하는 작업을 수행하는 Job 을 취소한다.

그렇게 될 경우, Snackbar 는 원래 지정되었던 Duration 에 상관없이 작업이 취소되어 화면 내에서 사라지게 된다.

정확하게 말하자면, Duration 자체를 Custom 하는 것이 아니라, 지정된 Duration 과 상관 없이 작업을 취소해버리는 것이기 때문에, 사실 꼼수라고 생각하는데, 다른 방법은 아직 발견하지 못했다.

내 바람은 사실, 개발자가 시간을 직접 지정할 수 있도록 Snackbar 내에 parameter 를 추가 해줬으면 하는 바람이다. 그러면 모두가 행복해질 것 같다는 생각이 든다.
그러게 왜 4초, 10초로 설정해놔가지고

더 좋은 방법이 있다면 덧글로 알려주시면 감사하겠습니다!

profile
실력은 고통의 총합이다. Android Developer
post-custom-banner

1개의 댓글

comment-user-thumbnail
2024년 9월 27일

좋은 포스팅 잘 읽었습니다!

답글 달기