17 Minutes
Jetpack Compose Tutorial for Android Developers: Build Modern UIs Faster
Fix Bugs Faster! Log Collection Made Easy
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.
Table of Contents
- How Jetpack Compose works
- How Jetpack Compose benefits developers
- Some basic examples of Jetpack Compose at work
- What a Jetpack Compose function actually looks like
- How layout & view
- Button
- Using intent and intent filters
- Using Toast & RecyclerView
- Advanced Layout Options: LazyGrid Layouts and Motion Layouts
- How do we make reusable components in Jetpack Compose?
- Now, how do we use modifiers in Jetpack Compose?
- How to prevent errors and deal with edge cases in Jetpack Compose
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:
LazyColumnfor lists.Modifierfor styling, padding, and gestures.ScrollableColumnfor 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
ComposeViewto embed a composable inside an XML layout. - We can use
AndroidViewto 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.
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:
| Category | LazyGrid Layouts | MotionLayout |
|---|---|---|
| Primary Focus | Efficiently displaying many items in a grid or list. | Creating complex animations and transitions between layout states. |
| Performance Optimization | Uses lazy loading — only visible items are composed. | Uses constraint-based animations — calculates movement between states. |
| Scrolling Behavior | Built-in scrolling (vertical or horizontal). | No inherent scrolling — typically used within a static screen. |
| Animation Support | Limited 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:
LazyVerticalGridfor vertically scrolling grids.LazyHorizontalGridfor 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