Skip to content
Jetpack Compose Tutorial for Android Developers: Build Modern UIs Faster

17 Minutes

Jetpack Compose Tutorial for Android Developers: Build Modern UIs Faster

Fix Bugs Faster! Log Collection Made Easy

Get started

How Jetpack Compose works

Jetpack Compose is Android’s modern toolkit for building native UI using Kotlin. Instead of the traditional XML-based layouts, Compose uses declarative programming, allowing developers to describe how the UI should look based on the current app state.

In essence, Compose re-renders the UI automatically when the data changes — eliminating the need to manually sync layouts and logic. It manages UI state efficiently through composable functions, which are lightweight and reusable components.

In this Jetpack Compose tutorial, we’ll show you all the good stuff about Compose: how you can access it from Android Jetpack and integrate it seamlessly with existing apps, enabling gradual migration from XML to Compose-based interfaces without major rewrites.

How Jetpack Compose benefits developers

Before we get into the thick of our Jetpack Compose tutorial, we’ll look at the benefits of Compose… specifically, how it simplifies and speeds up Android UI development. Here are the key benefits:

  • Less boilerplate: With a declarative syntax, you simply describe the outcome you want, and the framework does the rest. Which means a lot less code.
  • Faster development: Android Studio lets you preview in real time.
  • Reusability: You can create modular composables and reuse them across screens.
  • Powerful theming: The Material theme system is great for clarity and consistency.
  • Integration-friendly: Jetpack Compose works with existing Views and Jetpack libraries.

At its simplest, Compose’s state-driven design lets you focus on “what to show,” not “how to update.” This results in cleaner, more predictable UI behavior. As developers, we can experiment rapidly, preview changes instantly, and deliver smoother user experiences faster.

How it solves everyday challenges

Before Jetpack Compose, developers faced common painpoints like:

  • Managing complex view hierarchies.
  • Writing excessive XML and Kotlin code for simple UI updates.
  • Handling configuration changes and UI state manually.

Jetpack Compose solves these challenges through:

  • Declarative UI updates: The UI automatically reacts to state changes.
  • Lifecycle awareness: Composables are lifecycle-aware by default.
  • Simplified testing: UI testing is easier with ComposeTestRule.
  • Improved consistency: Styling and theming are centralized.

This modern approach reduces bugs, improves scalability, and makes maintaining Android UIs far more efficient than with traditional view-based systems.

That’s enough background. Now let’s get into the heart of our Jetpack Compose tutorial, starting from the most simple.

Some basic examples of Jetpack Compose at work

Here’s how simple it is to build a basic UI with Jetpack Compose:

@Composable
fun Greeting(name: String) {
    Text(text = "Hello, $name!")
}

You can preview it instantly:

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

To create a layout with multiple elements.

@Composable
fun GreetingCard() {
    Column {
        Text("Welcome to Jetpack Compose")
        Button(onClick = { /* TODO */ }) {
            Text("Click Me")
        }
    }
}

Each @Composable function is reusable, testable, and can be previewed independently.


What a Jetpack Compose function actually looks like

A Composable function is the fundamental building block of Jetpack Compose, allowing us to define and build our UI in a declarative way. It’s annotated with @Composable and describes part of the UI.

Note that composables can call other composables, forming a UI tree similar to the View hierarchy — but far more flexible.

Animation

Jetpack Compose provides built-in animation APIs that make transitions fluid and natural. Here’s an example:

@Composable
fun AnimatedBox() {
    var expanded by remember { mutableStateOf(false) }
    val size by animateDpAsState(if (expanded) 200.dp else 100.dp)

    Box(
        Modifier
            .size(size)
            .background(Color.Red)
            .clickable { expanded = !expanded }
    )
}

This animation automatically adjusts the box size with smooth motion whenever the user taps it.

Layouts

We can organize elements extremely efficiently in Jetpack Compose, using layouts like Column, Row, and Box.

@Composable
fun ProfileLayout() {
    Row(verticalAlignment = Alignment.CenterVertically) {
        Image(painterResource(R.drawable.profile), contentDescription = "Profile")
        Column {
            Text("John Doe", fontWeight = FontWeight.Bold)
            Text("Android Developer")
        }
    }
}

Layouts are flexible, composable, and adaptive to screen size changes.

Foundation

