Why Jetpack Compose Changes Everything
For years, Android UI development meant writing XML layouts and wiring them up with findViewById or View Binding. Jetpack Compose replaces that entire paradigm with a declarative, Kotlin-first approach to building user interfaces. Instead of describing what the UI looks like in XML and imperatively mutating views, you write composable functions that describe what the UI should be given the current state. When state changes, Compose automatically re-renders only the parts of the UI that need updating.
This article covers the fundamentals: composable functions, state management, layout with Column and Row, and building a real screen from scratch.
Composable Functions: The Building Blocks
Every piece of UI in Compose is a composable function, annotated with @Composable. These functions do not return anything. Instead, they emit UI elements by calling other composable functions.
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
@Composable
fun Greeting(name: String) {
Text(
text = "Hello, $name!",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.primary
)
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
MaterialTheme {
Greeting(name = "ByteYogi")
}
}
The @Preview annotation lets you see the composable in Android Studio’s preview pane without running the app on a device. This tight feedback loop is one of Compose’s biggest productivity gains.
State Management with remember and mutableStateOf
Compose re-executes composable functions whenever their inputs change, a process called recomposition. To hold onto data across recompositions, you use remember combined with mutableStateOf.
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxSize()
) {
Text(
text = "Count: $count",
style = MaterialTheme.typography.displayMedium
)
Spacer(modifier = Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = { count-- }) {
Text("Decrease")
}
Button(onClick = { count++ }) {
Text("Increase")
}
}
}
}
When count changes, Compose knows exactly which parts of the UI depend on it and re-renders only those. The by keyword delegates to Kotlin’s property delegation, allowing you to read and write count as if it were a plain integer.
Layouts: Column, Row, and Box
Compose provides three fundamental layout composables. Column arranges children vertically, Row arranges them horizontally, and Box stacks them on top of each other. Combined with the Modifier system, these three composables can express any layout.
@Composable
fun TaskCard(
title: String,
description: String,
priority: String,
onComplete: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium
)
AssistChip(
onClick = {},
label = { Text(priority) },
colors = AssistChipDefaults.assistChipColors(
containerColor = when (priority) {
"High" -> MaterialTheme.colorScheme.errorContainer
"Medium" -> MaterialTheme.colorScheme.secondaryContainer
else -> MaterialTheme.colorScheme.surfaceVariant
}
)
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = onComplete,
modifier = Modifier.align(Alignment.End)
) {
Text("Mark Complete")
}
}
}
}
Building a Complete Screen: Task List
Now let us combine everything into a full screen with a list of tasks, the ability to add new tasks, and state hoisting to keep the architecture clean.
data class Task(
val id: Int,
val title: String,
val description: String,
val priority: String
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TaskListScreen() {
var tasks by remember {
mutableStateOf(
listOf(
Task(1, "Set up CI/CD", "Configure GitHub Actions pipeline", "High"),
Task(2, "Write unit tests", "Cover repository layer", "Medium"),
Task(3, "Update README", "Add setup instructions", "Low")
)
)
}
var showDialog by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("My Tasks") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
)
},
floatingActionButton = {
FloatingActionButton(onClick = { showDialog = true }) {
Text("+", style = MaterialTheme.typography.headlineSmall)
}
}
) { paddingValues ->
LazyColumn(
contentPadding = paddingValues,
modifier = Modifier.fillMaxSize()
) {
items(tasks.size) { index ->
val task = tasks[index]
TaskCard(
title = task.title,
description = task.description,
priority = task.priority,
onComplete = {
tasks = tasks.filterNot { it.id == task.id }
}
)
}
}
}
if (showDialog) {
AddTaskDialog(
onDismiss = { showDialog = false },
onAdd = { title, desc, priority ->
val newId = (tasks.maxOfOrNull { it.id } ?: 0) + 1
tasks = tasks + Task(newId, title, desc, priority)
showDialog = false
}
)
}
}
@Composable
fun AddTaskDialog(
onDismiss: () -> Unit,
onAdd: (String, String, String) -> Unit
) {
var title by remember { mutableStateOf("") }
var description by remember { mutableStateOf("") }
var priority by remember { mutableStateOf("Medium") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("New Task") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text("Title") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("Description") },
modifier = Modifier.fillMaxWidth()
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf("Low", "Medium", "High").forEach { level ->
FilterChip(
selected = priority == level,
onClick = { priority = level },
label = { Text(level) }
)
}
}
}
},
confirmButton = {
TextButton(
onClick = { onAdd(title, description, priority) },
enabled = title.isNotBlank()
) {
Text("Add")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
Key Compose Principles to Remember
First, composable functions should be side-effect free. They describe what the UI looks like, not what it does. Side effects like network calls belong in LaunchedEffect or a ViewModel. Second, state should be hoisted. Push state up to the nearest common ancestor that needs it, and pass it down as parameters. This keeps composables reusable and testable. Third, use the Modifier chain for layout, sizing, padding, and interaction. Modifiers compose cleanly and keep your function signatures focused on business logic rather than visual tweaks.
Jetpack Compose eliminates the boilerplate that made Android UI development tedious. With composable functions, reactive state, and flexible layouts, you can build sophisticated interfaces with significantly less code. The learning curve is real, but once the declarative mental model clicks, going back to XML layouts feels like stepping back in time.