Back to blogAndroid Development

Mastering Jetpack Compose: Building Modern Android UIs

ADAnthony Dodson
December 15, 2024 6 min read
Mastering Jetpack Compose: Building Modern Android UIs

Jetpack Compose represents the future of Android UI development. This modern toolkit simplifies and accelerates UI development with less code, powerful tools, and intuitive Kotlin APIs. In this comprehensive guide, we'll explore how to master Jetpack Compose and build stunning, responsive Android applications.

Why Jetpack Compose?

Jetpack Compose is Android's recommended modern toolkit for building native UI. It simplifies and significantly accelerates UI development on Android with less code, powerful tools, and intuitive Kotlin APIs.

Key Benefits of Jetpack Compose

  • Declarative UI: Describe what your UI should look like, not how to construct it
  • Less Code: Do more with less code and avoid entire classes of bugs
  • Intuitive: Just describe your UI, and Compose takes care of the rest
  • Accelerates Development: Compatible with all your existing code
  • Powerful: Create beautiful apps with direct access to Android platform APIs

Getting Started with Compose

Let's start by setting up a new Compose project and creating our first composable function.

Setting Up Your Project

First, ensure your app's minimum API level is set to 21 or higher and add Compose dependencies to your module's build.gradle file:

android {
    compileSdk = 34

    defaultConfig {
        minSdk = 21
    }

    buildFeatures {
        compose = true
    }

    composeOptions {
        kotlinCompilerExtensionVersion = "1.5.8"
    }
}

dependencies {
    // Compose BOM - manages all Compose library versions
    implementation platform("androidx.compose:compose-bom:2024.02.00")
    implementation "androidx.compose.ui:ui"
    implementation "androidx.compose.ui:ui-tooling-preview"
    implementation "androidx.compose.material3:material3"

    // Optional - for debugging
    debugImplementation "androidx.compose.ui:ui-tooling"
    debugImplementation "androidx.compose.ui:ui-test-manifest"
}

Your First Composable

Composable functions are the basic building blocks of Compose. They're functions annotated with @Composable that can call other composable functions.

import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.tooling.preview.Preview

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Text(
        text = "Hello, $name!",
        modifier = modifier,
        style = MaterialTheme.typography.headlineLarge
    )
}

@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
    MyAppTheme {
        Greeting("Android Developer")
    }
}

Core Concepts in Jetpack Compose

1. State Management

State in Compose is any value that can change over time. Compose provides several ways to manage state:

@Composable
fun CounterScreen() {
    // remember preserves state across recompositions
    var count by remember { mutableStateOf(0) }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text(
            text = "Count: $count",
            style = MaterialTheme.typography.headlineMedium
        )

        Spacer(modifier = Modifier.height(16.dp))

        Row(
            horizontalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            Button(onClick = { count-- }) {
                Text("Decrease")
            }
            Button(onClick = { count++ }) {
                Text("Increase")
            }
        }
    }
}

State Hoisting Best Practice

Move state up to the nearest common ancestor of components that need it. This makes your composables more reusable and testable.

2. Layouts in Compose

Compose provides several layout composables to help you arrange elements on screen:

Layout Purpose Use Case
Column Arranges items vertically Vertical lists, forms
Row Arranges items horizontally Horizontal buttons, icons
Box Stacks items on top of each other Overlapping UI elements
LazyColumn/LazyRow Efficient scrolling lists Long lists with many items
ConstraintLayout Complex layouts with constraints Complex UI arrangements

3. Building a Complex UI

Let's build a more complex example - a task management app UI:

data class Task(
    val id: Int,
    val title: String,
    val completed: Boolean = false
)

