Jetpack Compose basics

1. Before you begin

Jetpack Compose is a modern toolkit designed to simplify UI development. It combines a reactive programming model with the conciseness and ease of use of the Kotlin programming language. It is fully declarative, meaning you describe your UI by calling a series of functions that transform data into a UI hierarchy. When the underlying data changes, the framework automatically re-execute these functions, updating the UI hierarchy for you.

A Compose app is made up of composable functions - just regular functions marked with @Composable, which can call other composable functions. A function is all you need to create a new UI component. The annotation tells Compose to add special support to the function for updating and maintaining your UI over time. Compose lets you structure your code into small chunks. Composable functions are often referred to as "composables" for short.

By making small reusable composables, it's easy to build up a library of UI elements used in your app. Each one is responsible for one part of the screen and can be edited independently.

Prerequisites

  • Experience with Kotlin syntax, including lambdas

What you'll do

In this codelab, you will learn:

  • What Compose is
  • How to build UIs with Compose
  • How to manage state in composable functions
  • How to create a performant list
  • How to add animations
  • How to style and theme an app

You'll build an app with an onboarding screen, and a list of animated expanding items:

87f2753c576d26f2.gif

What you'll need

2. Starting a new Compose project

To start a new Compose project, open Android Studio Arctic Fox and select Start a new Android Studio project as shown:

dabf04f3abbdc28a.png

If the screen above doesn't appear, go to File > New > New Project.

When creating a new project, choose Empty Compose Activity from the available templates.

a67ba73a4f06b7ac.png

Click Next and configure your project as usual, calling it "Basics Codelab". Make sure you select a minimumSdkVersion of at least API level 21, which is the minimum API Compose supports.

When choosing the Empty Compose Activity template, the following code is generated for you in your project:

  • The project is already configured to use Compose.
  • The AndroidManifest.xml file is created.
  • The build.gradle and app/build.gradle files contain options and dependencies needed for Compose.

After syncing the project, open MainActivity.kt and check out the code.

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            BasicsCodelabTheme {
                // A surface container using the 'background' color from the theme
                Surface(color = MaterialTheme.colors.background) {
                    Greeting("Android")
                }
            }
        }
    }
}

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

@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
    BasicsCodelabTheme {
        Greeting("Android")
    }
}

In the next section, you'll see what each method does, and how you can improve them to create flexible and reusable layouts.

Solution to the codelab

You can get the code for the solution of this codelab from GitHub:

$ git clone https://github.com/googlecodelabs/android-compose-codelabs

Alternatively you can download the repository as a Zip file:

Download Zip

You'll find the solution code in the BasicsCodelab project. We recommend that you follow the codelab step-by-step at your own pace and check the solution if you consider it necessary. During the codelab, you'll be presented with snippets of code that you'll need to add to the project.

3. Getting started with Compose

Go through the different classes and methods related to Compose that Android Studio has generated for you.

Composable functions

A composable function is a regular function annotated with @Composable. This enables your function to call other @Composable functions within it. You can see how the Greeting function is marked as @Composable. This function will produce a piece of UI hierarchy displaying the given input, String. Text is a composable function provided by the library.

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

Compose in an Android app

With Compose, Activities remain the entry point to an Android app. In our project, MainActivity is launched when the user opens the app (as it's specified in the AndroidManifest.xml file). You use setContent to define your layout, but instead of using an XML file as you'd do in the traditional View system, you call Composable functions within it.

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            BasicsCodelabTheme {
                // A surface container using the 'background' color from the theme
                Surface(color = MaterialTheme.colors.background) {
                    Greeting("Android")
                }
            }
        }
    }
}

BasicsCodelabTheme is a way to style Composable functions. You'll see more about this in the Theming your app section. To see how the text displays on the screen, you can either run the app in an emulator or device, or use the Android Studio preview.

To use the Android Studio preview, you just have to mark any parameterless Composable function or functions with default parameters with the @Preview annotation and build your project. You can already see a Preview Composable function in the MainActivity.kt file. You can have multiple previews in the same file and give them names.

@Preview(showBackground = true, name = "Text preview")
@Composable
fun DefaultPreview() {
    BasicsCodelabTheme {
        Greeting(name = "Android")
    }
}

88d6e7a2cfc33ed9.png

The preview might not appear if Code bcf00530a220eea9.png is selected. Click Split aadde7eea0921d0f.png to see the preview.

4. Tweaking the UI

Let's start by setting a different background color for the Greeting. You can do this by wrapping the Text composable with a Surface. Surface takes a color, so use MaterialTheme.colors.primary.

@Composable
private fun Greeting(name: String) {
    Surface(color = MaterialTheme.colors.primary) {
        Text (text = "Hello $name!")
    }
}

The components nested inside Surface will be drawn on top of that background color.

When you add that code to the project, you will see a Build & Refresh button in the top-right corner of Android Studio. Tap on it or build the project to see the new changes in the preview.

