Jetpack Compose: A Comprehensive Guide for Modern Android Development
android jetpack compose ui development

Jetpack Compose: A Comprehensive Guide for Modern Android Development

D. Rout

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 createComposeRule enables 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! 🚀

Share

Comments (0)

Join the conversation

Sign in to leave a comment on this post.

No comments yet. to be the first!