Create your first Tile in Wear OS

tiles swiping from time to weather to timer.

Watch the animation above (Tiles demo). Note: Animated gifs only animate through one time. If you missed the animation, reload the page.

Wear OS Tiles provide easy access to the information and actions users need in order to get things done. With a simple swipe from the watch face, a user can find out the latest forecast or start a timer.

Developing Tiles works a bit differently from writing an Android App.

A Tile runs as a part of the System UI instead of running in its own application container. This means it doesn't have access to some Android programming concepts that you might be familiar with like Activities and XML layouts.

Instead we use a Service to describe the layout and content of the Tile. The System UI will then render the Tile when needed.

In this codelab, you will learn how to write your own Wear OS Tile from scratch.

What you will learn

  • Create a Tile
  • Test a Tile on a device
  • Design a Tile layout
  • Add an image
  • Add interaction (tap)

What you will build

You'll build a custom Tile that shows the number of steps towards a daily goal. It includes a complex layout which will help you learn about different layout elements and containers.

Here's what it will look like when you are finished with the code lab:

c6e1959693cded21.png

Prerequisites

In this step, you will set up your environment and download a starter project.

What you will need

  • Latest stable version of Android Studio
  • Wear OS device or emulator (New to this? Here's how to set it up.)

Download code

If you have git installed, you can simply run the command below to clone the code from this repo. To check whether git is installed, type git --version in the terminal or command line and verify that it executes correctly.

git clone https://github.com/googlecodelabs/wear-tiles.git
cd wear-tiles

If you do not have git, you can click the following button to download all the code for this codelab:

Download ZIP

At any time you can run either module in Android Studio by changing the run configuration in the toolbar.

8a2e49d6d6d2609d.png

Open project in Android Studio

  1. On the Welcome to Android Studio window select 1f5145c42df4129a.png Open an Existing Project
  2. Select the folder [Download Location]
  3. When Android Studio has imported the project, test that you can run the start and finished modules on a Wear OS emulator or physical device.
  4. The start module should look like the screenshot below. It's where you will be doing all your work.

c72e8870facd8458.png

Explore the start code

  • build.gradle contains a basic app configuration. It includes the dependencies necessary to create a Tile.
  • main > AndroidManifest.xml includes the parts necessary to mark this as a Wear OS application. We'll review this file during the codelab.
  • main > GoalsRepository.kt contains a fake repository class that asynchronously retrieves a random number of steps that the user has set today.
  • main > GoalsTileService.kt contains boilerplate for creating a Tile. We will do most of our work in this file.
  • debug > AndroidManifest.xml contains an activity element for the TilePreviewActivity, so we can preview our tile.
  • debug > TilesPreviewActivity.kt contains the Activity that we will use to preview the Tile.

Let's start by opening GoalsTileService in the start module. As you can see, this class extends TileProviderService.

TileProviderService is a part of the Tiles library, which provides the methods we'll use to write our Tile:

  • onTileRequest() - Creates a Tile when the system requests one.
  • onResourcesRequest() - Provides any images needed for the Tile returned in onTileRequest().

We're using coroutines to work with the asynchronous nature of these methods. To learn more about coroutines, please read the Android coroutines documentation.

Let's start by creating a simple "Hello, world" Tile.

Before we jump in, note that we define a bunch of constants at the beginning of the file that we will use throughout the code lab. You can review them if you search for "TODO: Review Constants". They define a number of things including resource version to dp values (padding, size, etc.), sp values (text), identifiers, and more.

Ok, let's get coding.

In GoalsTileService.kt, search for "TODO: Build a Tile" and replace the entire

onTileRequest() implementation with code below.

Step 1

// TODO: Build a Tile.
override fun onTileRequest(requestParams: TileRequest) = serviceScope.future {

    Tile.builder()
        // If there are any graphics/images defined in the Tile's layout, the system will
        // retrieve them using onResourcesRequest() and match them with this version number.
        .setResourcesVersion(RESOURCES_VERSION)

        // Creates a timeline to hold one or more tile entries for a specific time periods.
        .setTimeline(
            Timeline.builder().addTimelineEntry(
                TimelineEntry.builder().setLayout(
                    Layout.builder().setRoot(
                        Text.builder().setText("Hello, world!")
                    )
                )
            )
        ).build()
}

The onTileRequest() method expects a Future to be returned and we use serviceScope.future { ... } to convert the coroutine to a Java ListenableFuture.

Outside of that, you really just need to create a Tile within the coroutine, so let's do that.

Within the onTileRequest(), we use a builder pattern to create a "Hello, world!" Tile. Read through the comments in the code block to see what is happening.

First, we set a resource version. The resource version returned in the payload from this method must match the resource version in the payload returned from onResourcesRequest() , regardless of if any resources are actually used.

It's a way to match those graphics with the correct version when the system calls onResourcesRequest() to get those graphics.

However, for this simple "Hello, world" Tile, we don't have any graphics.

Next, we create a Timeline.

A Timeline consists of one or more TimelineEntry instances. Each of these describes a layout for a specific time interval. You could create multiple TimelineEntry values that will occur in the future, and the system will render those at those future times.

fbb666b722376749.png

You can read more about working with timelines in the timeline guide.

In this example, we only declared one TimelineEntry instance because we just want one Tile for all times. We then set the layout using setLayout.

The root can be constructed of one or more complex layouts, but for this step, we'll create a simple Text layout element which displays "Hello, world!".

That's it!

Now that we've created a very simple Tile, let's preview it in an Activity.

To preview this Tile inside an Activity, open the TilePreviewActivity in your app's debug source folder.

Search for "TODO: Review creation of Tile for Preview" and add the following code after it.

Step 2

// TODO: Review creation of Tile for Preview.
tileClient = TileClient(
    context = this,
    component = ComponentName(this, GoalsTileService::class.java),
    parentView = rootLayout
)
tileClient.connect()

Most of this is pretty self-explanatory.

You create a TileClient, which we can use to preview the Tile and set the context, component (the tile service class we've been working on), and the parent view where we insert the Tile.

After that, we create the tile using connect().

You'll also notice we do some cleanup in onDestroy() using tileClient.close().

Now run your app on your Wear OS emulator or device (make sure you choose the start module). You should be able to see a centered "Hello, world!" inside the screen:

b9976e1073554422.png

Now that we have a basic layout working, let's expand on it and create a more complex Tile. Our end goal will be this layout:

c6e1959693cded21.png

Replace root layout with a Box

In the GoalsTileService.kt file replace the entire onTileRequest method, which includes the "Hello, world!" text label, with the code below. You can search for "TODO: Build a Tile" just like the previous step.

Step 3

// TODO: Build a Tile.
override fun onTileRequest(requestParams: TileRequest) = serviceScope.future {

    // Retrieves progress value to populate the Tile.
    val goalProgress = GoalsRepository.getGoalProgress()
    // Retrieves device parameters to later retrieve font styles for any text in the Tile.
    val deviceParams = requestParams.deviceParameters!!

    // Creates Tile.
    Tile.builder()
        // If there are any graphics/images defined in the Tile's layout, the system will
        // retrieve them using onResourcesRequest() and match them with this version number.
        .setResourcesVersion(RESOURCES_VERSION)
        // Creates a timeline to hold one or more tile entries for a specific time periods.
        .setTimeline(
            Timeline.builder().addTimelineEntry(
                TimelineEntry.builder().setLayout(
                    Layout.builder().setRoot(
                        // Creates the root [Box] [LayoutElement]
                        layout(goalProgress, deviceParams)
                    )
                )
            )
        ).build()
}

Read through the comments in the code block to see what is happening. We haven't defined layout() yet, so don't worry about the error.

In the first piece of code in the method, we start by retrieving the actual goal progress from a (fake) repository.

We also retrieve the deviceParameters passed into onTileRequest() that we'll use later to build the font style our text labels.

The next chunk of code is creating the Tile again as before. You might notice that almost all the code is the same!

In fact, we only changed the line where we set the root to layout(goalProgress, deviceParameters).

As called out earlier, you should see a red line under the method name because it doesn't exist!

It's a common pattern to break up your layout into several logical pieces and define those in their own methods to prevent deeply nested code. So next we'll define the layout method!

Search for "TODO: Create root Box layout and content" and add the code below..

Step 4

    // TODO: Create root Box layout and content.
    // Creates a simple [Box] container that lays out its children one over the other. In our
    // case, an [Arc] that shows progress on top of a [Column] that includes the current steps
    // [Text], the total steps [Text], a [Spacer], and a running icon [Image].
    private fun layout(goalProgress: GoalProgress, deviceParameters: DeviceParameters) =
        Box.builder()
            // Sets width and height to expand and take up entire Tile space.
            .setWidth(expand())
            .setHeight(expand())

            // Adds an [Arc] via local function.
            .addContent(progressArc(goalProgress.percentage))

            // TODO: Add Column containing the rest of the data.
            // TODO: START REPLACE THIS LATER
            .addContent(
                Text.builder()
                    .setText("REPLACE ME!")
                    .setFontStyle(FontStyles.display3(deviceParameters))
            )
            // TODO: END REPLACE THIS LATER

            .build()

Read through the comments in the code block to see what is happening. We haven't defined progressArc() yet, so don't worry about any errors for that method call or on setRoot().

Our layout method contains a simple Box container, which is one of the many Tile layout containers that let you arrange child elements. For the Box container specifically, children are laid out one over the other. Inside the box, we add some content. In this case, we are calling a method which doesn't exist yet!

We will again follow the pattern of breaking out methods for complex layouts, so they are easier to read.

You might notice a couple additional TODOs near the bottom of the code (above the build() call). We will return to these later in the codelab.

For now, let's fix that error and define our local method that will finally declare the arc we've been looking for.

Create an ArcLine

We first want to create an arc around the edge of the screen that fills up as the user increases their step count.

The Tiles API offers a number of Arc container options. We will use the ArcLine which renders a curved line around the Arc.

Let's now define that function, so we can get rid of that pesky error.

Find "TODO: Create a function that constructs an Arc representation of the current step progress" and add the code below.

Step 5

    // TODO: Create a function that constructs an Arc representation of the current step progress.
    // Creates an [Arc] representing current progress towards steps goal.
    private fun progressArc(percentage: Float) = Arc.builder()
        .addContent(
            ArcLine.builder()
                // Uses degrees() helper to build an [AngularDimension] which represents progress.
                .setLength(degrees(percentage * ARC_TOTAL_DEGREES))
                .setColor(argb(ContextCompat.getColor(this, R.color.primary)))
                .setThickness(PROGRESS_BAR_THICKNESS)
        )
        // Element will start at 12 o'clock or 0 degree position in the circle.
        .setAnchorAngle(degrees(0.0f))
        // Aligns the contents of this container relative to anchor angle above.
        // ARC_ANCHOR_START - Anchors at the start of the elements. This will cause elements
        // added to an arc to begin at the given anchor_angle, and sweep around to the right.
        .setAnchorType(ARC_ANCHOR_START)
        .build()

Read through the comments in the code block to see what is happening. (Note: You may see errors for degrees(), argb(), and ContextCompat. If you do, simply import the tiles versions by clicking on them.)

In this piece of code, we're returning an Arc.Builder with an ArcLine representing our step goal progress.

We set the length as the progress towards our finished goal using a ratio (converted to degrees), the color, and the thickness.

Next we specify where we want our arc to start with the anchor type. There are lots of different options for the anchor, feel free to play around with all them after the code lab.

Ok, we're done with this part!

Let's see it in action. Run the app again and you should see something like this:

ad79daf115d3b6a4.png

Add a Column container

Now that our Tile has a nice progress indicator, let's add some proper text.

Since we will be adding multiple text fields (and later an image), we want the items to be in a Column down the center of the screen.

A Column is another one of the many Tile layout containers, and it allows us to lay out child elements vertically, one after another.

Find "TODO: Add Column containing the rest of the data" and replace the temporary text content with the code below. That would be everything from TODO: START REPLACE THIS LATER to TODO: END REPLACE THIS LATER.

Remember not to remove the build() call at the end of the code block from the original.

Step 6

            // TODO: Add Column containing the rest of the data.
            // Adds a [Column] containing the two [Text] objects, a [Spacer], and a [Image].
            .addContent(
                Column.builder()
                    // Adds a [Text] using local function.
                    .addContent(
                        currentStepsText(goalProgress.current.toString(), deviceParameters)
                    )
                    // Adds a [Text] using local function.
                    .addContent(
                        totalStepsText(
                            resources.getString(R.string.goal, goalProgress.goal),
                            deviceParameters
                        )
                    )
                    // TODO: Add Spacer and Image representations of our step graphic.
                    // DO LATER
            )


Read through the comments in the code block to see what is happening.

We are creating a column adding two pieces of content. We now have two errors!

Any idea what is going on?

Again, we follow the pattern of breaking out methods for our complex layouts (and we can ignore the DO LATER TODO).

Let's fix those errors.

Add Text elements

Find "TODO: Create functions that construct/stylize Text representations of the step count & goal" and add the following code below it.

Step 7

    // TODO: Create functions that construct/stylize Text representations of the step count & goal.
    // Creates a [Text] with current step count and stylizes it.
    private fun currentStepsText(current: String, deviceParameters: DeviceParameters) = Text.builder()
        .setText(current)
        .setFontStyle(FontStyles.display2(deviceParameters))
        .build()

    // Creates a [Text] with total step count goal and stylizes it.
    private fun totalStepsText(goal: String, deviceParameters: DeviceParameters) = Text.builder()
        .setText(goal)
        .setFontStyle(FontStyles.title3(deviceParameters))
        .build()

Read through the comments in the code block to see what is happening.

It's pretty straightforward. Through two different functions, we create separate Text layout elements.

As you might have guessed, a Text layout element renders a string of text (optionally wrapping it).

We set the font size from constants defined earlier in this class, and, finally, we set the font styles from the styles we retrieved in onTileRequest().

Now we have some text. Let's see how our Tile looks now. Run it and you should see something like this.

9eaca483c7e51f38.png

Add the image name to onTileRequest()

For the final portion of our UI, we will add an image.

Find "TODO: Add Spacer and Image representations of our step graphic" and replace DO LATER with the code below (make sure not to erase the trailing ‘)' character).

Also, remember not to remove the build() call at the end of the code block from the original.

Step 8

                // TODO: Add Spacer and Image representations of our step graphic.
                // Adds a [Spacer].
                .addContent(Spacer.builder().setHeight(VERTICAL_SPACING_HEIGHT))
                // Adds an [Image] using local function.
                .addContent(startRunButton())

Read through the comments in the code block to see what is happening.

First, we add a Spacer layout element to provide padding between elements, in our case, the image that follows.

Next, we add the content for an Image layout element which renders an image, but as you guessed by the error, we are defining that in a separate local function.

Find "TODO: Create a function that constructs/stylizes a clickable Image of a running icon" and add the code below.

Step 9

    // TODO: Create a function that constructs/stylizes a clickable Image of a running icon.
    // Creates a running icon [Image] that's also a button to refresh the tile.
    private fun startRunButton() =
        Image.builder()
            .setWidth(BUTTON_SIZE)
            .setHeight(BUTTON_SIZE)
            .setResourceId(ID_IMAGE_START_RUN)
            .setModifiers(
                Modifiers.builder()
                    .setPadding(
                        Padding.builder()
                            .setStart(BUTTON_PADDING)
                            .setEnd(BUTTON_PADDING)
                            .setTop(BUTTON_PADDING)
                            .setBottom(BUTTON_PADDING)
                    )
                    .setBackground(
                        Background.builder()
                            .setCorner(Corner.builder().setRadius(BUTTON_RADIUS))
                            .setColor(argb(ContextCompat.getColor(this, R.color.primaryDark)))
                    )
                    // TODO: Add click (START)
                    // DO LATER
                    // TODO: Add click (END)
            )
            .build()

Read through the code block to see what is happening.

As always, ignore the DO LATER TODO for now.

Most of the code is self explanatory. We set various dimensions and styles with constants we define at the top of the class. To learn more about modifiers, click here.

The most important thing to note is the .setResourceId(ID_IMAGE_START_RUN) call.

We are setting the name of the image to a constant we define at the top of the class.

As a final step, we need to map that constant name to an actual image in our application.

Add the image mapping to onResourcesRequest()

Tiles don't have access to any of your app's resources. This means that you can't pass an Android image ID to an Image layout element and expect it to resolve. Instead, you need to override the onResourcesRequest() method and provide any resources manually.

There are two ways to provide images within the onResourcesRequest() method:

Use setAndroidResourceByResId()to map the name we used for the image earlier to an actual image.

Find "TODO: Supply resources (graphics) for the Tile" and replace the entire existing method with the code below.

Step 10

    // TODO: Supply resources (graphics) for the Tile.
    override fun onResourcesRequest(requestParams: ResourcesRequest) = serviceScope.future {
        Resources.builder()
            .setVersion(RESOURCES_VERSION)
            .addIdToImageMapping(
                ID_IMAGE_START_RUN,
                ImageResource.builder()
                    .setAndroidResourceByResId(
                        AndroidImageResourceByResId.builder()
                            .setResourceId(R.drawable.ic_run)
                    )
            )
            .build()
    }

Read through the code block to see what is happening.

As you remember from our first steps with onTileRequest(), we set a resource version number.

Here, we set the same resource version number in our Resources builder to match the tile to the right resources.

Next, we need to map any Image layout elements we created to the actual image using the addIdToImageMapping() method. You can see we use the same constant name as before, ID_IMAGE_START_RUN , and now we set the specific drawable we want returned in .setResourceId(R.drawable.ic_run).

Now, run it and you should see the finished UI!

c6e1959693cded21.png

As our final step, we want to add a click action to our tile. This could open an Activity within your Wear OS app or, in the case of this code lab, just trigger an update to the Tile itself.

Find "TODO: Add click (START)" and replace the DO LATER comment with the code below.

Remember not to remove the build() call at the end of the code block from the original.

Step 11

                    // TODO: Add click (START)
                    .setClickable(
                        Clickable.builder()
                            .setId(ID_CLICK_START_RUN)
                            .setOnClick(ActionBuilders.LoadAction.builder())
                    )
                    // TODO: Add click (END)

Read through the code block to see what is happening.

By adding the Clickable modifier to a layout element, you can react to a user tapping that layout element. As a reaction to a click event, you can perform two actions:

In our case, we set a clickable modifier but use the LoadAction to refresh the Tile itself with the simple line ActionBuilders.LoadAction.builder(). This triggers a call to onTileRequest() but passes the id we set, ID_CLICK_START_RUN.

If we wanted to, we could check for the last clickable id passed to onTileRequest() and render a different Tile based on that id. It would look something like this:

// Example of getting the clickable Id
override fun onTileRequest(requestParams: TileRequest) = serviceScope.future {

    if (requestParams.state.lastClickableId == ID_CLICK_START_RUN) {
        // Create start run tile...
    } else {
        // Create default tile...
    }
}

In our case, we aren't doing that. We simply refresh the Tile (and a new value is pulled from our mock database).

To learn about the additional options for interacting with a tile, please review our guide.

Now run the Tile again. You will see when you click on the button, the step value changes!

e15bba88abc0d832.png

You've probably guessed that we defined the service we've been editing somewhere in the manifest.

Find "TODO: Review service" in the AndroidManifest.xml file and you should see the code below.

There is no step here, just a review of the existing code.

<!-- TODO: Review service -->
<service
   android:name="com.example.wear.tiles.GoalsTileService"
   android:label="@string/fitness_tile_label"
   android:description="@string/tile_description"
   android:icon="@drawable/ic_run"
   android:permission="com.google.android.wearable.permission.BIND_TILE_PROVIDER">
   <intent-filter>
       <action android:name="androidx.wear.tiles.action.BIND_TILE_PROVIDER" />
   </intent-filter>

   <!-- The tile preview shown when configuring tiles on your phone -->
   <meta-data
       android:name="androidx.wear.tiles.PREVIEW"
       android:resource="@drawable/tile_goals" />
</service>

Read through the code block to see what is happening.

This is similar to a normal service, but we added a couple specific elements for our Tile:

  1. A permission to bind the tile provider
  2. An intent filter registering the service as a Tile provider
  3. Additional meta data specifying a preview image to be viewed on the phone

Congratulations! You learned how to build a Tile for Wear OS!

What's next?

Check out the other Wear OS codelabs:

Further reading