Skip to main content
Module: 03 of 13Duration: 4 weeksTopics: 4 · 8 subtopicsPrerequisites: Modules 01–02

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 @Composable function or a setContent { } block.
  • Should be idempotent — given the same parameters, produce the same UI.
  • Should have no side effects in the function body itself; use LaunchedEffect/SideEffect for 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

Recomposition propagation
MutableState<T>value mutated → invalidate readersReader ARecomposesreads state.valueReader BRecomposesreads state.valueSibling CSKIPPEDno read of stateOnly composables that READ the state recomposeStable parameters + remember + derivedStateOf keep recomposition narrowUse Layout Inspector → Recomposition counts to spot leaks
Only composables that read the state recompose. Siblings stay untouched.

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) })
}
}
}

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

Key takeaways

Practice exercises

  1. 01

    Refactor to stateless

    Take a stateful Counter composable and refactor it into a stateless one with hoisted state.

  2. 02

    List with animations

    Build a LazyColumn of cards. Tapping a card expands its description with animateContentSize().

  3. 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.