Modern UI with Jetpack Compose
Jetpack Compose is the recommended way to build Android UIs. It replaces XML layouts and the imperative View system with a declarative, reactive toolkit written entirely in Kotlin. Once you internalize the mental model — "UI is a function of state" — building screens becomes dramatically faster and less error-prone.
Topic 1 · Compose Fundamentals
Declarative UI & the Compose philosophy
A Composable function describes a piece of UI. It takes parameters (state) and emits UI. When the state changes, Compose calls the function again — a process called recomposition.
@Composable
fun Greeting(name: String) {
Text(text = "Hello, $name!")
}
// Use it
@Composable
fun App() {
Column(modifier = Modifier.padding(16.dp)) {
Greeting(name = "Aarav")
Greeting(name = "Diya")
}
}
A @Composable function:
- Returns
Unit— it emits UI as a side effect of being called. - Can only be called from another
@Composablefunction or asetContent { }block. - Should be idempotent — given the same parameters, produce the same UI.
- Should have no side effects in the function body itself; use
LaunchedEffect/SideEffectfor those.
Composables, modifiers, and layouts
Modifiers are how you decorate a composable: padding, size, background, clicks, gestures, semantics. Order matters!
@Composable
fun ProfileCard(user: User, onClick: () -> Unit) {
Row(
// Modifiers are applied left-to-right — clip happens AFTER background here
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = onClick)
.padding(16.dp), // inner padding (after clickable)
verticalAlignment = Alignment.CenterVertically
) {
AsyncImage(
model = user.avatarUrl,
contentDescription = null,
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
)
Spacer(Modifier.width(12.dp))
Column {
Text(user.name, style = MaterialTheme.typography.titleMedium)
Text(user.email, style = MaterialTheme.typography.bodySmall)
}
}
}
The three foundational layouts are Row, Column, and Box. For lists you
use LazyColumn / LazyRow / LazyVerticalGrid — they only compose visible
items, like RecyclerView.
Topic 2 · State & Theming
State management — remember, mutableStateOf, hoisting
State in Compose is observable — when you read a State<T> inside a
composable, that composable will recompose whenever the value changes.
@Composable
fun Counter() {
// 'remember' preserves state across recompositions.
// 'mutableStateOf' creates an observable state holder.
var count by remember { mutableIntStateOf(0) }
Column {
Text("Count: $count")
Button(onClick = { count++ }) {
Text("Increment")
}
}
}
remember { } survives recomposition but not configuration changes
(rotation) or process death. For that, use rememberSaveable { }:
var draft by rememberSaveable { mutableStateOf("") }
State hoisting — the most important pattern
A composable that owns state is called stateful. A composable that receives state and event callbacks as parameters is stateless. Stateless composables are easier to test, preview, and reuse.
Hoist state up to the lowest common ancestor of all composables that read or write it.
// STATEFUL — owns state, hard to reuse
@Composable
fun NameField() {
var name by remember { mutableStateOf("") }
OutlinedTextField(value = name, onValueChange = { name = it })
}
// STATELESS — state and callbacks are parameters; reusable everywhere
@Composable
fun NameField(name: String, onNameChange: (String) -> Unit) {
OutlinedTextField(value = name, onValueChange = onNameChange)
}
// Caller hoists state; in real apps, this comes from a ViewModel
@Composable
fun ProfileScreen(viewModel: ProfileViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
NameField(name = state.name, onNameChange = viewModel::onNameChange)
}
Material Design 3 theming
Material 3 (Material You) supports dynamic color on Android 12+ — your app can adopt the user's wallpaper-derived palette automatically.
@Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val ctx = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx)
}
darkTheme -> darkColorScheme()
else -> lightColorScheme()
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}
Reference theme tokens via MaterialTheme.colorScheme.primary, .typography.titleLarge, etc. — never hardcode hex values.
Topic 3 · Lists & Navigation
Lazy lists — LazyColumn, LazyRow, LazyVerticalGrid
@Composable
fun MessageList(messages: List<Message>, onMessageClick: (Message) -> Unit) {
LazyColumn(
contentPadding = PaddingValues(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(
items = messages,
key = { it.id } // stable keys help Compose track items across reorders
) { message ->
MessageRow(message = message, onClick = { onMessageClick(message) })
}
}
}
Navigation in Compose
The official solution is Navigation Compose with type-safe routes (introduced in 2.8):
@Serializable data object Home
@Serializable data class Profile(val userId: String)
@Serializable data object Settings
@Composable
fun AppNavHost() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = Home) {
composable<Home> {
HomeScreen(
onProfileClick = { id -> navController.navigate(Profile(id)) },
onSettingsClick = { navController.navigate(Settings) }
)
}
composable<Profile> { backStackEntry ->
val args = backStackEntry.toRoute<Profile>()
ProfileScreen(userId = args.userId, onBack = { navController.popBackStack() })
}
composable<Settings> { SettingsScreen(onBack = { navController.popBackStack() }) }
}
}
Type-safe routes catch mistyped argument names at compile time and eliminate manual URL encoding.
Topic 4 · Advanced Compose
Animations
Compose makes animations declarative. Three families to know:
// 1. animate*AsState — interpolates a value when its target changes
@Composable
fun ToggleBox() {
var expanded by remember { mutableStateOf(false) }
val size by animateDpAsState(
targetValue = if (expanded) 200.dp else 100.dp,
animationSpec = spring(),
label = "boxSize"
)
Box(
Modifier
.size(size)
.background(MaterialTheme.colorScheme.primary)
.clickable { expanded = !expanded }
)
}
// 2. AnimatedVisibility — animate appearance/disappearance
AnimatedVisibility(
visible = isLoaded,
enter = fadeIn() + slideInVertically(),
exit = fadeOut()
) {
SuccessBanner()
}
// 3. Crossfade — swap composables with a fade
Crossfade(targetState = uiState, label = "screen") { state ->
when (state) {
is UiState.Loading -> LoadingView()
is UiState.Success -> ContentView(state.data)
is UiState.Error -> ErrorView(state.error)
}
}
Side effects
Side effects let composables interact with the non-Compose world (network, analytics, lifecycle):
@Composable
fun ProfileScreen(viewModel: ProfileViewModel) {
// LaunchedEffect — runs in a coroutine; restarts when 'key1' changes
LaunchedEffect(viewModel) {
viewModel.events.collect { event ->
// handle one-shot events: navigation, toast, etc.
}
}
// DisposableEffect — runs setup + cleanup tied to the composition lifetime
DisposableEffect(Unit) {
analytics.logScreenView("profile")
onDispose { analytics.logScreenLeave("profile") }
}
}
Compose ↔ XML interop
You can host Composables inside XML (ComposeView) and Views inside
Composables (AndroidView). Useful for incremental migration:
@Composable
fun MapView(coords: Coords) {
AndroidView(
factory = { ctx ->
MapView(ctx).apply { onCreate(null); onResume() }
},
update = { it.moveCamera(coords) }
)
}
Companion libraries you'll use constantly
Coroutine-based, Compose-first image loader. AsyncImage handles caching, transformations, and placeholders.
Permissions, system UI, pager indicators, and other utilities while features mature into the official toolkit.
Official routing for Compose with type-safe arguments and deep links.
Render Adobe After Effects animations natively with one Composable.
Key takeaways
Practice exercises
- 01
Refactor to stateless
Take a stateful Counter composable and refactor it into a stateless one with hoisted state.
- 02
List with animations
Build a LazyColumn of cards. Tapping a card expands its description with animateContentSize().
- 03
Navigation flow
Implement Home → ProductList → ProductDetail with type-safe routes and back navigation.
Next module
Continue to Module 04 — Architecture & Design Patterns to organize your Compose code with MVVM, Clean Architecture, and Hilt.