Compose Conventions
Screen Structure Pattern
XxxScreen() -> Stateful: DI (hiltViewModel), effects, navigation
XxxScreenInternal() -> Stateless: previewable, uses IBaseViewModel interface
ContentComponent() -> Private: actual UI content
Stateful Screen
@Composable
fun XxxScreen(viewModel: XxxViewModel = hiltViewModel()) {
val navController = LocalAppNavController.current
LaunchedEffect(Unit) {
viewModel.effectState.collect { effect ->
when (effect) {
is XxxViewEffect.NavigateToY -> {
navController.navigateTo(Screen.YScreen(...))
viewModel.clearEffect()
}
null -> {}
}
}
}
XxxScreenInternal(
viewModel = viewModel,
viewModel.onTriggerEvent(XxxViewEvent.OnAction) },
)
}
Stateless Screen
@Composable
internal fun XxxScreenInternal(
viewModel: IBaseViewModel<XxxViewState, XxxViewEvent, XxxViewEffect>,
modifier: Modifier = Modifier,
onAction: () -> Unit = {},
) {
BaseScreenComponent(
viewModel = viewModel,
modifier = modifier.fillMaxSize().statusBarsPadding(),
topBar = { state -> XxxTopAppBar(...) },
) { state, paddingValues ->
// Content using state
}
}
BaseScreenComponent
BaseScreenComponent(
viewModel = viewModel,
modifier = modifier,
enableLoading = true,
externalIsRefreshing = refreshState.isRefreshing,
refreshState.refresh() },
effect -> /* handle effects (Pattern 2) */ },
topBar = { state -> /* TopAppBar */ },
bottomBar = { /* BottomBar */ },
floatingActionButton = { /* FAB */ },
diaLogComposable = { state -> /* Dialogs/BottomSheets */ },
loadingComposable = { CircleLoadingComponent() },
) { state, paddingValues ->
// Main content
}
Features: state collection, effect handling, pull-to-refresh, loading overlay, system bar colors, keyboard dismiss, back button.
CompositionLocals
| Local | Type | Usage |
|---|---|---|
LocalAppNavController |
NavHostController |
LocalAppNavController.current |
LocalToastHostState |
ToastHostState |
Toast display |
LocalSpacing |
Spacing |
MaterialTheme.spacing.dp16 |
Spacing System
// CORRECT
Modifier.padding(MaterialTheme.spacing.dp16)
Modifier.padding(horizontal = MaterialTheme.spacing.dp16, vertical = MaterialTheme.spacing.dp8)
// INCORRECT
Modifier.padding(16.dp) // Don't hardcode
Values: dp0, dp1, dp2, dp4, dp6, dp8, dp10, dp12, dp14, dp16, dp20, dp24, dp32, dp40,
dp48, dp56, dp64, dp80, dp96, dp120, dp160, dp200, dp500
Navigation
// Route definition in feature/core/navigation/routes/NavigationRoutes.kt
sealed class Screen {
@Serializable data object SampleScreen : Screen()
@Serializable data class DetailScreen(val id: String) : Screen()
}
// Navigation extensions
navController.navigateTo(Screen.DetailScreen(id = "123"))
navController.navigateAndClearBackStack(Screen.SampleScreen)
navController.navigateSingleTop(Screen.SampleScreen)
navController.safePopBackStack()
// NavGraphBuilder extension in feature/xxx/XxxNavigation.kt
fun NavGraphBuilder.xxxScreen() {
composable<Screen.XxxScreen>(
enterTransition = slideInRight(),
exitTransition = slideOutRight(),
) {
XxxScreen()
}
}
Theme Usage
// Production - provides NavController, ToastHost, Spacing, MaterialTheme
TemplateTheme(darkTheme = isSystemInDarkTheme()) { AppContent() }
// Preview - NO NavController, NO ToastHost
TemplateThemePreview(darkTheme = false) { XxxScreenInternal(viewModel = fakeViewModel) }
Preview Pattern
@Preview(showBackground = true)
@Composable
private fun XxxScreenPreview() {
val fakeViewModel = FakeBaseViewModel<XxxViewState, XxxViewEvent, XxxViewEffect>(
initialState = XxxViewState(/* preview data */),
// initialLoading = true,
// initialEffect = XxxViewEffect.ShowDialog,
)
TemplateThemePreview(darkTheme = false) {
XxxScreenInternal(viewModel = fakeViewModel)
}
}
PagingData in Compose
@Composable
internal fun XxxScreenInternal(viewModel: IBaseViewModel<...>) {
val refreshState = rememberPagingRefreshState<ItemType>()
BaseScreenComponent(
viewModel = viewModel,
externalIsRefreshing = refreshState.isRefreshing,
refreshState.refresh() },
) { state, padding ->
val items = state.pagingFlow?.collectAsLazyPagingItems()
refreshState.bind(items)
PagingVerticalGridComponent(
items = items,
columns = GridCells.Fixed(2),
) { pagingItems ->
items(
count = pagingItems.itemCount,
key = pagingItems.itemKey { it.id }, // Stable keys!
) { index ->
pagingItems[index]?.let { item -> ItemComponent(item = item) }
}
}
}
}
Reusable Components (feature/core/component/)
CircleLoadingComponent- Loading indicatorPagingVerticalGridComponent- Paginated gridPagingLazyColumnComponent- Paginated listToastHost+ToastHostState- Custom toast systemBaseScreenComponent- Screen wrapperNoDataComponent- Empty stateSkeletonLoaderComponent- Skeleton loading