@Composable
fun TaskListScreen() {
    var tasks by remember {
        mutableStateOf(
            listOf(
                Task(1, "Complete Compose tutorial"),
                Task(2, "Build amazing Android app"),
                Task(3, "Publish to Play Store")
            )
        )
    }
    var newTaskText by remember { mutableStateOf("") }

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("My Tasks") },
                colors = TopAppBarDefaults.topAppBarColors(
                    containerColor = MaterialTheme.colorScheme.primaryContainer
                )
            )
        },
        floatingActionButton = {
            if (newTaskText.isNotEmpty()) {
                ExtendedFloatingActionButton(
                    onClick = {
                        tasks = tasks + Task(
                            id = tasks.size + 1,
                            title = newTaskText
                        )
                        newTaskText = ""
                    },
                    icon = { Icon(Icons.Default.Add, "Add task") },
                    text = { Text("Add Task") }
                )
            }
        }
    ) { paddingValues ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues)
        ) {
            // Task input field
            OutlinedTextField(
                value = newTaskText,
                onValueChange = { newTaskText = it },
                label = { Text("New task") },
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(16.dp),
                singleLine = true,
                keyboardOptions = KeyboardOptions(
                    imeAction = ImeAction.Done
                ),
                keyboardActions = KeyboardActions(
                    onDone = {
                        if (newTaskText.isNotEmpty()) {
                            tasks = tasks + Task(
                                id = tasks.size + 1,
                                title = newTaskText
                            )
                            newTaskText = ""
                        }
                    }
                )
            )

            // Task list
            LazyColumn(
                modifier = Modifier.fillMaxSize(),
                contentPadding = PaddingValues(horizontal = 16.dp),
                verticalArrangement = Arrangement.spacedBy(8.dp)
            ) {
                items(tasks) { task ->
                    TaskItem(
                        task = task,
                        onToggle = {
                            tasks = tasks.map {
                                if (it.id == task.id) {
                                    it.copy(completed = !it.completed)
                                } else it
                            }
                        }
                    )
                }
            }
        }
    }
}

@Composable
fun TaskItem(
    task: Task,
    onToggle: () -> Unit
) {
    Card(
        modifier = Modifier.fillMaxWidth(),
        colors = CardDefaults.cardColors(
            containerColor = if (task.completed) {
                MaterialTheme.colorScheme.surfaceVariant
            } else {
                MaterialTheme.colorScheme.surface
            }
        )
    ) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Checkbox(
                checked = task.completed,
                onCheckedChange = { onToggle() }
            )
            Spacer(modifier = Modifier.width(8.dp))
            Text(
                text = task.title,
                style = MaterialTheme.typography.bodyLarge,
                textDecoration = if (task.completed) {
                    TextDecoration.LineThrough
                } else null
            )
        }
    }
}

Advanced Compose Techniques

1. Custom Composables and Modifiers

Create reusable components and custom modifiers for consistent UI patterns:

// Custom modifier extension
fun Modifier.shimmerEffect(): Modifier = composed {
    var size by remember { mutableStateOf(IntSize.Zero) }
    val transition = rememberInfiniteTransition()
    val startOffsetX by transition.animateFloat(
        initialValue = -2 * size.width.toFloat(),
        targetValue = 2 * size.width.toFloat(),
        animationSpec = infiniteRepeatable(
            animation = tween(1000)
        )
    )

    background(
        brush = Brush.linearGradient(
            colors = listOf(
                Color(0xFFB8B5B5),
                Color(0xFF8F8B8B),
                Color(0xFFB8B5B5),
            ),
            start = Offset(startOffsetX, 0f),
            end = Offset(startOffsetX + size.width.toFloat(), size.height.toFloat())
        )
    ).onGloballyPositioned {
        size = it.size
    }
}

// Custom composable with animation
@Composable
fun AnimatedButton(
    text: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    var pressed by remember { mutableStateOf(false) }
    val scale by animateFloatAsState(
        targetValue = if (pressed) 0.95f else 1f,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioMediumBouncy,
            stiffness = Spring.StiffnessLow
        )
    )

    Button(
        onClick = onClick,
        modifier = modifier
            .scale(scale)
            .pointerInput(Unit) {
                detectTapGestures(
                    onPress = {
                        pressed = true
                        tryAwaitRelease()
                        pressed = false
                    }
                )
            }
    ) {
        Text(text)
    }
}

2. Navigation in Compose

Implement navigation between screens using Navigation Compose:

import androidx.navigation.compose.*

