UI Utils¶
The starter/ui/utils module is the Compose utility layer — ViewModel base classes, side-effect helpers, state-flow extensions, popups, theme dimensions, image converters, and cross-platform Compose services.
components and layouts both build on top of it, so you get all of this for free when you use them.
Add the dependency in your commonMain source set:
ViewModels¶
MviViewModel¶
The MVI base class for every screen. It enforces a clean unidirectional flow: State, Actions, and Events.
class HomeViewModel : MviViewModel<HomeState, HomeAction, HomeEvent>() {
override val initialState get() = HomeState()
override fun onStateStart() {
loadData()
}
override fun onAction(action: HomeAction) {
when (action) {
is HomeAction.EnteringName ->
_state.update { it.copy(name = action.name) }
HomeAction.SaveButtonClicked ->
emitEventAsync(HomeEvent.ShowSnackbar("Saved!"))
}
}
}
Key points:
state— observableStateFlow, collected by the UI.uiEvents—SharedFlowfor one-time side effects (navigation, snackbars).emitEvent(event)—suspend, use inside a coroutine.emitEventAsync(event)— launches onviewModelScopefor you.stateTimeoutMillis— how long state stays alive after the screen hides (default 5000ms).
See Mvi View Model for the full guide.
Flow / Channel helpers¶
Launch collection and emission directly on the viewModelScope using context receivers:
context(viewModel: ViewModel)
fun <T> Flow<T>.collectInViewModel(collector: FlowCollector<T>)
context(viewModel: ViewModel)
fun <T> Channel<T>.sendInViewModel(value: T)
context(viewModel: ViewModel)
fun <T> MutableSharedFlow<T>.emitInViewModel(value: T)
Side Effects¶
ObserveAsEvents¶
A lifecycle-aware event collector — better than LaunchedEffect for one-time events, because it pauses when the app is in the background and resumes when it comes back.
ObserveAsEvents(flow = viewModel.uiEvents) { event ->
when (event) {
is HomeEvent.ShowSnackbar -> { /* show it */ }
}
}
LaunchOnce¶
Like LaunchedEffect, but the block runs only once, surviving recompositions and configuration changes.
LaunchOnStart¶
Runs a suspend block every time the composable reaches STARTED lifecycle.
StateFlow Helpers¶
reset¶
Reset a MutableStateFlow from a factory:
SavedStateHandle delegates¶
Persist MutableStateFlow values straight into SavedStateHandle:
class FormViewModel(
private val savedStateHandle: SavedStateHandle,
) : MviViewModel<FormState, FormAction, FormEvent>() {
context(this)
private val name by savedStateHandle.getMutableStateFlow("", key = "name")
// or getMutableStateFlow2(...) for transient-property control
}
getMutableStateFlow2 adds a transientProperties set — those keys are kept fresh from defaults on restore and not persisted.
Composition Locals¶
LocalThemeMode¶
Read the current theme mode (LIGHT / DARK / SYSTEM) anywhere:
Theme¶
Dimens¶
A central object of spacing, elevation, and size tokens. Use it instead of hardcoding dp values.
val size = Dimens.paddingMedium // 16.dp
val h = Dimens.buttonHeight // 50.dp
val r = Dimens.cardRadius // 16.dp
ThemeMode¶
ThemeMode.getIcon()— returns aLightMode/DarkMode/SettingsBrightnessicon.isAppInDarkTheme()— composable that reads the stored theme mode and resolvesSYSTEMagainst the OS.
Dynamic Color¶
getDynamicColorScheme(darkTheme) returns a Material You ColorScheme on supported platforms (or null).
Color¶
Color.fromHex¶
Parse a hex string into a Color. Supports RRGGBB, AARRGGBB, and RRGGBBAA.
val color = Color.fromHex("#FF6C63FF") // ARGB
val safe = Color.fromHex("#3B82F6", Color.Red) // with fallback
toHexString¶
complimentaryColor / brightness¶
ColorFilter.lightDarkTint¶
A composable that returns a tint matching the current theme (white on dark, black on light) — useful for image/icon tinting that needs to flip with the theme.
Deprecated helpers
The old parseHexColor* / parseToColor functions are deprecated — use Color.fromHex instead.
Keyboard¶
rememberKeyboardInfo¶
Observe the keyboard state cross-platform:
val keyboard = rememberKeyboardInfo()
val visible = keyboard.isVisible
val height = keyboard.height // dp
val state by keyboard.keyboardState.collectAsState() // KeyboardState(isVisible, height)
isKeyboardVisible¶
Quick one-liner for showing/hiding UI based on the IME:
FocusManager¶
Readable shortcuts for moving focus:
focusManager.moveDown()
focusManager.moveUp()
focusManager.moveNext()
focusManager.movePrevious()
focusManager.moveEnter()
focusManager.moveExit()
focusManager.moveLeft()
focusManager.moveRight()
Screen¶
getScreenSize / ScreenSizeValue¶
Get the screen width/height in Dp:
val size = getScreenSize() // ScreenSize(width, height)
val size2 = ScreenSizeValue // property-style
ScreenController¶
Control the device brightness:
val controller = rememberScreenController()
controller.setBrightness(0.5f)
val b = controller.getBrightness()
controller.resetBrightness()
Chrome Tabs¶
rememberChromeTabs() opens URLs in a custom tab (Android) or in-app browser (iOS):
val tabs = rememberChromeTabs()
tabs.open("https://starter.atherio.dev")
tabs.openElseDirect("https://starter.atherio.dev")
Popups¶
BaseDialog¶
A styled Dialog wrapper with rounded corners and sensible dismiss defaults. Build your dialogs on top of it.
DeleteDialog¶
A ready-made Material 3 delete confirmation dialog with an error-colored confirm button.
DeleteDialog(
title = "Delete item?",
message = "This action cannot be undone.",
confirmText = "Delete",
onConfirmDelete = { /* delete */ },
onDismiss = { showDialog = false },
)
DatePickerDialog¶
A Material 3 date picker dialog wired to kotlinx.datetime.LocalDate, with a "past or present" selectable-dates default.
DatePickerDialog(
selectedDate = initialDate,
onDateSelected = { date -> /* LocalDate */ },
onDismiss = { showPicker = false },
)
BaseBottomSheet¶
A ModalBottomSheet wrapper that animates its top corners when fully expanded, and hides itself "properly" before calling onDismiss.
It also ships SheetState.hideProperly(scope) { onHidden -> } so you can safely dismiss the sheet and only remove it from composition after it's hidden.
Modifiers¶
grayScale¶
Render any composable in grayscale:
customOverscroll¶
An iOS-style overscroll effect for LazyListState or PagerState (Android). Used internally by CupertinoLazyColumn.
Modifier.customOverscroll(
listState = state,
onNewOverscrollAmount = { amount -> /* apply offset */ },
)
Image Helpers¶
ImageBitmap¶
val bytes = imageBitmap.toByteArray(ImageBitmapCompressFormat.COMMON_PNG)
val b64 = imageBitmap.toBase64String(ImageBitmapCompressFormat.COMMON_WEBP)
val resized = imageBitmap.resize(width = 128, height = 128)
val decoded = ImageBitmap.fromByteArray(bytes) // suspend
val fromB64 = ImageBitmap.fromBase64String(b64) // suspend
val compressed = imageBitmap.compress(ImageBitmapCompressFormat.COMMON_JPEG, quality = 60)
ImageVector¶
val painter = icon.asVectorPainter()
val bitmap = icon.toImageBitmap(size = Size(64f, 64f))
val bytes = icon.toByteArray(ImageBitmapCompressFormat.COMMON_PNG)
val b64 = icon.toBase64String(ImageBitmapCompressFormat.COMMON_WEBP)
Base64 decoding¶
decodeBase64ToImageBitmap(base64) turns a Base64 string into an ImageBitmap (strips common data:image/...;base64, prefixes).
ByteString¶
ByteString.cleanBase64Web(input) strips data-URL prefixes and whitespace from a Base64 web string.
DataStore (Compose)¶
Read DataStore values directly in Compose:
val theme by rememberStringDataStore("theme", "LIGHT") // State<T>
var count by rememberMutableIntDataStore("count", 0) // MutableState<T>
rememberSerializableDataStore / rememberMutableSerializableDataStore do the same for @Serializable objects. See DataStores.
Previews¶
AllDevicePreviews¶
A single annotation that renders phone, foldable, tablet, and desktop previews:
Store Reviews & Updates¶
rememberStarterStoreManager() and rememberUpdateLauncher() live here, powering AppUpdateProvider. See Store Reviews & Updates.
Files¶
rememberStarterFileManager() gives you a Compose-aware StarterFileManager (bound to the Android Activity). See Starter File Manager.
Support My Project ☕️¶
If you find this project useful, consider supporting it by buying me a coffee. Your support will help me to continue working on this project and add more features.