코루틴의 Job 을 통해 Snackbar 의 Duration 을 Custom 할 수 있다.
일반적으로 Snackbar 의 Duration 설정은 3가지의 enum 값으로 밖에 설정할 수 없다.
이번 글에선 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초다
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초로 설정해놔가지고
더 좋은 방법이 있다면 덧글로 알려주시면 감사하겠습니다!
좋은 포스팅 잘 읽었습니다!