The Foundation library in Jetpack Compose provides the core building blocks of our UI, including text, images, scrolling, and touch handling.

Examples include:

  • LazyColumn for lists.
  • Modifier for styling, padding, and gestures.
  • ScrollableColumn for vertical scrolling.
@Composable
fun ItemList(items: List<String>) {
    LazyColumn {
        items(items) { item ->
            Text(text = item, Modifier.padding(8.dp))
        }
    }
}

Material

Jetpack Compose implements the Material Design system, a design framework created by Google for building visually consistent, user-friendly, and accessible interfaces. Here’s some code to illustrate what this looks like:

@Composable
fun MaterialExample() {
    MaterialTheme {
        Surface(color = MaterialTheme.colorScheme.primary) {
            Text(
                text = "Material Design with Jetpack Compose",
                color = Color.White,
                modifier = Modifier.padding(16.dp)
            )
        }
    }
}

Material components like Button, Card, and Snackbar follow Google’s design guidelines by default.


How layout & view

As we get deeper into our Jetpack Compose tutorial, it’s important to note that Compose doesn’t completely replace Views — it complements them. Developers can mix Compose and Views within the same project.

  • We can use ComposeView to embed a composable inside an XML layout.
  • We can use AndroidView to include a traditional View (like a MapView) inside a composable.

Example:

@Composable
fun MixedUI() {
    AndroidView(factory = { context ->
        TextView(context).apply { text = "Old View inside Compose" }
    })
}

This hybrid approach allows gradual migration and backward compatibility with legacy codebases.


Button

Buttons are fundamental interactive components in all aspects of programming. In Jetpack Compose, they’re also part of the Material Design system, and are implemented as composable functions.

@Composable
fun SimpleButton() {
    Button(onClick = { Log.d("Compose", "Button clicked") }) {
        Text("Click Me")
    }
}

Types of Buttons available include:

  • Button — Standard material button.
  • OutlinedButton — Button with a border.
  • TextButton — Text-only button.
  • IconButton — Displays only an icon.

You can style buttons easily using modifiers, shapes, and colors:

Button(
    onClick = {},
    colors = ButtonDefaults.buttonColors(containerColor = Color.Blue),
    shape = RoundedCornerShape(8.dp)
) {
    Text("Styled Button", color = Color.White)
}

Learn more about buttons in our in-depth article about Jetpack buttons.


Using intent and intent filters

Even with Jetpack Compose, Android fundamentals like Intents remain essential for navigation and interactivity.

As an example, let’s look at what happens when we launch a new activity:

@Composable
fun OpenActivityButton(context: Context) {
    Button(onClick = {
        val intent = Intent(context, SecondActivity::class.java)
        context.startActivity(intent)
    }) {
        Text("Open Activity")
    }
}

Intent filters in the manifest still define how your app responds to system actions (like sharing or opening a URL). Compose integrates seamlessly with these traditional mechanisms — the underlying Android framework remains the same.


Using Toast & RecyclerView

While Compose offers composables like Snackbar and LazyColumn, you can still use familiar Android components such as Toast and RecyclerView when needed.

Example — showing a Toast in Compose:

@Composable
fun ToastExample(context: Context) {
    Button(onClick = {
        Toast.makeText(context, "Hello from Jetpack Compose!", Toast.LENGTH_SHORT).show()
    }) {
        Text("Show Toast")
    }
}

If your app still uses RecyclerView, it can coexist with Compose:

@Composable
fun RecyclerInteropView() {
    AndroidView(factory = { context ->
        RecyclerView(context).apply {
            adapter = MyAdapter()
        }
    })
}

However, for pure Compose apps, LazyColumn and LazyRow offer modern replacements for RecyclerView. In fact, let’s get into that right now:

Advanced Layout Options: LazyGrid Layouts and Motion Layouts

Two of the most powerful layout tools in Compose are LazyGrid layouts and MotionLayout. Together, they allow developers to build efficient, scrollable grids and smooth, animated user experiences — all using declarative Kotlin code.

First, let’s look at the key differences between these two layout options:

CategoryLazyGrid LayoutsMotionLayout
Primary FocusEfficiently displaying many items in a grid or list.Creating complex animations and transitions between layout states.
Performance OptimizationUses lazy loading — only visible items are composed.Uses constraint-based animations — calculates movement between states.
Scrolling BehaviorBuilt-in scrolling (vertical or horizontal).No inherent scrolling — typically used within a static screen.
Animation SupportLimited to per-item animations (fade-in, scale, etc.).Built for advanced motion (e.g., dragging, coordinated animations).
Use Case Examples– Image gallery