1886a2cbfefe7df3.png

You can see the new changes in the preview:

a6cd30458c8829a2.png

You might have missed an important detail: the text is now white. When did we define this?

You didn't! The Material components, such as androidx.compose.material.Surface, are built to make your experience better by taking care of common features that you probably want in your app, such as choosing an appropriate color for text. We say Material is opinionated because it provides good defaults and patterns that are common to most apps. The Material components in Compose are built on top of other foundational components (in androidx.compose.foundation), which are also accessible from your app components in case you need more flexibility.

In this case, Surface understands that, when the background is set to the primary color, any text on top of it should use the onPrimary color, which is also defined in the theme. You can learn more about this in the Theming your app section.

Modifiers

Most Compose UI elements such as Surface and Text accept an optional modifier parameter. Modifiers tell a UI element how to lay out, display, or behave within its parent layout.

For example, the padding modifier will apply an amount of space around the element it decorates. You can create a padding modifier with Modifier.padding().

Now, add padding to your Text on the screen:

import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
...

@Composable
private fun Greeting(name: String) {
    Surface(color = MaterialTheme.colors.primary) {
        Text(text = "Hello $name!", modifier = Modifier.padding(24.dp))
    }
}

Click Build & Refresh to see the new changes.

52d5ed8919f277f0.png

There are dozens of modifiers which can be used to align, animate, lay out, make clickable or scrollable, transform, etc. For a comprehensive list, check out the List of Compose Modifiers. You'll use some of them in the next steps.

5. Reusing composables

The more components you add to the UI, the more levels of nesting you create. This can affect readability if a function becomes really large. By making small reusable components it's easy to build up a library of UI elements used in your app. Each one is responsible for one small part of the screen and can be edited independently.

Create a Composable called MyApp that includes the greeting.

@Composable
private fun MyApp() {
    Surface(color = MaterialTheme.colors.background) {
        Greeting("Android")
    }
}

This lets you clean up the onCreate callback and the preview as you can now reuse the MyApp composable, avoiding code duplication. Your MainActivity.kt file should look like this:

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.padding
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.codelab.basicstep1.ui.theme.BasicsCodelabTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            BasicsCodelabTheme {
                MyApp()
            }
        }
    }
}

@Composable
private fun MyApp() {
    Surface(color = MaterialTheme.colors.background) {
        Greeting("Android")
    }
}

@Composable
private fun Greeting(name: String) {
    Surface(color = MaterialTheme.colors.primary) {
        Text(text = "Hello $name!", modifier = Modifier.padding(24.dp))
    }
}

@Preview(showBackground = true)
@Composable
private fun DefaultPreview() {
    BasicsCodelabTheme {
        MyApp()
    }
}

6. Creating columns and rows

The three basic standard layout elements in Compose are Column, Row and Box.

fbd450e8eab10338.png

They are Composable functions that take Composable content, so you can place items inside. For example, each child inside of a Column will be placed vertically.

// Don't copy over
Column {
    Text("First row")
    Text("Second row")
}

Now try to change Greeting so that it shows a column with two text elements like in this example:

cf26127d32021cd.png

Note that you might have to move the padding around.

Compare your result with this solution:

import androidx.compose.foundation.layout.Column
...

@Composable
private fun Greeting(name: String) {
    Surface(color = MaterialTheme.colors.primary) {
        Column(modifier = Modifier.padding(24.dp)) {
            Text(text = "Hello,")
            Text(text = name)
        }
    }
}

Compose and Kotlin

Composable functions can be used like any other function in Kotlin. This makes building UIs really powerful since you can add statements to influence how the UI will be displayed.

For example, you can use a for loop to add elements to the Column:

@Composable
fun MyApp(names: List<String> = listOf("World", "Compose")) {
    Column {
        for (name in names) {
            Greeting(name = name)
        }
    }
}

c366a44b3e99157f.png

You haven't set dimensions or added any constraints to the size of your composables yet, so each row takes the minimum space it can and the preview does the same thing. Let's change our preview to emulate a common width of a small phone, 320dp. Add a widthDp parameter to the @Preview annotation like so:

@Preview(showBackground = true, widthDp = 320)
@Composable
fun DefaultPreview() {
    BasicsCodelabTheme {
        MyApp()
    }
}

63ac84a794ec7a3d.png

Modifiers are used extensively in Compose so let's practice with a more advanced exercise: Try to replicate the following layout using the fillMaxWidth and padding modifiers.

ecd3370d03f7130e.png

Now compare your code with the solution:

@Composable
fun MyApp(names: List<String> = listOf("World", "Compose")) {
    Column(modifier = Modifier.padding(vertical = 4.dp)) {
        for (name in names) {
            Greeting(name = name)
        }
    }
}

@Composable
private fun Greeting(name: String) {
    Surface(
        color = MaterialTheme.colors.primary,
        modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        Column(modifier = Modifier.fillMaxWidth().padding(24.dp)) {
            Text(text = "Hello, ")
            Text(text = name)
        }
    }
}

