Build a complete app with Relay and Jetpack Compose

1. Before you begin

Relay is a toolkit that lets teams design UI components in Figma and use them directly in Jetpack Compose projects. It removes the need for tedious design specification and QA cycles, which helps teams quickly deliver great Android UIs.

In this codelab, you learn how to integrate Relay UI Packages into your Compose development process. It focuses on integration techniques, not the end-to-end workflow. To learn about the general workflow for Relay, see the Relay basic tutorial.

Prerequisites

  • Basic experience with Compose. If you haven't already done so, complete the Jetpack Compose basics codelab.
  • Experience with Kotlin syntax.

What you'll learn

  • How to import UI Packages.
  • How to integrate UI Packages with navigation and data architecture.
  • How to wrap UI Packages with controller logic.
  • How to map Figma styles to your Compose theme.
  • How to replace UI Packages with existing composables in generated code.

What you'll build

  • A realistic app design based on Relay packages provided by a designer. The app is called Reflect, a daily tracking app that promotes mindfulness and good habits. It contains a collection of trackers of various types and UI to add and manage them. The app looks like the following image:

The finished app

What you'll need

2. Get set up

Get the code

To get the code for this codelab, do one of the following:

$ git clone https://github.com/googlecodelabs/relay-codelabs
  • Navigate to the relay-codelabs repository on GitHub, select the desired branch, and then click Code > Download zip and unpack the downloaded zip file.

In either case, the main branch contains the starter code and the end branch contains the solution code.

Install the Relay for Android Studio plugin

If you don't already have the Relay for Android Studio plugin, follow these steps:

  1. In Android Studio, click Settings > Plugins.
  2. In the text box, enter Relay for Android Studio.
  3. On the extension that appears in the search results, click Install.

Android Studio plugin settings

  1. If you see a Third-party plugins privacy note dialog, click Accept.
  2. Click OK > Restart.
  3. If you see a Confirm exit dialog, click Exit.

Connect Android Studio to Figma

Relay retrieves UI Packages with the Figma API. To use it, you need a free Figma account and a personal access token, hence why they're listed in the What you'll need section.

If you haven't already connected Android Studio to Figma, follow these steps:

  1. In your Figma account, click your profile icon at the top of the page and select Settings.
  2. In the Personal access tokens section, enter a description for the token in the text box and then press Enter (or return on macOS). A token is generated.
  3. Click Copy this token.

An access token generated in Figma

  1. In Android Studio, select Tools > Relay Settings. A Relay settings dialog appears.
  2. In the Figma Access Token text box, paste the access token and then click OK. Your environment is set up.

3. Review the app's design

For the Reflect app, we worked with a designer to help us define the app's color, typography, layout, and behavior. We created these designs in accordance with Material Design 3 conventions so that the app works seamlessly with Material components and themes.

Review the home screen

The home screen displays a list of user-defined trackers. It also provides affordances to change the active day and create other trackers.

The home screen

In Figma, our designer divided this screen into multiple components, defined their APIs, and packaged them with the Relay for Figma plugin. After these components are packaged, you can import them into your Android Studio project.

Home screen component

Review the add/edit screen

The add/edit screen lets users add or edit trackers. The form displayed is slightly different based on tracker type.

The add/edit screen

Similarly, this screen is divided into multiple packaged components.

Add/edit screen components

Review the theme

Colors and typography for this design are implemented as Figma styles based on Material Design 3 token names. This provides better interoperability with Compose themes and Material components.

Figma styles

4. Import UI Packages

Before you can import UI Packages into your project, you need to upload the design source to Figma.

To get the link to the Figma source, follow these steps:

  1. In Figma, click Import file and then select the ReflectDesign.fig file found in the CompleteAppCodelab project folder.
  2. Right-click the file and then select Copy link. You need it in the next section.

88afd168463bf7e5.png