@Composable
fun AppNavigation() {
    val navController = rememberNavController()

    NavHost(
        navController = navController,
        startDestination = "home"
    ) {
        composable("home") {
            HomeScreen(
                onNavigateToDetail = { itemId ->
                    navController.navigate("detail/$itemId")
                }
            )
        }

        composable(
            "detail/{itemId}",
            arguments = listOf(
                navArgument("itemId") { type = NavType.IntType }
            )
        ) { backStackEntry ->
            val itemId = backStackEntry.arguments?.getInt("itemId") ?: 0
            DetailScreen(
                itemId = itemId,
                onNavigateBack = {
                    navController.popBackStack()
                }
            )
        }
    }
}

3. Side Effects in Compose

Handle side effects properly using Compose's effect handlers:

Common Side Effect APIs

  • LaunchedEffect: Run suspend functions in response to composition
  • rememberCoroutineScope: Obtain a coroutine scope to launch coroutines
  • DisposableEffect: Effects that require cleanup
  • SideEffect: Publish Compose state to non-compose code
@Composable
fun TimerScreen() {
    var seconds by remember { mutableStateOf(0) }
    var isRunning by remember { mutableStateOf(false) }

    // LaunchedEffect for timer logic
    LaunchedEffect(isRunning) {
        while (isRunning) {
            delay(1000)
            seconds++
        }
    }

    // DisposableEffect for cleanup
    DisposableEffect(Unit) {
        onDispose {
            // Cleanup code here
            isRunning = false
        }
    }

    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text(
            text = "Time: ${seconds}s",
            style = MaterialTheme.typography.headlineLarge
        )

        Spacer(modifier = Modifier.height(32.dp))

        Row(
            horizontalArrangement = Arrangement.spacedBy(16.dp)
        ) {
            Button(
                onClick = { isRunning = !isRunning }
            ) {
                Text(if (isRunning) "Pause" else "Start")
            }

            OutlinedButton(
                onClick = {
                    isRunning = false
                    seconds = 0
                }
            ) {
                Text("Reset")
            }
        }
    }
}

Performance Optimization

Performance Best Practices

Follow these guidelines to ensure your Compose apps perform optimally:

1. Minimize Recomposition

  • Use remember to cache expensive computations
  • Make composables skippable by using stable parameters
  • Use derivedStateOf for computed values
  • Avoid reading state in phases where it's not needed

2. Optimize Lists

// Use keys for list items to help Compose track changes
LazyColumn {
    items(
        items = taskList,
        key = { task -> task.id } // Stable key
    ) { task ->
        TaskItem(task = task)
    }
}

// Use LazyColumn instead of Column for long lists
// Bad: Creates all items at once
Column {
    tasks.forEach { task ->
        TaskItem(task)
    }
}

// Good: Only creates visible items
LazyColumn {
    items(tasks) { task ->
        TaskItem(task)
    }
}

Testing Compose UI

Compose provides excellent testing support with semantic properties and test rules:

import androidx.compose.ui.test.*

@Test
fun testCounterScreen() {
    composeTestRule.setContent {
        CounterScreen()
    }

    // Find nodes and perform actions
    composeTestRule
        .onNodeWithText("Count: 0")
        .assertIsDisplayed()

    composeTestRule
        .onNodeWithText("Increase")
        .performClick()

    composeTestRule
        .onNodeWithText("Count: 1")
        .assertIsDisplayed()
}

Migration Strategy

If you're migrating an existing app to Compose, follow this strategy:

Gradual Migration Approach

  1. Start Small: Begin with simple, isolated screens
  2. Use ComposeView: Embed Compose in existing layouts
  3. Bottom-Up: Convert leaf components first
  4. Feature Flags: Toggle between old and new implementations
  5. Test Thoroughly: Ensure parity with existing functionality

Conclusion

Jetpack Compose represents a paradigm shift in Android UI development. Its declarative nature, combined with the power of Kotlin, makes building UIs faster, more intuitive, and less error-prone. As you continue your Compose journey, remember that the key to mastery is practice and experimentation.

Start small, build incrementally, and don't be afraid to refactor as you learn better patterns. The Android development community is actively sharing knowledge and best practices, so stay connected and keep learning!

Next Steps

Ready to dive deeper? Check out the official Compose documentation, explore the sample apps, and start building your next Android app with Jetpack Compose!

Have a project in mind?

Let's turn your idea into a mobile product people love.

Start your project