Master Kotlin’s select expression for precise Snackbar timing beyond Short and Long durations

Introduction
Did you ever want your Snackbar to display for exactly 6.5 seconds instead of the standard “Short” or “Long” durations? Material3’s Snackbar component restricts you to three fixed timing options, but there’s an elegant workaround using Kotlin’s powerful select coroutine function.
The Problem with Fixed Durations
Material3 limits Snackbar timing to three predefined values:
- Short — around 4 seconds
- Long — around 10 seconds
- Indefinite — persists until manual dismissal
This becomes problematic when your app requires specific timing, such as “File will be deleted in 6.5 seconds” — it’s not possible to achieve this with the standard API.
The Solution: Racing User Actions Against Timers
Custom timing is essentially a race between two events:
- User response — clicks action, swipes away, or taps outside
- Timer expires — your custom duration completes
Kotlin’s select coroutine construct manages exactly this competitive scenario.
Implementation: The Duration Wrapper
First, we need a wrapper that handles both standard and custom durations:
| sealed class SnackbarDurationWrapper { | |
| data class Standard(val duration: SnackbarDuration) : SnackbarDurationWrapper() | |
| data class Custom(val millis: Long) : SnackbarDurationWrapper() | |
| companion object { | |
| fun fromMillis(milliseconds: Long): SnackbarDurationWrapper { | |
| require(milliseconds > 0) { "Duration must be positive" } | |
| return Custom(milliseconds) | |
| } | |
| fun fromSeconds(seconds: Double): SnackbarDurationWrapper { | |
| return Custom((seconds * 1000).toLong()) | |
| } | |
| } | |
| fun getMilliseconds(): Long { | |
| return when (this) { | |
| is Standard -> when (duration) { | |
| SnackbarDuration.Short -> 4000L | |
| SnackbarDuration.Long -> 10000L | |
| SnackbarDuration.Indefinite -> Long.MAX_VALUE | |
| } | |
| is Custom -> millis | |
| } | |
| } | |
| } |
The Magic: Koltin’s select expression-based racing
Here’s how we use select to handle the race between user interaction and timer expiration:
| LaunchedEffect(currentMessage.value) { | |
| currentMessage.value?.let { message -> | |
| val result: SnackbarResult | |
| if (message.duration is SnackbarDurationWrapper.Custom) { | |
| // Create two competing operations | |
| val snackbarDeferred = async { | |
| snackbarHostState.showSnackbar( | |
| message = message.text, | |
| actionLabel = message.actionLabel, | |
| duration = SnackbarDuration.Indefinite // Never auto-dismiss | |
| ) | |
| } | |
| val timeoutDeferred = async { | |
| delay(message.duration.getMilliseconds()) | |
| SnackbarResult.Dismissed // Timer won | |
| } | |
| // Race them - winner takes all | |
| result = select { | |
| snackbarDeferred.onAwait { userResult -> | |
| timeoutDeferred.cancel() // Cancel timer | |
| userResult | |
| } | |
| timeoutDeferred.onAwait { timerResult -> | |
| snackbarHostState.currentSnackbarData?.dismiss() | |
| snackbarDeferred.cancel() // Cancel snackbar | |
| timerResult | |
| } | |
| } | |
| } else { | |
| // Use standard Material3 behavior | |
| result = snackbarHostState.showSnackbar( | |
| message = message.text, | |
| actionLabel = message.actionLabel, | |
| duration = message.duration.getStandardDuration() | |
| ) | |
| } | |
| // Handle the result | |
| when (result) { | |
| SnackbarResult.ActionPerformed -> message.onAction?.invoke() | |
| SnackbarResult.Dismissed -> { /* Handle dismissal */ } | |
| } | |
| } | |
| } |
Why This Works
The select-based approach provides several key advantages:
Perfect Race Management
select automatically handles the competition between user interaction and timer expiration, providing deterministic behavior regardless of which event completes first.
Zero Memory Leaks
When one operation completes, select automatically cancels the other, preventing abandoned coroutines from consuming resources.
Millisecond Precision
Unlike Material3’s approximate timing, this delivers exact duration control down to the millisecond.
Seamless Integration
Works within Material3’s existing architecture — no changes to Compose’s snackbar system required.
Practical Usage Examples
| // Undo action with precise 5-second window | |
| showCustomSnackbar( | |
| message = "Item deleted", | |
| actionLabel = "Undo", | |
| onAction = { restoreItem() }, | |
| durationMillis = 5000 | |
| ) | |
| // Quick success confirmation | |
| showCustomSnackbar( | |
| message = "File saved successfully", | |
| durationMillis = 2000 | |
| ) | |
| // Critical error with extended display time | |
| showCustomSnackbar( | |
| message = "Payment failed - contact support", | |
| durationMillis = 12000 | |
| ) |
Job Offers
Beyond Snackbars
The select pattern applies to any UI scenario involving race conditions between user actions and timers — tooltips, dialogs, loading states, or any component where precise timing matters.
Conclusion
By leveraging Kotlin’s select function, you can break free from Material3’s timing constraints while maintaining clean, readable code. This approach provides the precision your app needs while preserving the Material3 user experience.
This article was previously published on proandroiddev.com.