Note that:

  • Modifiers can have overloads so, for example, you can specify different ways to create a padding.
  • To add multiple modifiers to an element, you simply chain them.

There are multiple ways to achieve this result, so if your code doesn't match this snippet, that doesn't mean your code is wrong. However, copy and paste this code to continue with the codelab.

Adding a button

In the next step you'll add a clickable element that expands the Greeting, so we need to add that button first. The goal is to create the following layout:

203e0a9946f313cc.png

Button is a composable provided by the material package which takes a composable as the last argument. Since trailing lambdas can be moved outside of the parentheses, you can add any content to the button as a child. For example, a Text:

// Don't copy yet
Button(
    onClick = { } // You'll learn about this callback later
) {
    Text("Show less")
}

To achieve this you need to learn how to place a composable at the end of a row. There's no alignEnd modifier so, instead, you give some weight to the composable at the start. The weight modifier makes the element fill all available space, making it flexible, effectively pushing away the other elements that don't have a weight, which are called inflexible. It also makes the fillMaxWidth modifier redundant.

Now try to add the button and place it as shown in the previous image.

Check out the solution here:

import androidx.compose.material.Button
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
...

@Composable
private fun Greeting(name: String) {

    Surface(
        color = MaterialTheme.colors.primary,
        modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        Row(modifier = Modifier.padding(24.dp)) {
            Column(modifier = Modifier.weight(1f)) {
                Text(text = "Hello, ")
                Text(text = name)
            }
            OutlinedButton(
                onClick = { /* TODO */ }
            ) {
                Text("Show less")
            }
        }
    }
}

7. State in Compose

In this section you'll add some interaction to your screen. So far you've created static layouts but now you'll make them react to user changes to achieve this:

e3914108b7082ac0.gif

Before getting into how to make a button clickable and how to resize an item, you need to store some value somewhere that indicates whether each item is expanded or not–the state of the item. Since we need to have one of these values per greeting, the logical place for it is in the Greeting composable. Take a look at this expanded boolean and how it's used in the code:

// Don't copy over
@Composable
private fun Greeting(name: String) {
    var expanded = false // Don't do this!

    Surface(
        color = MaterialTheme.colors.primary,
        modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        Row(modifier = Modifier.padding(24.dp)) {
            Column(modifier = Modifier.weight(1f)) {
                Text(text = "Hello, ")
                Text(text = name)
            }
            OutlinedButton(
                onClick = { expanded = !expanded }
            ) {
                Text(if (expanded) "Show less" else "Show more")
            }
        }
    }
}

Note that we also added an onClick action and a dynamic button text. More on that later.

However, this won't work as expected. Setting a different value for the expanded variable won't make Compose detect it as a state change so nothing will happen.

The reason why mutating this variable does not trigger recompositions is that it's not being tracked by Compose. Also, each time Greeting is called, the variable will be reset to false.

To add internal state to a composable, you can use the mutableStateOf function, which makes Compose recompose functions that read that State.

import androidx.compose.runtime.mutableStateOf
...

// Don't copy over
@Composable
fun Greeting() {
    val expanded = mutableStateOf(false) // Don't do this!
}

However you can't just assign mutableStateOf to a variable inside a composable. As explained before, recomposition can happen at any time which would call the composable again, resetting the state to a new mutable state with a value of false.

To preserve state across recompositions, remember the mutable state using remember.

import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
...

@Composable
fun Greeting() {
    val expanded = remember { mutableStateOf(false) }
    ...
}

remember is used to guard against recomposition, so the state is not reset.

Note that if you call the same composable from different parts of the screen you will create different UI elements, each with its own version of the state. You can think of internal state as a private variable in a class.

The composable function will automatically be "subscribed" to the state. If the state changes, composables that read these fields will be recomposed to display the updates.

Mutating state and reacting to state changes

In order to change the state, you might have noticed that Button has a parameter called onClick but it doesn't take a value, it takes a function.

You can define the action to take on click by assigning a lambda expression to it. For example, let's toggle the value of the expanded state, and show a different text depending on the value.

            OutlinedButton(
                onClick = { expanded.value = !expanded.value },
            ) {
                Text(if (expanded) "Show less" else "Show more")
            }

If you run the app in an emulator you can see that, when the button is clicked, expanded is toggled triggering a recomposition of the text inside the button. Each Greeting maintains its own expanded state, because they belong to different UI elements.

825dd6d6f98bff05.gif

Code up to this point:

@Composable
private fun Greeting(name: String) {
    var expanded = remember { mutableStateOf(false) } 

    Surface(
        color = MaterialTheme.colors.primary,
        modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        Row(modifier = Modifier.padding(24.dp)) {
            Column(modifier = Modifier.weight(1f)) {
                Text(text = "Hello, ")
                Text(text = name)
            }
            OutlinedButton(
                onClick = { expanded.value = !expanded.value }
            ) {
                Text(if (expanded.value) "Show less" else "Show more")
            }
        }
    }
}