Import UI Packages into the project

  1. In Android Studio, open the ./CompleteAppCodelab project.
  2. Click File > New > Import UI Packages. An Import UI Packages dialog appears.
  3. In the Figma source URL text box, paste the URL that you copied in the previous section.

f75d0c3e17b6f75.png

  1. In the App theme text box, enter com.google.relay.example.reflect.ui.theme.ReflectTheme. This ensures that generated previews use the custom theme.
  2. Click Next. You see a preview of the file's UI Packages.
  3. Click Create. The packages are imported into your project.
  4. Navigate to the Project tab and then click the 2158ffa7379d2b2e.pngexpander arrow next to the ui-packages folder.

The ui-packages folder

  1. Click the 2158ffa7379d2b2e.pngexpander arrow next to one of the package folders, and then notice that it contains a JSON source file and asset dependencies.
  2. Open the JSON source file. The Relay module displays a preview of the package and its API.

a6105146c4cfb47.png

Build and generate code

  1. At the top of Android Studio, click b3bc77f3c78cac1b.png Make project. Generated code for each package is added to the java/com.google.relay.example.reflect folder. Generated composables contain all layout and styling information from the Figma design.
  2. Open the com/google/relay/example/reflect/range/Range.kt file.
  3. Notice that Compose previews are created for each component variation. If necessary, click Split so that you see the code and preview panes next to each other.

c0d21ab0622ad550.png

5. Integrate components

In this section, you take a closer look at the generated code for the Switch tracker.

The design for the Switch tracker

  1. In Android Studio, open the com/google/relay/example/reflect/switch/Switch.kt file.

Switch.kt (generated)

/**
 * This composable was generated from the UI Package 'switch'.
 * Generated code; don't edit directly.
 */
@Composable
fun Switch(
    modifier: Modifier = Modifier,
    isChecked: Boolean = false,
    emoji: String = "",
    title: String = ""
) {
    TopLevel(modifier = modifier) {
        if (isChecked) {
            ActiveOverlay(modifier = Modifier.rowWeight(1.0f).columnWeight(1.0f)) {}
        }
        TopLevelSynth(modifier = Modifier.rowWeight(1.0f)) {
            Label(modifier = Modifier.rowWeight(1.0f)) {
                Emoji(emoji = emoji)
                Title(
                    title = title,
                    modifier = Modifier.rowWeight(1.0f)
                )
            }
            Checkmark {
                if (isChecked) {
                    Icon()
                }
            }
        }
    }
}
  1. Notice the following:
  • All layout and styling from the Figma design is generated.
  • Sub-elements are divided into separate composables.
  • Composable Previews are generated for all design variations.
  • Color and typography styles are hardcoded. You fix this later.

Insert the tracker

  1. In Android Studio, open the java/com/google/relay/example/reflect/ui/components/TrackerControl.kt file. This file provides data and interaction logic to habit trackers.
  2. Build and run the app in the emulator. Currently, this component outputs raw data from the tracker model.

5d56f8a7065066b7.png

  1. Import the com.google.relay.example.reflect.switch.Switch package into the file.
  2. Replace Text(text = trackerData.tracker.toString()) with a when block that pivots on the trackerData.tracker.type field.
  3. In the body of the when block, call the Switch() Composable function when the type is TrackerType.BOOLEAN.

Your code should look like this:

TrackerControl.kt

// TODO: replace with Relay tracker components
when (trackerData.tracker.type) {
    TrackerType.BOOLEAN ->
        Switch(
          title = trackerData.tracker.name,
          emoji = trackerData.tracker.emoji
        )
    else ->
        Text(trackerData.tracker.toString())
}
  1. Rebuild the project. Now the homepage correctly renders the Switch tracker as designed with live data.

4241e78b9f82075b.png

6. Add state and interaction

UI Packages are stateless. What's rendered is a simple result of the parameters passed in. But real apps need interaction and state. Interaction handlers can be passed into generated composables like any other parameters, but where do you keep the state that those handlers manipulate? How do you avoid passing the same handler to every instance? How can you abstract compositions of packages into reusable composables? For these cases, we recommend that you wrap your generated packages in a custom Composable function.

