🚀 Jetpack Compose Cheat Sheet
Composables, State, Navigation, ViewModel, side effects and Material 3.
🧩 Composables
Basic Composable
@Composable
fun Greeting(name: String) {
Text(
text = "Hello, $name!",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary
)
}
// Composable with modifier (best practice)
@Composable
fun UserCard(
user: User,
modifier: Modifier = Modifier,
onClick: () -> Unit = {}
) {
Card(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick),
elevation = CardDefaults.cardElevation(4.dp)
) {
Column(Modifier.padding(16.dp)) {
Text(user.name, style = MaterialTheme.typography.titleMedium)
Text(user.email, style = MaterialTheme.typography.bodySmall)
}
}
}Common components
// Text
Text("Hello", style = MaterialTheme.typography.bodyLarge)
// Button
Button(onClick = { /* action */ }) { Text("Click") }
OutlinedButton(onClick = {}) { Text("Outlined") }
TextButton(onClick = {}) { Text("Text") }
IconButton(onClick = {}) { Icon(Icons.Default.Add, null) }
FloatingActionButton(onClick = {}) { Icon(Icons.Default.Add, null) }
// TextField
var text by remember { mutableStateOf("") }
OutlinedTextField(
value = text,
onValueChange = { text = it },
label = { Text("Email") },
leadingIcon = { Icon(Icons.Default.Email, null) },
isError = text.isEmpty(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
// Checkbox, Switch, RadioButton
var checked by remember { mutableStateOf(false) }
Checkbox(checked = checked, onCheckedChange = { checked = it })
Switch(checked = checked, onCheckedChange = { checked = it })
// Image
AsyncImage( // Coil library
model = "https://...",
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(80.dp).clip(CircleShape)
)🔄 State Management
remember & mutableStateOf
// Basic state
var count by remember { mutableStateOf(0) }
var text by remember { mutableStateOf("") }
var list by remember { mutableStateOf(listOf<Item>()) }
// Remember with key — recomputes when key changes
val processed by remember(userId) { mutableStateOf(process(userId)) }
// rememberSaveable — survives rotation
var input by rememberSaveable { mutableStateOf("") }
// Derived state — recomputes only when dependencies change
val isValid by remember { derivedStateOf { text.isNotEmpty() } }
// Snapshot state list/map
val items = remember { mutableStateListOf<Item>() }
val map = remember { mutableStateMapOf<String, Int>() }
items.add(newItem) // triggers recompositionState hoisting pattern
// Stateful (owns state)
@Composable
fun CounterScreen() {
var count by remember { mutableStateOf(0) }
CounterContent(
count = count,
onIncrement = { count++ },
onDecrement = { count-- }
)
}
// Stateless (receives state + callbacks)
@Composable
fun CounterContent(
count: Int,
onIncrement: () -> Unit,
onDecrement: () -> Unit,
modifier: Modifier = Modifier
) {
Row(modifier) {
IconButton(onClick = onDecrement) { Icon(Icons.Default.Remove, null) }
Text("$count")
IconButton(onClick = onIncrement) { Icon(Icons.Default.Add, null) }
}
}📐 Layout
Column, Row, Box
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Top")
Spacer(Modifier.height(8.dp))
Text("Bottom")
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Star, null)
Text("Title", Modifier.weight(1f)) // weight = Expanded equivalent
Text("Right")
}
Box(Modifier.fillMaxSize()) {
Image(...)
Text("Overlay", Modifier.align(Alignment.BottomCenter).padding(8.dp))
}Modifier chaining
Modifier
.fillMaxWidth()
.fillMaxHeight() // or .fillMaxSize()
.size(100.dp)
.width(200.dp).height(50.dp)
.padding(16.dp)
.padding(horizontal = 16.dp, vertical = 8.dp)
.background(MaterialTheme.colorScheme.surface)
.clip(RoundedCornerShape(12.dp))
.border(1.dp, Color.Gray, RoundedCornerShape(12.dp))
.clickable { }
.alpha(0.5f)
.offset(x = 8.dp, y = 4.dp)
.wrapContentSize()
.statusBarsPadding()
.navigationBarsPadding()
.imePadding() // avoid keyboardScaffold & TopAppBar
Scaffold(
topBar = {
TopAppBar(
title = { Text("Home") },
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, null)
}
},
actions = {
IconButton(onClick = { }) {
Icon(Icons.Default.Search, null)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
)
},
floatingActionButton = {
FloatingActionButton(onClick = { }) {
Icon(Icons.Default.Add, null)
}
},
bottomBar = { BottomNavBar(navController) }
) { padding ->
// Content — always apply the padding!
Column(Modifier.padding(padding)) { ... }
}📋 Lazy Lists
LazyColumn & LazyRow
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
state = listState // LazyListState for scroll control
) {
// Header
item { Text("Header", style = MaterialTheme.typography.titleLarge) }
// Section with sticky header
stickyHeader { SectionHeader("Recent") }
// Items
items(items = items, key = { it.id }) { item ->
ItemCard(item = item)
}
// Items with index
itemsIndexed(items) { index, item ->
if (index > 0) Divider()
ItemRow(item = item)
}
// Footer / load more
item {
if (isLoadingMore) CircularProgressIndicator()
}
}
// LazyRow (horizontal)
LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
items(categories) { cat -> ChipButton(cat) }
}
// Scroll state
val listState = rememberLazyListState()
val isAtBottom by remember {
derivedStateOf {
val last = listState.layoutInfo.visibleItemsInfo.lastOrNull()
last?.index == items.lastIndex
}
}LazyVerticalGrid
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 160.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(photos, key = { it.id }) { photo ->
PhotoCard(photo = photo)
}
}🧠 ViewModel
ViewModel with StateFlow
class HomeViewModel(
private val getUsersUseCase: GetUsersUseCase,
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
private val _uiState = MutableStateFlow<HomeUiState>(HomeUiState.Loading)
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
private val _events = MutableSharedFlow<HomeEvent>()
val events: SharedFlow<HomeEvent> = _events.asSharedFlow()
init { loadUsers() }
fun loadUsers() {
viewModelScope.launch {
_uiState.value = HomeUiState.Loading
getUsersUseCase()
.onSuccess { _uiState.value = HomeUiState.Success(it) }
.onFailure { _uiState.value = HomeUiState.Error(it.message ?: "") }
}
}
fun onUserClick(user: User) {
viewModelScope.launch {
_events.emit(HomeEvent.NavigateTo(Routes.detail(user.id)))
}
}
}
// Composable
@Composable
fun HomeScreen(navController: NavController) {
val vm: HomeViewModel = hiltViewModel()
val uiState by vm.uiState.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
vm.events.collect { event ->
when (event) {
is HomeEvent.NavigateTo -> navController.navigate(event.route)
}
}
}
when (uiState) {
is HomeUiState.Loading -> LoadingScreen()
is HomeUiState.Success -> UserList((uiState as HomeUiState.Success).users, vm::onUserClick)
is HomeUiState.Error -> ErrorScreen((uiState as HomeUiState.Error).message)
}
}⚡ Side Effects
LaunchedEffect, SideEffect, DisposableEffect
// LaunchedEffect — coroutine, re-runs when keys change
LaunchedEffect(Unit) { /* one-time setup */ }
LaunchedEffect(userId) { vm.loadUser(userId) }
LaunchedEffect(snackbarState) { /* collect events */ }
// SideEffect — runs after every successful recomposition
SideEffect {
systemUiController.setStatusBarColor(color)
}
// DisposableEffect — cleanup on leave
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) vm.refresh()
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
// rememberCoroutineScope — for non-composable callbacks
val scope = rememberCoroutineScope()
Button(onClick = {
scope.launch { scrollState.animateScrollTo(0) }
}) { Text("Scroll to top") }✨ Animation
Animate state changes
// animateFloatAsState
val alpha by animateFloatAsState(
targetValue = if (isVisible) 1f else 0f,
animationSpec = tween(300),
label = "alpha"
)
Box(Modifier.alpha(alpha)) { ... }
// animateDpAsState
val size by animateDpAsState(
targetValue = if (selected) 120.dp else 80.dp,
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy),
label = "size"
)
// animateColorAsState
val color by animateColorAsState(
targetValue = if (selected) MaterialTheme.colorScheme.primary else Color.Gray,
label = "color"
)
// AnimatedVisibility
AnimatedVisibility(
visible = isVisible,
enter = fadeIn() + slideInVertically(),
exit = fadeOut() + slideOutVertically()
) { Content() }
// AnimatedContent
AnimatedContent(targetState = count, label = "count") { target ->
Text("$target")
}🎨 Material 3 Theme
M3 Theme access
// Colors MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.secondary MaterialTheme.colorScheme.surface MaterialTheme.colorScheme.background MaterialTheme.colorScheme.onPrimary MaterialTheme.colorScheme.primaryContainer MaterialTheme.colorScheme.error // Typography MaterialTheme.typography.displayLarge MaterialTheme.typography.titleLarge MaterialTheme.typography.titleMedium MaterialTheme.typography.bodyLarge MaterialTheme.typography.bodyMedium MaterialTheme.typography.labelSmall // Shapes MaterialTheme.shapes.small // 4dp MaterialTheme.shapes.medium // 8dp MaterialTheme.shapes.large // 16dp MaterialTheme.shapes.extraLarge // 28dp
Dynamic color theme
@Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= 31 -> {
if (darkTheme) dynamicDarkColorScheme(LocalContext.current)
else dynamicLightColorScheme(LocalContext.current)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = AppTypography,
content = content
)
}⚡ Pro Tips
Performance best practices
// Use keys in LazyColumn to avoid full recompose
items(list, key = { it.id }) { ItemCard(it) }
// Don't read state in parent if only child needs it
// Bad: triggers parent recompose
Column { Text("$count") } // reads count in Column scope
// Good: isolate to where used
Column { CountText(count) } // only CountText recomposes
// @Stable for stable classes (skip recompose if equal)
@Stable
data class User(val id: String, val name: String)
// Avoid lambda allocations — use stable references
val onClick = remember { { item: Item -> handleClick(item) } }
// Prefer collectAsStateWithLifecycle (lifecycle-aware)
val state by vm.uiState.collectAsStateWithLifecycle()
// Use key() to force reset
key(userId) { UserProfile(userId) } // fresh instance on userId changeHilt + Compose
// ViewModel injection
val vm: HomeViewModel = hiltViewModel()
// Scoped to nav entry
val vm: HomeViewModel = hiltViewModel(
viewModelStoreOwner = navController.getBackStackEntry(Routes.HOME)
)
// Entry points in composables (if needed)
@AndroidEntryPoint class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { AppTheme { AppNavGraph() } }
}
}