Expanding the item

Now let's actually expand an item when requested. Add an additional variable that depends on our state:

@Composable
private fun Greeting(name: String) {

    val expanded = remember { mutableStateOf(false) }

    val extraPadding = if (expanded.value) 48.dp else 0.dp
...

You don't need to remember extraPadding against recomposition because it depends on a state and it's doing a simple calculation.

And now we can apply a new padding modifier to the Column:

@Composable
private fun Greeting(name: String) {

    val expanded = remember { mutableStateOf(false) }

    val extraPadding = if (expanded.value) 48.dp else 0.dp

    Surface(
        color = MaterialTheme.colors.primary,
        modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        Row(modifier = Modifier.padding(24.dp)) {
            Column(modifier = Modifier
                .weight(1f)
                .padding(bottom = extraPadding)
            ) {
                Text(text = "Hello, ")
                Text(text = name)
            }
            OutlinedButton(
                onClick = { expanded.value = !expanded.value }
            ) {
                Text(if (expanded.value) "Show less" else "Show more")
            }
        }
    }
}

If you run on an emulator, you should see that each item can be expanded independently:

e3914108b7082ac0.gif

8. State hoisting

In Composable functions, state that is read or modified by multiple functions should live in a common ancestor—this process is called state hoisting. To hoist means to lift or elevate.

Making state hoistable avoids duplicating state and introducing bugs, helps reuse composables, and makes composables substantially easier to test. Contrarily, state that doesn't need to be controlled by a composable's parent should not be hoisted. The source of truth belongs to whoever creates and controls that state.

For example, let's create an onboarding screen for our app.

8c0da5d9a631ba97.png

Add the following code to MainActivity.kt:

import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment

...

@Composable
fun OnboardingScreen() {
    // TODO: This state should be hoisted
    var shouldShowOnboarding by remember { mutableStateOf(true) }

    Surface {
        Column(
            modifier = Modifier.fillMaxSize(),
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Text("Welcome to the Basics Codelab!")
            Button(
                modifier = Modifier.padding(vertical = 24.dp),
                onClick = { shouldShowOnboarding = false } 
            ) {
                Text("Continue")
            }
        }
    }
}

@Preview(showBackground = true, widthDp = 320, heightDp = 320)
@Composable
fun OnboardingPreview() {
    BasicsCodelabTheme {
        OnboardingScreen()
    }
}

This code contains a bunch of new features:

  • You have added a new composable called OnboardingScreen and also a new preview. If you build the project you'll notice you can have multiple previews at the same time. We also added a fixed height to verify that the content is aligned correctly.
  • Column can be configured to display its contents in the center of the screen.
  • The modifier align can be used to align composables inside a row or a column.
  • shouldShowOnboarding is using a by keyword instead of the =. This is a property delegate that saves you from typing .value every time.
  • When the button is clicked, shouldShowOnboarding is set to false, however you are not reading the state from anywhere yet.

Now we can add this new onboarding screen to our app. We want to show it on launch and then hide it when the user presses "Continue".

In Compose you don't hide UI elements. Instead, you simply don't add them to the composition, so they're not added to the UI tree that Compose generates. You do this with simple conditional Kotlin logic. For example to show the onboarding screen or the list of greetings you would do something like:

// Don't copy yet
@Composable
fun MyApp() {
    if (shouldShowOnboarding) { // Where does this come from?
        OnboardingScreen()
    } else {
        Greetings()
    }
}

However we don't have access to shouldShowOnboarding . It's clear that we need to share the state that we created in OnboardingScreen with the MyApp composable.

Instead of somehow sharing the value of the state with its parent, we hoist the state–we simply move it to the common ancestor that needs to access it.

First, move the content of MyApp into a new composable called Greetings:

@Composable
fun MyApp() {
     Greetings()
}

@Composable
private fun Greetings(names: List<String> = listOf("World", "Compose")) {
    Column(modifier = Modifier.padding(vertical = 4.dp)) {
        for (name in names) {
            Greeting(name = name)
        }
    }
}

Now add the logic to show the different screens in MyApp, and hoist the state.

@Composable
fun MyApp() {

    var shouldShowOnboarding by remember { mutableStateOf(true) }

    if (shouldShowOnboarding) {
        OnboardingScreen(/* TODO */)
    } else {
        Greetings()
    }
}

We also need to share shouldShowOnboarding with the onboarding screen but we are not going to pass it directly. Instead of letting OnboardingScreen mutate our state, it would be better to let it notify us when the user clicked on the Continue button.

How do we pass events up? By passing callbacks down. Callbacks are functions that are passed as arguments to other functions and get executed when the event occurs.

Try to add a function parameter to the onboarding screen defined as onContinueClicked: () -> Unit so you can mutate the state from MyApp.

Solution:

@Composable
fun MyApp() {

    var shouldShowOnboarding by remember { mutableStateOf(true) }

    if (shouldShowOnboarding) {
        OnboardingScreen(onContinueClicked = { shouldShowOnboarding = false })
    } else {
        Greetings()
    }
}

@Composable
fun OnboardingScreen(onContinueClicked: () -> Unit) {

    Surface {
        Column(
            modifier = Modifier.fillMaxSize(),
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Text("Welcome to the Basics Codelab!")
            Button(
                modifier = Modifier
                    .padding(vertical = 24.dp),
                onClick = onContinueClicked
            ) {
                Text("Continue")
            }
        }
    }
}

By passing a function and not a state to OnboardingScreen we are making this composable more reusable and protecting the state from being mutated by other composables. In general, it keeps things simple. A good example is how the onboarding preview needs to be modified to call the OnboardingScreen now:

@Preview(showBackground = true, widthDp = 320, heightDp = 320)
@Composable
fun OnboardingPreview() {
    BasicsCodelabTheme {
        OnboardingScreen(onContinueClicked = {}) // Do nothing on click.
    }
}

Assigning onContinueClicked to an empty lambda expression means "do nothing", which is perfect for a preview.

This is looking more and more like a real app, good job!

1fd101673cd56005.gif

Full code so far:

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material.Button
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.codelab.basics.ui.BasicsCodelabTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            BasicsCodelabTheme {
                MyApp()
            }
        }
    }
}