Wrap UI packages in a controller Composable function

Wrapping UI Packages in a controller Composable function lets you customize presentation or business logic and, if necessary, manage local state. Designers are still free to update the original UI Package in Figma without requiring you to update the wrapper code.

To create a controller for the Switch tracker, follow these steps:

  1. In Android Studio, open the java/com/google/relay/example/reflect/ui/components/SwitchControl.kt file.
  2. In the SwitchControl() Composable function, pass in the following parameters:
  • trackerData: a TrackerData object
  • modifier: a decorator object
  • onLongClick: an interaction callback to enable long press on trackers for edit and delete
  1. Insert a Switch() function and pass in a combinedClickable modifier to handle click and long press.
  2. Pass values from the TrackerData object to the Switch() function, including the isToggled() method.

The completed SwitchControl() function looks like this code snippet:

SwitchControl.kt

package com.google.relay.example.reflect.ui.components

import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.google.relay.example.reflect.model.Tracker
import com.google.relay.example.reflect.model.TrackerData
import com.google.relay.example.reflect.model.TrackerType
import com.google.relay.example.reflect.switch.Switch

/*
 * A component for controlling switch-type trackers.
 *
 * SwitchControl is responsible for providing interaction and state management to the stateless
 * composable [Switch] generated by Relay. [onLongClick] provides a way for callers to supplement
 * the control's intrinsic interactions with, for example, a context menu.
 */
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SwitchControl(
    trackerData: TrackerData,
    modifier: Modifier = Modifier,
    onLongClick: (() -> Unit)? = null,
) {
    Switch(
        modifier
            .clip(shape = RoundedCornerShape(size = 32.dp))
            .combinedClickable(onLongClick = onLongClick) {
                trackerData.toggle()
            },
        emoji = trackerData.tracker.emoji,
        title = trackerData.tracker.name,
        isChecked = trackerData.isToggled(),
    )
}

@Preview
@Composable
fun SwitchControllerPreview() {
    val data = TrackerData(
        Tracker(
            emoji = "🍕",
            name = "Ate Pizza",
            type = TrackerType.BOOLEAN
        )
    )
    SwitchControl(data)
}
  1. In the TrackerControl.kt file, remove the Switch import and then replace the Switch() function with a call to the SwitchControl() function.
  2. Add cases for the TrackerType.RANGE and TrackerType.COUNT enumerator constants.

The completed when block looks like this code snippet:

TrackerControl.kt

when (trackerData.tracker.type) {
    TrackerType.BOOLEAN ->
        SwitchControl(
            trackerData = trackerData,
            onLongClick = { expanded = true },
        )
    TrackerType.RANGE ->
        RangeControl(
            trackerData = trackerData,
            onLongClick = { expanded = true },
        )
    TrackerType.COUNT ->
        ValueControl(
            trackerData = trackerData,
            onLongClick = { expanded = true },
        )
}
  1. Rebuild the project. Now you can display and interact with trackers. The home screen is complete.

b23b94f0034243d3.png

7. Map existing components

Relay lets developers customize generated code by replacing UI Packages with existing composables. This is a great way to output out-of-the-box components or even custom design systems in your code.

Map a text field

The following image is the design for the Tracker Settings component in the Add/edit tracker dialog:

Design for the Switch settings component

Our designer used a ReflectTextField in the design, for which we already have an implementation in code built on top of Material Design 3 text fields. Figma doesn't support text fields natively, so the default code generated by Relay only looks like the design; it's not a functional control.

To test out the current implementation for TrackerSettings:

  1. In Android Studio, build and run the app in the emulator.
  2. Long press on a tracker row and select Edit.
  3. Tap on the Title text field and note that it doesn't respond to interaction.

To substitute the real implementation for this element, you need two things: a text field UI package and a mapping file. Fortunately our designer already packaged our design system components in Figma and used a text field component in their design for Tracker Settings. By default, this nested package is generated as a dependency, but you use component mapping to swap it.

