
Jetpack Compose: A Comprehensive Guide for Modern Android Development

D. Rout
March 11, 2026 13 min read
On this page
Jetpack Compose is Android's modern, declarative UI toolkit that simplifies and accelerates UI development. Instead of building layouts with XML and manipulating views imperatively, Compose lets you describe your UI in Kotlin using composable functions — and the framework handles the rest. This guide walks you through everything you need to get started and build production-quality Android apps with Compose.
1. Getting Started
Why Jetpack Compose?
Traditional Android development with XML layouts and View-based APIs can be verbose, error-prone, and difficult to maintain. Jetpack Compose solves this by:
- Eliminating the need for XML layout files
- Providing a reactive, state-driven UI model
- Enabling UI code and logic to live together in one place (Kotlin)
- Offering hot reload via Live Edit and interactive previews in Android Studio
- Reducing boilerplate significantly
2. Setup
Prerequisites
- Android Studio Hedgehog (2023.1.1) or newer (recommended: Ladybug / latest stable)
- Kotlin 1.9+
- minSdk 21 (Android 5.0) or higher
- Gradle 8.x
Creating a New Compose Project
Open Android Studio and select New Project → Empty Activity. Android Studio will scaffold a Compose-ready project automatically.
Gradle Configuration
In your app-level build.gradle.kts:
android {
compileSdk = 34
defaultConfig {
minSdk = 21
targetSdk = 34
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.8"
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.02.00")
implementation(composeBom)
// Core Compose UI
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
// Material Design 3
implementation("androidx.compose.material3:material3")
// Activity integration
implementation("androidx.activity:activity-compose:1.8.2")
// ViewModel support
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
// Navigation
implementation("androidx.navigation:navigation-compose:2.7.6")
// Debug tools
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}
Tip: Using the Compose BOM (Bill of Materials) ensures all Compose library versions are compatible with each other — you don't need to manage individual version numbers.
3. Basic Structure
Your First Composable
Everything in Compose starts with composable functions — regular Kotlin functions annotated with @Composable.
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
@Composable
fun Greeting(name: String) {
Text(text = "Hello, $name!")
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
Greeting(name = "Android")
}
Setting Up Your Activity
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyAppTheme {
Surface(color = MaterialTheme.colorScheme.background) {
Greeting(name = "World")
}
}
}
}
}
Rules of Composable Functions
- Names should start with a capital letter (they represent UI elements)
- They can only be called from other composable functions
- They should be side-effect free — don't perform I/O or mutations inside them directly
- They may be called multiple times (recomposition), so they must be idempotent
4. Key Concepts and Components
4.1 State and Recomposition
Compose uses a reactive model: when state changes, the UI recomposes automatically.
import androidx.compose.runtime.*
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.foundation.layout.Column
@Composable
fun Counter() {
// 'remember' retains state across recompositions
var count by remember { mutableStateOf(0) }
Column {
Text(text = "Count: $count")
Button(onClick = { count++ }) {
Text("Increment")
}
}
}
Key state APIs:
| API | Purpose |
|---|---|
remember { } |
Retains value across recompositions |
mutableStateOf() |
Creates observable state |
rememberSaveable { } |
Survives configuration changes |
collectAsState() |
Converts Flow to Compose state |
4.2 Layout Composables
Compose provides three fundamental layout containers:
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
// Vertical stack
@Composable
fun VerticalLayout() {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Item 1")
Text("Item 2")
Text("Item 3")
}
}
// Horizontal stack
@Composable
fun HorizontalLayout() {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Left")
Text("Right")
}
}
// Z-axis stacking (overlapping elements)
@Composable
fun OverlayLayout() {
Box(contentAlignment = Alignment.Center) {
Text("Background")
Text("Foreground")
}
}
4.3 Modifiers
Modifiers are the primary way to decorate or configure composables — think of them as a chain of layout instructions.
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@Composable
fun StyledBox() {
Box(
modifier = Modifier
.size(200.dp)
.background(Color.LightGray, shape = RoundedCornerShape(12.dp))
.border(2.dp, Color.DarkGray, shape = RoundedCornerShape(12.dp))
.padding(16.dp)
) {
Text(text = "Styled Content")
}
}
Modifier order matters!
.padding(16.dp).background(Color.Blue)produces a different result than.background(Color.Blue).padding(16.dp).
4.4 Lists with LazyColumn and LazyRow
For scrollable lists of items, use lazy composables (equivalent to RecyclerView):
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.Text
import androidx.compose.foundation.layout.*
data class Item(val id: Int, val title: String, val description: String)
@Composable
fun ItemList(items: List<Item>) {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(items, key = { it.id }) { item ->
ItemCard(item = item)
}
}
}
@Composable
fun ItemCard(item: Item) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(text = item.title, style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(4.dp))
Text(text = item.description, style = MaterialTheme.typography.bodyMedium)
}
}
}
4.5 State Hoisting
A best practice in Compose is state hoisting — moving state up to the caller so composables remain stateless and reusable.
// Stateless composable — easy to test and reuse
@Composable
fun NameInput(
name: String,
onNameChange: (String) -> Unit
) {
OutlinedTextField(
value = name,
onValueChange = onNameChange,
label = { Text("Name") }
)
}
// State owner composable
@Composable
fun NameScreen() {
var name by remember { mutableStateOf("") }
Column {
NameInput(name = name, onNameChange = { name = it })
Text(text = "Hello, $name!")
}
}
4.6 Side Effects
Use structured APIs when composables need to interact with the outside world:
import androidx.compose.runtime.*
import kotlinx.coroutines.delay
@Composable
fun TimerScreen() {
var seconds by remember { mutableStateOf(0) }
// LaunchedEffect runs a coroutine tied to the composable's lifecycle
LaunchedEffect(Unit) {
while (true) {
delay(1000)
seconds++
}
}
Text(text = "Elapsed: ${seconds}s")
}
Common side-effect APIs:
| API | Use case |
|---|---|
LaunchedEffect(key) |
Coroutine that restarts when key changes |
SideEffect |
Sync Compose state with non-Compose code |
DisposableEffect |
Setup/teardown (e.g., listeners) |
rememberCoroutineScope() |
Trigger coroutines from event handlers |
5. Building a Sample App
Let's build a simple Task Manager app with a list of tasks, the ability to add new ones, and mark them complete.
Data Model
data class Task(
val id: Int,
val title: String,
val isCompleted: Boolean = false
)
ViewModel
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
class TaskViewModel : ViewModel() {
private val _tasks = MutableStateFlow(
listOf(
Task(1, "Buy groceries"),
Task(2, "Read a book"),
Task(3, "Go for a run")
)
)
val tasks: StateFlow<List<Task>> = _tasks
fun addTask(title: String) {
if (title.isBlank()) return
val newTask = Task(id = (_tasks.value.maxOfOrNull { it.id } ?: 0) + 1, title = title)
_tasks.update { it + newTask }
}
fun toggleTask(taskId: Int) {
_tasks.update { list ->
list.map { if (it.id == taskId) it.copy(isCompleted = !it.isCompleted) else it }
}
}
fun deleteTask(taskId: Int) {
_tasks.update { list -> list.filter { it.id != taskId } }
}
}
Task List Screen
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun TaskScreen(viewModel: TaskViewModel = viewModel()) {
val tasks by viewModel.tasks.collectAsStateWithLifecycle()
var showDialog by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(title = { Text("My Tasks") })
},
floatingActionButton = {
FloatingActionButton(onClick = { showDialog = true }) {
Icon(Icons.Default.Add, contentDescription = "Add Task")
}
}
) { padding ->
LazyColumn(
contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
top = padding.calculateTopPadding() + 8.dp,
bottom = 80.dp
),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(tasks, key = { it.id }) { task ->
TaskItem(
task = task,
onToggle = { viewModel.toggleTask(task.id) },
onDelete = { viewModel.deleteTask(task.id) }
)
}
}
}
if (showDialog) {
AddTaskDialog(
onDismiss = { showDialog = false },
onConfirm = { title ->
viewModel.addTask(title)
showDialog = false
}
)
}
}
@Composable
fun TaskItem(
task: Task,
onToggle: () -> Unit,
onDelete: () -> Unit
) {
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(checked = task.isCompleted, onCheckedChange = { onToggle() })
Spacer(modifier = Modifier.width(8.dp))
Text(
text = task.title,
modifier = Modifier.weight(1f),
style = if (task.isCompleted)
MaterialTheme.typography.bodyLarge.copy(
textDecoration = TextDecoration.LineThrough
)
else MaterialTheme.typography.bodyLarge
)
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, contentDescription = "Delete")
}
}
}
}
@Composable
fun AddTaskDialog(
onDismiss: () -> Unit,
onConfirm: (String) -> Unit
) {
var text by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("New Task") },
text = {
OutlinedTextField(
value = text,
onValueChange = { text = it },
label = { Text("Task title") },
singleLine = true
)
},
confirmButton = {
TextButton(onClick = { onConfirm(text) }) {
Text("Add")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
Navigation Between Screens
import androidx.compose.runtime.Composable
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
sealed class Screen(val route: String) {
object TaskList : Screen("task_list")
object TaskDetail : Screen("task_detail/{taskId}") {
fun createRoute(taskId: Int) = "task_detail/$taskId"
}
}
@Composable
fun AppNavigation() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = Screen.TaskList.route) {
composable(Screen.TaskList.route) {
TaskScreen(
onTaskClick = { taskId ->
navController.navigate(Screen.TaskDetail.createRoute(taskId))
}
)
}
composable(Screen.TaskDetail.route) { backStackEntry ->
val taskId = backStackEntry.arguments?.getString("taskId")?.toInt()
TaskDetailScreen(taskId = taskId, onBack = { navController.popBackStack() })
}
}
}
6. Theming and Styling
Compose integrates deeply with Material Design 3 through its theming system.
Defining Your Theme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
// Define your color palette
private val LightColorScheme = lightColorScheme(
primary = Color(0xFF6200EE),
onPrimary = Color.White,
secondary = Color(0xFF03DAC6),
onSecondary = Color.Black,
background = Color(0xFFF6F6F6),
surface = Color.White,
error = Color(0xFFB00020)
)
private val DarkColorScheme = darkColorScheme(
primary = Color(0xFFBB86FC),
onPrimary = Color.Black,
secondary = Color(0xFF03DAC6),
onSecondary = Color.Black,
background = Color(0xFF121212),
surface = Color(0xFF1E1E1E),
error = Color(0xFFCF6679)
)
@Composable
fun MyAppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
MaterialTheme(
colorScheme = colorScheme,
typography = AppTypography,
shapes = AppShapes,
content = content
)
}
Custom Typography
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Nunito = FontFamily(
Font(R.font.nunito_regular, FontWeight.Normal),
Font(R.font.nunito_bold, FontWeight.Bold),
Font(R.font.nunito_semibold, FontWeight.SemiBold)
)
val AppTypography = Typography(
headlineLarge = TextStyle(
fontFamily = Nunito,
fontWeight = FontWeight.Bold,
fontSize = 32.sp,
lineHeight = 40.sp
),
bodyLarge = TextStyle(
fontFamily = Nunito,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp
),
labelSmall = TextStyle(
fontFamily = Nunito,
fontWeight = FontWeight.SemiBold,
fontSize = 11.sp
)
)
Custom Shapes
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
import androidx.compose.ui.unit.dp
val AppShapes = Shapes(
small = RoundedCornerShape(4.dp),
medium = RoundedCornerShape(8.dp),
large = RoundedCornerShape(16.dp)
)
Using Theme Values in Composables
@Composable
fun ThemedCard(title: String, body: String) {
Card(
shape = MaterialTheme.shapes.medium,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = body,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
Animations
Compose makes animations straightforward:
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.runtime.*
// Animated visibility
@Composable
fun ExpandableSection(content: @Composable () -> Unit) {
var expanded by remember { mutableStateOf(false) }
Column {
Button(onClick = { expanded = !expanded }) {
Text(if (expanded) "Collapse" else "Expand")
}
AnimatedVisibility(
visible = expanded,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut()
) {
content()
}
}
}
// Animated value
@Composable
fun PulsatingDot() {
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
val size by infiniteTransition.animateFloat(
initialValue = 20f,
targetValue = 40f,
animationSpec = infiniteRepeatable(
animation = tween(800, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "size"
)
Box(
modifier = Modifier
.size(size.dp)
.background(Color.Red, CircleShape)
)
}
7. Testing Jetpack Compose
Compose has first-class testing support via the compose-ui-test library.
Setup
// In build.gradle.kts (app)
dependencies {
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}
Writing UI Tests
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import org.junit.Rule
import org.junit.Test
class TaskScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun taskList_displaysItems() {
// Arrange
val tasks = listOf(
Task(1, "Buy groceries"),
Task(2, "Read a book")
)
// Act
composeTestRule.setContent {
MyAppTheme {
TaskList(tasks = tasks, onToggle = {}, onDelete = {})
}
}
// Assert
composeTestRule.onNodeWithText("Buy groceries").assertIsDisplayed()
composeTestRule.onNodeWithText("Read a book").assertIsDisplayed()
}
@Test
fun addTask_dialog_opensOnFabClick() {
composeTestRule.setContent {
MyAppTheme { TaskScreen() }
}
// Click the FAB
composeTestRule.onNodeWithContentDescription("Add Task").performClick()
// Dialog should appear
composeTestRule.onNodeWithText("New Task").assertIsDisplayed()
}
@Test
fun task_togglesCompletionOnCheckboxClick() {
var toggled = false
composeTestRule.setContent {
MyAppTheme {
TaskItem(
task = Task(1, "Test Task"),
onToggle = { toggled = true },
onDelete = {}
)
}
}
composeTestRule.onNodeWithText("Test Task").performClick()
assert(toggled)
}
}
Testing with ViewModel
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import org.junit.Rule
import org.junit.Test
class TaskViewModelTest {
private val viewModel = TaskViewModel()
@Test
fun addTask_increasesTaskCount() {
val initialCount = viewModel.tasks.value.size
viewModel.addTask("New task")
assert(viewModel.tasks.value.size == initialCount + 1)
}
@Test
fun toggleTask_flipsCompletionStatus() {
val taskId = viewModel.tasks.value.first().id
val initialStatus = viewModel.tasks.value.first().isCompleted
viewModel.toggleTask(taskId)
assert(viewModel.tasks.value.first { it.id == taskId }.isCompleted == !initialStatus)
}
@Test
fun deleteTask_removesFromList() {
val taskId = viewModel.tasks.value.first().id
viewModel.deleteTask(taskId)
assert(viewModel.tasks.value.none { it.id == taskId })
}
}
Useful Test APIs
| API | Purpose |
|---|---|
onNodeWithText("...") |
Find node by displayed text |
onNodeWithContentDescription("...") |
Find by accessibility label |
onNodeWithTag("...") |
Find by testTag modifier |
assertIsDisplayed() |
Verify visibility |
assertIsEnabled() |
Verify interactivity |
performClick() |
Simulate a tap |
performTextInput("...") |
Type into a text field |
waitUntil { } |
Wait for async condition |
Summary
Here's a quick recap of the Jetpack Compose journey covered in this guide:
- Getting Started: Compose is a declarative, Kotlin-first UI framework that replaces XML layouts
- Setup: Configure your project with the Compose BOM for version-safe dependencies
- **Basic Structure**: Everything is a `@Composable` function; `setContent {}` bridges your Activity into Compose
- Key Concepts: State, recomposition, layouts (
Column,Row,Box), Modifiers,LazyColumn, state hoisting, and side effects - Sample App: Combined ViewModel,
StateFlow, navigation, and UI composables into a working Task Manager - Theming: Material 3 provides a comprehensive color, typography, and shape system; animations are built-in
- Testing: Compose's
createComposeRuleenables fast, reliable UI tests without a device emulator
Jetpack Compose is now the recommended way to build Android UI. Its concise syntax, powerful tooling, and seamless integration with Kotlin coroutines and Flow make it an excellent foundation for modern Android development.
Happy composing! 🚀
Read next
Comments (0)
Join the conversation
Sign in to leave a comment on this post.
No comments yet. to be the first!