@Composable
fun MyApp() {

    var shouldShowOnboarding by remember { mutableStateOf(true) }

    if (shouldShowOnboarding) {
        OnboardingScreen(onContinueClicked = { shouldShowOnboarding = false })
    } else {
        Greetings()
    }
}

@Composable
fun OnboardingScreen(onContinueClicked: () -> Unit) {

    Surface {
        Column(
            modifier = Modifier.fillMaxSize(),
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Text("Welcome to the Basics Codelab!")
            Button(
                modifier = Modifier.padding(vertical = 24.dp),
                onClick = onContinueClicked
            ) {
                Text("Continue")
            }
        }
    }
}

@Composable
private fun Greetings(names: List<String> = listOf("World", "Compose")) {
    Column(modifier = Modifier.padding(vertical = 4.dp)) {
        for (name in names) {
            Greeting(name = name)
        }
    }
}

@Preview(showBackground = true, widthDp = 320, heightDp = 320)
@Composable
fun OnboardingPreview() {
    BasicsCodelabTheme {
        OnboardingScreen(onContinueClicked = {})
    }
}

@Composable
private fun Greeting(name: String) {

    val expanded = remember { mutableStateOf(false) }

    val extraPadding = if (expanded.value) 48.dp else 0.dp

    Surface(
        color = MaterialTheme.colors.primary,
        modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        Row(modifier = Modifier.padding(24.dp)) {
            Column(modifier = Modifier
                .weight(1f)
                .padding(bottom = extraPadding)
            ) {
                Text(text = "Hello, ")
                Text(text = name)
            }
            OutlinedButton(
                onClick = { expanded.value = !expanded.value }
            ) {
                Text(if (expanded.value) "Show less" else "Show more")
            }
        }
    }
}

@Preview(showBackground = true, widthDp = 320)
@Composable
fun DefaultPreview() {
    BasicsCodelabTheme {
        Greetings()
    }
}

9. Creating a performant lazy list

Now let's make the names list more realistic. So far you have displayed two greetings in a Column. But, can it handle thousands of them?

Change the default list value in the Greetings parameters to use another list constructor which allows to set the list size and fill it with the value contained in its lambda (here $it represents the list index):

names: List<String> = List(1000) { "$it" }

This creates 1000 greetings, even the ones that don't fit in the screen. Obviously this is not performant. You can try to run it on an emulator (warning: this code might freeze your emulator).

To display a scrollable column we use a LazyColumn. LazyColumn renders only the visible items on screen, allowing performance gains when rendering a big list.

In its basic usage, the LazyColumn API provides an items element within its scope, where individual item rendering logic is written:

import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
...

@Composable
private fun Greetings(names: List<String> = List(1000) { "$it" } ) {
    LazyColumn(modifier = Modifier.padding(vertical = 4.dp)) {
        items(items = names) { name ->
            Greeting(name = name)
        }
    }
}

487ca9093596fc6c.gif

10. Persisting state

Our app has a problem: if you run the app on a device, click on the buttons and then you rotate, the onboarding screen is shown again. The remember function works only as long as the composable is kept in the Composition. When you rotate, the whole activity is restarted so all state is lost. This also happens with any configuration change and on process death.

Instead of using remember you can use rememberSaveable. This will save each state surviving configuration changes (such as rotations) and process death.

Now replace the use of remember in shouldShowOnboarding with rememberSaveable:

    var shouldShowOnboarding by rememberSaveable { mutableStateOf(true) }