Figma component for text field with Relay plugin overlaid

Create a mapping file

The Relay for Android Studio plugin provides a shortcut to create component mapping files.

To create a mapping file, follow these steps:

  1. In Android Studio, right-click the text_field UI package and then select Generate mapping file.

Generate mapping file context menu item

  1. A mapping file dialog will show. Enter the following options:
  • In Target composable, select Use existing composable and enter com.google.relay.example.reflect.ui.components.ReflectTextField
  • In Generated file, check Generate implementation and uncheck Generate Compose Preview

e776585c3b838b10.png

  1. Click Generate mapping file. This will generate the following mapping file:

text_field.json

{
  "target": "ReflectTextField",
  "package": "com.google.relay.example.reflect.ui.components",
  "generateImplementation": true,
  "generatePreviews": false,
}

Component-mapping files identify a Compose class target and package, and an optional collection of fieldMapping objects. These field mappings let you transform package parameters into the expected Compose parameters. In this case, the APIs are identical, so you only need to specify the target class.

  1. Rebuild the project.
  2. In the trackersettings/ TrackerSettings.kt file, find the generated TitleFieldStyleFilledStateEnabledTextConfigurationsInputText() Composable function and note that it includes a generated ReflectTextField component.

TrackerSettings.kt (generated)

@Composable
fun TitleFieldStyleFilledStateEnabledTextConfigurationsInputText(
    onTitleChanged: (String) -> Unit,
    title: String,
    modifier: Modifier = Modifier
) {
    ReflectTextField(
        onChange = onTitleChanged,
        labelText = "Title",
        leadingIcon = "search",
        trailingIcon = "cancel",
        supportingText = "Supporting text",
        inputText = title,
        state = State.Enabled,
        textConfigurations = TextConfigurations.InputText,
        modifier = modifier.fillMaxWidth(1.0f).requiredHeight(56.0.dp)
    )
}
  1. Rebuild the project. Now you can interact with tracker settings fields. The edit screen is complete.

8. Map to Compose themes

By default, Relay generates literal values for colors and typography. This ensures translation accuracy, but prevents components from using the Compose theming system. This is obvious when you view your app in dark mode:

Preview of home screen using dark mode and showing incorrect colors

The day navigation component is almost invisible and the colors are wrong. To fix this, you use the style-mapping feature in Relay to link Figma styles to Compose theme tokens in your generated code. This increases visual consistency between Relay and Material Design 3 components, and enables dark mode support.

1fac916db14929bb.png

Create a style-mapping file

  1. In Android Studio, navigate to the src/main/ui-package-resources directory and create a new directory named style-mappings. In that directory, create a figma_styles.json file that contains the following code:

figma_styles.json