How to use LazyGrid layouts

In any Jetpack Compose tutorial, you’ll learn that “lazy” components like LazyColumn or LazyRow render only visible items, which improves performance.

LazyGrid layouts extend this concept to grids, allowing you to create responsive and memory-efficient displays for large sets of data.

Jetpack Compose provides two main grid types:

  • LazyVerticalGrid for vertically scrolling grids.
  • LazyHorizontalGrid for horizontally scrolling grids.

As an exampe, let’s look at the LazyVerticalGrid.

@Composable
fun PhotoGrid() {
    val photos = (1..50).map { "Photo $it" }

    LazyVerticalGrid(
        columns = GridCells.Adaptive(minSize = 100.dp),
        modifier = Modifier.fillMaxSize(),
        contentPadding = PaddingValues(8.dp)
    ) {
        items(photos.size) { index ->
            Box(
                modifier = Modifier
                    .aspectRatio(1f)
                    .padding(4.dp)
                    .background(Color.LightGray),
                contentAlignment = Alignment.Center
            ) {
                Text(text = photos[index])
            }
        }
    }
}

Why this is powerful:

  • Only visible items are composed (great for large datasets).
  • Adaptive columns adjust to screen width automatically.
  • It’s perfect for galleries, dashboards, or catalogs.

For horizontally scrolling grids, simply replace LazyVerticalGrid with LazyHorizontalGrid and define the number of rows using GridCells.Fixed().


How to use motion layouts

MotionLayout brings constraint-based animation and motion to Jetpack Compose. It allows you to define start and end layout states, then animate between them smoothly based on a progress value.

To use MotionLayout in Compose, add this dependency:

implementation("androidx.constraintlayout:constraintlayout-compose:1.0.1")

ExaAs an example, we can look at simple MotionLayout Animation.

@Composable
fun AnimatedBox() {
    val progress = remember { Animatable(0f) }

    MotionLayout(
        start = ConstraintSet {
            val box = createRefFor("box")
            constrain(box) {
                top.linkTo(parent.top)
                start.linkTo(parent.start)
            }
        },
        end = ConstraintSet {
            val box = createRefFor("box")
            constrain(box) {
                bottom.linkTo(parent.bottom)
                end.linkTo(parent.end)
            }
        },
        progress = progress.value
    ) {
        Box(
            modifier = Modifier
                .layoutId("box")
                .size(100.dp)
                .background(Color.Red)
        )
    }
}

This example moves a red box from the top-left to the bottom-right as progress changes.

You can animate the progress using a coroutine or gesture detection, creating interactive motion effects like expanding cards, collapsing toolbars, or onboarding animations.

Pro Tip: Combine MotionLayout with Compose state management to create seamless, reactive animations tied to user interactions.


How do we make reusable components in Jetpack Compose?

This is arguably the most significant part of our Jetpack Compose tutorial.

Reusable components are arguably the biggest single USP of Jetpack Compose. Every @Composable function can be turned into a reusable building block that encapsulates UI logic, design, and behavior. This carries the following benefits:

  • Consistency: Uniform design across screens.
  • Scalability: Easier to maintain large codebases.
  • Flexibility: Parameters make components adaptable to different use cases.

We can also combine smaller reusable composables into larger UI sections like cards, toolbars, or entire form layouts.

It’s super-simple to create a reusable component. Here’s some code to show you:

@Composable
fun AppButton(
    text: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    Button(
        onClick = onClick,
        modifier = modifier
            .fillMaxWidth()
            .padding(8.dp)
    ) {
        Text(text)
    }
}

You can now use this same button across multiple screens:

AppButton("Login", onClick = { /* Handle login */ })
AppButton("Sign Up", onClick = { /* Handle signup */ })

Now, how do we use modifiers in Jetpack Compose?

Like reusable components, modifiers are a crucial part of the Jetpack Compose experience and a game-changer for developers. They allow us to change the appearance, layout, and behavior of composable elements without altering their structure.

Modifiers are chained together using a functional style, making them both readable and flexible. Now let’s look at that in detail.


Row