Run, rotate, change to dark mode or kill the process. The onboarding screen is not shown unless you have previously exited the app.

63be420fc371e10f.gif

Demo showing how a configuration change (switching to dark mode) does not show onboarding again.

With around 120 lines of code so far, you were able to display a long and performant scrolling list of items each holding their own state. Also, as you can see, your app has a perfectly correct dark mode without extra lines of code. You'll learn about theming later.

11. Animating your list

In Compose, there are multiple ways to animate your UI: from high-level APIs for simple animations to low-level methods for full control and complex transitions. You can read about them in the documentation.

In this section you will use one of the low-level APIs but don't worry, they can also be very simple. Let's animate the change in size that we already implemented:

50756832c3714a6f.gif

For this you'll use the animateDpAsState composable. It returns a State object whose value will continuously be updated by the animation until it finishes. It takes a "target value" whose type is Dp.

Create an animated extraPadding that depends on the expanded state. Also, let's use the property delegate (the by keyword):

@Composable
private fun Greeting(name: String) {

    var expanded by remember { mutableStateOf(false) }

    val extraPadding by animateDpAsState(
        if (expanded) 48.dp else 0.dp
    )
    Surface(
        color = MaterialTheme.colors.primary,
        modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        Row(modifier = Modifier.padding(24.dp)) {
            Column(modifier = Modifier
                .weight(1f)
                .padding(bottom = extraPadding)
            ) {
                Text(text = "Hello, ")
                Text(text = name)
            }
            OutlinedButton(
                onClick = { expanded = !expanded }
            ) {
                Text(if (expanded) "Show less" else "Show more")
            }

        }
    }
}

Run the app and try the animation out.

animateDpAsState takes an optional animationSpec parameter that lets you customize the animation. Let's do something more fun like adding a spring-based animation:

@Composable
private fun Greeting(name: String) {

    var expanded by remember { mutableStateOf(false) }

    val extraPadding by animateDpAsState(
        if (expanded) 48.dp else 0.dp,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioMediumBouncy,
            stiffness = Spring.StiffnessLow
        )
    )

    Surface(
    ...
            Column(modifier = Modifier
                .weight(1f)
                .padding(bottom = extraPadding.coerceAtLeast(0.dp))

    ...

    )
}

Note that we are also making sure that padding is never negative, otherwise it could crash the app. This introduces a subtle animation bug that we'll fix later in Finishing touches.

The spring spec does not take any time-related parameters. Instead it relies on physical properties (damping and stiffness) to make animations more natural. Run the app now to try the new animation:

489e7c08d5c46781.gif

Any animation created with animate*AsState is interruptible. This means that if the target value changes in the middle of the animation, animate*AsState restarts the animation and points to the new value. Interruptions look especially natural with spring-based animations:

354ddf3f23ebb8e0.gif

If you want to explore the different types of animations, try out different parameters for spring, different specs (tween, repeatable) and different functions: animateColorAsState or a different type of animation API.

Full code for this section

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.Button
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.codelab.basics.ui.BasicsCodelabTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            BasicsCodelabTheme {
                MyApp()
            }
        }
    }
}

@Composable
fun MyApp() {

    var shouldShowOnboarding by rememberSaveable { mutableStateOf(true) }

    if (shouldShowOnboarding) {
        OnboardingScreen(onContinueClicked = { shouldShowOnboarding = false })
    } else {
        Greetings()
    }
}

@Composable
fun OnboardingScreen(onContinueClicked: () -> Unit) {

    Surface {
        Column(
            modifier = Modifier.fillMaxSize(),
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Text("Welcome to the Basics Codelab!")
            Button(
                modifier = Modifier.padding(vertical = 24.dp),
                onClick = onContinueClicked
            ) {
                Text("Continue")
            }
        }
    }
}

@Composable
private fun Greetings(names: List<String> = List(1000) { "$it" } ) {
    LazyColumn(modifier = Modifier.padding(vertical = 4.dp)) {
        items(items = names) { name ->
            Greeting(name = name)
        }
    }
}

@Preview(showBackground = true, widthDp = 320, heightDp = 320)
@Composable
fun OnboardingPreview() {
    BasicsCodelabTheme {
        OnboardingScreen(onContinueClicked = {})
    }
}

@Composable
private fun Greeting(name: String) {

    var expanded by remember { mutableStateOf(false) }

    val extraPadding by animateDpAsState(
        if (expanded) 48.dp else 0.dp,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioMediumBouncy,
            stiffness = Spring.StiffnessLow
        )
    )
    Surface(
        color = MaterialTheme.colors.primary,
        modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        Row(modifier = Modifier.padding(24.dp)) {
            Column(modifier = Modifier
                .weight(1f)
                .padding(bottom = extraPadding.coerceAtLeast(0.dp))
            ) {
                Text(text = "Hello, ")
                Text(text = name)
            }
            OutlinedButton(
                onClick = { expanded.value = !expanded.value }
            ) {
                Text(if (expanded.value) "Show less" else "Show more")
            }
        }
    }
}