{
  "figma": {
    "colors": {
      "Reflect Light/background": "md.sys.color.background",
      "Reflect Dark/background": "md.sys.color.background",
      "Reflect Light/on-background": "md.sys.color.on-background",
      "Reflect Dark/on-background": "md.sys.color.on-background",
      "Reflect Light/surface": "md.sys.color.surface",
      "Reflect Dark/surface": "md.sys.color.surface",
      "Reflect Light/on-surface": "md.sys.color.on-surface",
      "Reflect Dark/on-surface": "md.sys.color.on-surface",
      "Reflect Light/surface-variant": "md.sys.color.surface-variant",
      "Reflect Dark/surface-variant": "md.sys.color.surface-variant",
      "Reflect Light/on-surface-variant": "md.sys.color.on-surface-variant",
      "Reflect Dark/on-surface-variant": "md.sys.color.on-surface-variant",
      "Reflect Light/primary": "md.sys.color.primary",
      "Reflect Dark/primary": "md.sys.color.primary",
      "Reflect Light/on-primary": "md.sys.color.on-primary",
      "Reflect Dark/on-primary": "md.sys.color.on-primary",
      "Reflect Light/primary-container": "md.sys.color.primary-container",
      "Reflect Dark/primary-container": "md.sys.color.primary-container",
      "Reflect Light/on-primary-container": "md.sys.color.on-primary-container",
      "Reflect Dark/on-primary-container": "md.sys.color.on-primary-container",
      "Reflect Light/secondary-container": "md.sys.color.secondary-container",
      "Reflect Dark/secondary-container": "md.sys.color.secondary-container",
      "Reflect Light/on-secondary-container": "md.sys.color.on-secondary-container",
      "Reflect Dark/on-secondary-container": "md.sys.color.on-secondary-container",
      "Reflect Light/outline": "md.sys.color.outline",
      "Reflect Dark/outline": "md.sys.color.outline",
      "Reflect Light/error": "md.sys.color.error",
      "Reflect Dark/error": "md.sys.color.error"
    },
    "typography": {
      "symbols": {
        "Reflect/headline/large": "md.sys.typescale.headline-large",
        "Reflect/headline/medium": "md.sys.typescale.headline-medium",
        "Reflect/headline/small": "md.sys.typescale.headline-small",
        "Reflect/title/large": "md.sys.typescale.title-large",
        "Reflect/title/medium": "md.sys.typescale.title-medium",
        "Reflect/title/small": "md.sys.typescale.title-small",
        "Reflect/body/large": "md.sys.typescale.body-large",
        "Reflect/body/medium": "md.sys.typescale.body-medium",
        "Reflect/body/small": "md.sys.typescale.body-small",
        "Reflect/label/large": "md.sys.typescale.label-large",
        "Reflect/label/medium": "md.sys.typescale.label-medium",
        "Reflect/label/small": "md.sys.typescale.label-small"
      },
      "subproperties": {
        "fontFamily": "font",
        "fontWeight": "weight",
        "fontSize": "size",
        "letterSpacing": "tracking",
        "lineHeightPx": "line-height"
      }
    }
  },
  "compose": {
    "colors": {
      "md.sys.color.background": "MaterialTheme.colorScheme.background",
      "md.sys.color.error": "MaterialTheme.colorScheme.error",
      "md.sys.color.error-container": "MaterialTheme.colorScheme.errorContainer",
      "md.sys.color.inverse-on-surface": "MaterialTheme.colorScheme.inverseOnSurface",
      "md.sys.color.inverse-surface": "MaterialTheme.colorScheme.inverseSurface",
      "md.sys.color.on-background": "MaterialTheme.colorScheme.onBackground",
      "md.sys.color.on-error": "MaterialTheme.colorScheme.onError",
      "md.sys.color.on-error-container": "MaterialTheme.colorScheme.onErrorContainer",
      "md.sys.color.on-primary": "MaterialTheme.colorScheme.onPrimary",
      "md.sys.color.on-primary-container": "MaterialTheme.colorScheme.onPrimaryContainer",
      "md.sys.color.on-secondary": "MaterialTheme.colorScheme.onSecondary",
      "md.sys.color.on-secondary-container": "MaterialTheme.colorScheme.onSecondaryContainer",
      "md.sys.color.on-surface": "MaterialTheme.colorScheme.onSurface",
      "md.sys.color.on-surface-variant": "MaterialTheme.colorScheme.onSurfaceVariant",
      "md.sys.color.on-tertiary": "MaterialTheme.colorScheme.onTertiary",
      "md.sys.color.on-tertiary-container": "MaterialTheme.colorScheme.onTertiaryContainer",
      "md.sys.color.outline": "MaterialTheme.colorScheme.outline",
      "md.sys.color.primary": "MaterialTheme.colorScheme.primary",
      "md.sys.color.primary-container": "MaterialTheme.colorScheme.primaryContainer",
      "md.sys.color.secondary": "MaterialTheme.colorScheme.secondary",
      "md.sys.color.secondary-container": "MaterialTheme.colorScheme.secondaryContainer",
      "md.sys.color.surface": "MaterialTheme.colorScheme.surface",
      "md.sys.color.surface-variant": "MaterialTheme.colorScheme.surfaceVariant",
      "md.sys.color.tertiary": "MaterialTheme.colorScheme.tertiary",
      "md.sys.color.tertiary-container": "MaterialTheme.colorScheme.tertiaryContainer"
    },
    "typography": {
      "symbols": {
        "md.sys.typescale.display-large": "MaterialTheme.typography.displayLarge",
        "md.sys.typescale.display-medium": "MaterialTheme.typography.displayMedium",
        "md.sys.typescale.display-small": "MaterialTheme.typography.displaySmall",
        "md.sys.typescale.headline-large": "MaterialTheme.typography.headlineLarge",
        "md.sys.typescale.headline-medium": "MaterialTheme.typography.headlineMedium",
        "md.sys.typescale.headline-small": "MaterialTheme.typography.headlineSmall",
        "md.sys.typescale.title-large": "MaterialTheme.typography.titleLarge",
        "md.sys.typescale.title-medium": "MaterialTheme.typography.titleMedium",
        "md.sys.typescale.title-small": "MaterialTheme.typography.titleSmall",
        "md.sys.typescale.body-large": "MaterialTheme.typography.bodyLarge",
        "md.sys.typescale.body-medium": "MaterialTheme.typography.bodyMedium",
        "md.sys.typescale.body-small": "MaterialTheme.typography.bodySmall",
        "md.sys.typescale.label-large": "MaterialTheme.typography.labelLarge",
        "md.sys.typescale.label-medium": "MaterialTheme.typography.labelMedium",
        "md.sys.typescale.label-small": "MaterialTheme.typography.labelSmall"
      },
      "subproperties": {
        "font": "fontFamily",
        "weight": "fontWeight",
        "size": "fontSize",
        "tracking": "letterSpacing",
        "line-height": "lineHeight"
      }
    },
    "options": {
      "packages": {
        "MaterialTheme": "androidx.compose.material3"
      }
    }
  }
}