Modifiers in a Row control how elements are placed horizontally.

@Composable
fun RowExample() {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .background(Color.LightGray)
            .padding(8.dp),
        horizontalArrangement = Arrangement.SpaceBetween
    ) {
        Text("Left")
        Text("Right")
    }
}

Here, Modifier.fillMaxWidth() expands the row to the full width, and Arrangement.SpaceBetween distributes its children evenly.


Column

In a Column, modifiers adjust vertical placement and spacing.

@Composable
fun ColumnExample() {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        Text("Header")
        Text("Body")
        Text("Footer")
    }
}

You can chain multiple modifiers like padding, borders, or clickable actions in one declaration.


Scoped

Scoped modifiers are used within specific composable contexts, like BoxScope, RowScope, or ColumnScope. They provide extra alignment and positioning options that apply only inside that scope.

Example — aligning content in a Box:

@Composable
fun ScopedExample() {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.Gray)
    ) {
        Text(
            text = "Bottom Right",
            modifier = Modifier.align(Alignment.BottomEnd)
        )
    }
}

Scoped modifiers make complex layouts easier to build, giving precise control over how children are placed relative to their parent.


How to prevent errors and deal with edge cases in Jetpack Compose

Ok, so we’ve gone through the bulk of the coding now. But it’s important to also look at the potential pitfalls, because even advanced Jetpack Compose developers encounter tricky edge cases.

Preventing errors requires a strong grasp of state management, recomposition behavior, and lifecycle awareness. Here are key strategies to ensure your UI remains stable and bug-free:

1. Manage state properly

Always use state hoisting and remember to manage local state efficiently. Avoid placing mutable states in child composables unnecessarily.

Example:

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }
    Button(onClick = { count++ }) {
        Text("Count: $count")
    }
}

Avoid redeclaring remember inside recomposing areas — it may reset your state unexpectedly.


2. Handle null and empty data safely

Before displaying dynamic data, be sure to always check for nulls or empty lists:

if (items.isNullOrEmpty()) {
    Text("No data available.")
} else {
    LazyColumn { items(items) { Text(it) } }
}

This prevents crashes during recomposition when API data hasn’t loaded yet.


3. Avoid unnecessary recompositions

Use remember and derivedStateOf to prevent expensive recompositions:

val filteredList by remember(items) {
    derivedStateOf { items.filter { it.isActive } }
}

This ensures Compose only recomposes when items actually change.


4. Handle side effects with care

Use effect handlers like LaunchedEffect, DisposableEffect and rememberCoroutineScope() for controlled side effects.

LaunchedEffect(Unit) {
    // Run once when the composable enters the composition
    viewModel.loadData()
}

Avoid launching coroutines directly in composables without proper scoping — this can cause memory leaks.


5. Test your composables in isolation

Use @Preview for quick UI checks and Compose UI Tests to validate behavior:

@Preview(showBackground = true)
@Composable
fun AppButtonPreview() {
    AppButton("Click Me", onClick = {})
}

Previewing helps catch layout issues early before runtime.


6. Be Mindful of Configuration Changes

Compose handles recompositions gracefully, but you should still keep data in ViewModelor rememberSaveable when the UI needs to survive rotations or process deaths.

Example:

var text by rememberSaveable { mutableStateOf("") }

Final Thoughts

Jetpack Compose represents a key pillar of futue Android UI development. It simplifies design, accelerates iteration, and aligns with modern Kotlin patterns. By using composable functions, developers can build elegant, efficient, and reactive interfaces with minimal effort.

Whether you’re upgrading existing XML layouts or starting a new project, Compose provides the flexibility to integrate seamlessly while future-proofing your Android app development workflow. Mastering Jetpack Compose means writing less code, achieving better performance, and delivering richer user experiences — all with the power of Kotlin and the latest Android tools.

In this Jetpack Compose tutorial, we’ve hopefully given you the building blocks of how to weave Compose into your developer’s toolkit. However, if you still have questions, don’t hesitate to reach out: we’re always happy to chat.


Expect The Unexpected!

Debug Faster With Bugfender

Start for Free
blog author

Sachin Siwal

Sachin is an accomplished iOS developer and seasoned technical manager, combining 12 years of mobile app expertise with a knack for leading and delivering innovative solutions. You can contact him on Linkedin

Join thousands of developers
and start fixing bugs faster than ever.