@Preview(showBackground = true, widthDp = 320)
@Composable
fun DefaultPreview() {
    BasicsCodelabTheme {
        Greetings()
    }
}

12. Styling and theming your app

You didn't style any of the composables so far and yet you got a decent default, including dark mode support! Let's look into what BasicsCodelabTheme and MaterialTheme are.

If you open the ui/Theme.kt file, you see that BasicsCodelabTheme uses MaterialTheme in its implementation:

@Composable
fun BasicsCodelabTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val colors = if (darkTheme) {
        DarkColorPalette
    } else {
        LightColorPalette
    }

    MaterialTheme(
        colors = colors,
        typography = typography,
        shapes = shapes,
        content = content
    )
}

MaterialTheme is a composable function that reflects the styling principles from the Material design specification. That styling information cascades down to the components that are inside its content, which may read the information to style themselves. In your UI, you are already using BasicsCodelabTheme as follows:

    BasicsCodelabTheme {
        MyApp()
    }

Because BasicsCodelabTheme wraps MaterialTheme internally, MyApp is styled with the properties defined in the theme. From any descendant composable you can retrieve three properties of MaterialTheme: colors, typography and shapes. Use them to set a header style one of your Texts:

            Column(modifier = Modifier.weight(1f)) {
                Text(text = "Hello, ")
                Text(text = name, style = MaterialTheme.typography.h4)
            }

The Text composable in the example above sets a new TextStyle. You can create your own TextStyle, or you can retrieve a theme-defined style by using MaterialTheme.typography, which is preferred. This construct gives you access to the Material-defined text styles, such as h1-h6, body1,body2, caption, subtitle1 etc. In your example, you use the h4 style defined in the theme.

Now build to see our newly styled text:

5a2a3b62960cd0e9.png

In general it's much better to keep your colors, shapes and font styles inside a MaterialTheme. For example, dark mode would be hard to implement if you hard-code colors and it would require a lot of error-prone work to fix.

However sometimes you need to deviate slightly from the selection of colors and font styles. In those situations it's better to base your color or style on an existing one.

For this, you can modify a predefined style by using the copy function. Make the number extra bold:

                Text(
                    text = name,
                    style = MaterialTheme.typography.h4.copy(
                        fontWeight = FontWeight.ExtraBold
                    )
                )

This way if you need to change the font family or any other attribute of h4, you don't have to worry about the small deviations.

Now this should be the result in the preview window:

619daa86b737c945.png

Tweak your app's theme

You can find everything related to the current theme in the files inside the ui folder. For example, the default colors that we have been using so far are defined in Color.kt.

Let's start by defining new colors. Add these to Color.kt:

val Navy = Color(0xFF073042)
val Blue = Color(0xFF4285F4)
val LightBlue = Color(0xFFD7EFFE)
val Chartreuse = Color(0xFFEFF7CF)

Now assign them to the MaterialTheme's palette in Theme.kt:

private val LightColorPalette = lightColors(
    surface = Blue,
    onSurface = Color.White,
    primary = LightBlue,
    onPrimary = Navy
)

If you go back to MainActivity.kt and refresh the preview, you'll see the new colors:

479f2f0e8f19c3e9.png

However, you haven't modified the dark colors yet. Before doing that, let's set up the previews for it. Add an additional @Preview annotation to DefaultPreview with UI_MODE_NIGHT_YES:

@Preview(
    showBackground = true,
    widthDp = 320,
    uiMode = UI_MODE_NIGHT_YES,
    name = "DefaultPreviewDark"
)
@Preview(showBackground = true, widthDp = 320)
@Composable
fun DefaultPreview() {
    BasicsCodelabTheme {
        Greetings()
    }
}

This adds a preview in dark mode.

8a54a386b258277a.png

In Theme.kt, define the palette for dark colors:

private val DarkColorPalette = darkColors(
    surface = Blue,
    onSurface = Navy,
    primary = Navy,
    onPrimary = Chartreuse
)

Now our app is theme and styled!

19e76f3aa95940af.png

Final code for Theme.kt

import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material.MaterialTheme
import androidx.compose.material.darkColors
import androidx.compose.material.lightColors
import androidx.compose.runtime.Composable

private val DarkColorPalette = darkColors(
    surface = Blue,
    onSurface = Navy,
    primary = Navy,
    onPrimary = Chartreuse
)

private val LightColorPalette = lightColors(
    surface = Blue,
    onSurface = Color.White,
    primary = LightBlue,
    onPrimary = Navy
)

@Composable
fun BasicsCodelabTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val colors = if (darkTheme) {
        DarkColorPalette
    } else {
        LightColorPalette
    }

    MaterialTheme(
        colors = colors,
        typography = typography,
        shapes = shapes,
        content = content
    )
}

13. Finishing touches!

In this step, you'll apply what you already know and learn new concepts with only a few hints. You will create this:

87f2753c576d26f2.gif

Replace button with an icon

  • Use the IconButton composable together with a child Icon.
  • Use Icons.Filled.ExpandLess and Icons.Filled.ExpandMore, which are available in the material-icons-extended artifact. Add the following line to dependencies in your app/build.gradle file.
implementation "androidx.compose.material:material-icons-extended:$compose_version"
  • Modify paddings to fix alignment.
  • Add a content description for accessibility (see "Use string resources" below).

Use string resources

Content description for "Show more" and "show less" should be present and you can add them with a simple if statement:

contentDescription = if (expanded) "Show less" else "Show more"

However, hard-coding strings is a bad practice and you should get them from the strings.xml file.

You can use "Extract string resource" on each string, available in "Context Actions" in Android Studio to do this automatically.

Alternatively, open app/src/res/values/strings.xml and add the following resources:

<string name="show_less">Show less</string>
<string name="show_more">Show more</string>

Showing more

The "Composem ipsum" text appears and disappears, triggering a change in size of each card.

  • Add a new Text to the Column inside Greeting that is displayed when the item is expanded.
  • Remove the extraPadding and instead apply the animateContentSize modifier to the Row. This is going to automate the process of creating the animation, which would be hard to do manually. Also, it removes the need to coerceAtLeast.

Add elevation and shapes

  • You could use the shadow modifier together with clip modifier to achieve the card look. However, there's a Material composable that does exactly that: Card.

Final code

import android.content.res.Configuration.UI_MODE_NIGHT_YES
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.Button
import androidx.compose.material.Card
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons.Filled
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.codelab.basics.R
import com.codelab.basics.ui.BasicsCodelabTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            BasicsCodelabTheme {
                MyApp()
            }
        }
    }
}

@Composable
private fun MyApp() {
    var shouldShowOnboarding by rememberSaveable { mutableStateOf(true) }

    if (shouldShowOnboarding) {
        OnboardingScreen(onContinueClicked = { shouldShowOnboarding = false })
    } else {
        Greetings()
    }
}

@Composable
private fun OnboardingScreen(onContinueClicked: () -> Unit) {
    Surface {
        Column(
            modifier = Modifier.fillMaxSize(),
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Text("Welcome to the Basics Codelab!")
            Button(
                modifier = Modifier.padding(vertical = 24.dp),
                onClick = onContinueClicked
            ) {
                Text("Continue")
            }
        }
    }
}

@Composable
private fun Greetings(names: List<String> = List(1000) { "$it" } ) {
    LazyColumn(modifier = Modifier.padding(vertical = 4.dp)) {
        items(items = names) { name ->
            Greeting(name = name)
        }
    }
}

@Composable
private fun Greeting(name: String) {
    Card(
        backgroundColor = MaterialTheme.colors.primary,
        modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        CardContent(name)
    }
}

@Composable
private fun CardContent(name: String) {
    var expanded by remember { mutableStateOf(false) }
    
    Row(
        modifier = Modifier
            .padding(12.dp)
            .animateContentSize(
                animationSpec = spring(
                    dampingRatio = Spring.DampingRatioMediumBouncy,
                    stiffness = Spring.StiffnessLow
                )
            )
    ) {
        Column(
            modifier = Modifier
                .weight(1f)
                .padding(12.dp)
        ) {
            Text(text = "Hello, ")
            Text(
                text = name,
                style = MaterialTheme.typography.h4.copy(
                    fontWeight = FontWeight.ExtraBold
                )
            )
            if (expanded) {
                Text(
                    text = ("Composem ipsum color sit lazy, " +
                        "padding theme elit, sed do bouncy. ").repeat(4),
                )
            }
        }
        IconButton(onClick = { expanded = !expanded }) {
            Icon(
                imageVector = if (expanded) Filled.ExpandLess else Filled.ExpandMore,
                contentDescription = if (expanded) {
                    stringResource(R.string.show_less)
                } else {
                    stringResource(R.string.show_more)
                }

            )
        }
    }
}

@Preview(
    showBackground = true,
    widthDp = 320,
    uiMode = UI_MODE_NIGHT_YES,
    name = "DefaultPreviewDark"
)
@Preview(showBackground = true, widthDp = 320)
@Composable
fun DefaultPreview() {
    BasicsCodelabTheme {
        Greetings()
    }
}

@Preview(showBackground = true, widthDp = 320, heightDp = 320)
@Composable
fun OnboardingPreview() {
    BasicsCodelabTheme {
        OnboardingScreen(onContinueClicked = {})
    }
}

14. Congratulations

Congratulations! You learned the basics of Compose!

Solution to the codelab

You can get the code for the solution of this codelab from GitHub:

$ git clone https://github.com/googlecodelabs/android-compose-codelabs

Alternatively you can download the repository as a Zip file:

Download Zip

What's next?

Check out the other codelabs on the Compose pathway:

Further reading