Theme mapping files are structured with two top-level objects: figma and compose. Inside these objects, color and type definitions are linked between both environments through intermediate tokens. This allows multiple Figma styles to map to a single Compose theme entry, which is useful when you support light and dark themes.

  1. Review the mapping file, especially how it remaps typography properties from Figma to what Compose expects.

Reimport UI Packages

After you create a mapping file, you need to reimport all UI Packages into your project because all Figma style values were discarded upon initial import since a mapping file wasn't provided.

To reimport UI Packages, follow these steps:

  1. In Android Studio, click File > New > Import UI Packages. An Import UI Packages dialog appears.
  2. In the Figma source URL text box, enter the URL of the Figma source file.
  3. Select the Translate Figma styles to Compose theme checkbox.
  4. Select Import custom configuration. Click the folder icon then select the file you just created: src/main/ui-package-resources/style-mappings/figma_styles.json.
  5. Click Next. You see a preview of the file's UI Packages.
  6. Click Create. The packages are imported into your project.

The Import UI Packages dialog

  1. Rebuild your project and then open the switch/Switch.kt file to view the generated code.

Switch.kt (generated)

@Composable
fun ActiveOverlay(
    modifier: Modifier = Modifier,
    content: @Composable RelayContainerScope.() -> Unit
) {
    RelayContainer(
        backgroundColor = MaterialTheme.colorScheme.surfaceVariant,
        isStructured = false,
        radius = 32.0,
        content = content,
        modifier = modifier.fillMaxWidth(1.0f).fillMaxHeight(1.0f)
    )
}
  1. Notice how the backgroundColor parameter is set to the MaterialTheme.colorScheme.surfaceVariant field in the Compose theme object.
  2. Run the project and switch on dark mode in the emulator. The theme is correctly applied and the visual bugs are fixed.

6cf2aa19fabee292.png

9. Congratulations

Congratulations! You learned how to integrate Relay into your Compose apps!

Learn more