Create your first Tile in Wear OS

1. Introduction

animated watch, user swiping the watch face to the first tile which is a forecast, then to a timer tile, and back

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.

A tile runs as a part of the system UI instead of running in its own application container. We use a Service to describe the layout and content of the tile. The system UI will then render the tile when needed.

What you'll do

35a459b77a2c9d52.png

You'll build a tile for a messaging app, which shows recent conversations. From this surface, the user can jump straight into 1 of 3 common tasks:

  • Open a conversation
  • Search for a conversation
  • Compose a new message

What you'll learn

In this codelab, you will learn how to write your own Wear OS Tile, including how to:

  • Create a TileService
  • Test a tile on a device
  • Preview UI for a tile in Android Studio
  • Develop UI for a tile
  • Add images
  • Handle interactions

Prerequisites

  • Basic understanding of Kotlin

2. Getting set up

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

What you will need

  • Android Studio Dolphin (2021.3.1) or newer
  • Wear OS device or emulator

If you're unfamiliar with using Wear OS, reading this quick guide before starting will be helpful. It includes instructions for setting up a Wear OS emulator, and describes how to navigate around the system.

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/android/codelab-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:

Open project in Android Studio

On the "Welcome to Android Studio" window, select c01826594f360d94.png Open an Existing Project or File > Open and select the folder [Download Location].

3. Create a basic tile

The entry point for a tile is the tile service. In this step, you'll register a tile service and define a layout for the tile.

HelloWorldTileService

A class implementing TileService needs to specify two functions:

  • onResourcesRequest(requestParams: ResourcesRequest): ListenableFuture<Resources>
  • onTileRequest(requestParams: TileRequest): ListenableFuture<Tile>

The first one maps string IDs to an image resource. This is where we provide image resources that we'll use in our tile.

The second returns a description of a tile, including its layout. This is where we define the layout of a tile, and how data is bound to it.

Open HelloWorldTileService.kt from the start module. All of the changes you'll make will be in this module. There is also a finished module if you want to have a look at the result of this codelab.

HelloWorldTileService extends CoroutinesTileService, a Kotlin coroutine-friendly wrapper from the Horologist Tiles library. Horologist is a group of libraries from Google, that aim to supplement Wear OS developers with features that are commonly required by developers but not yet available in Jetpack.

CoroutinesTileService provides two suspending functions, which are coroutine versions of the functions from TileService:

  • suspend resourcesRequest(requestParams: ResourcesRequest): Resources
  • suspend tileRequest(requestParams: TileRequest): Tile

To learn more about coroutines, check out the documentation for Kotlin coroutines on Android.

HelloWorldTileService isn't complete yet. We need to register the service in our manifest and we also need to provide an implementation for tileLayout.

Register the tile service

It's necessary to register the tile service in the manifest so that the system knows about it. Once it's registered, it'll show up in the list of available tiles for the user to add.

Add the <service> inside the <application> element:

start/src/main/AndroidManifest.xml

<service
    android:name="com.example.wear.tiles.hello.HelloWorldTileService"
    android:icon="@drawable/ic_waving_hand_24"
    android:label="@string/hello_tile_label"
    android:description="@string/hello_tile_description"
    android:exported="true"
    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_hello" />
</service>

The icon and label are used (as a placeholder) when the tile loads for the first time, or if there's an error loading the tile. The meta-data at the end defines a preview image that's shown in the carousel when the user is adding a tile.

Define a layout for the tile

HelloWorldTileService has a function called tileLayout with a TODO() as the body. Let's now replace that with an implementation where we define the layout for our tile, and bind data:

start/src/main/java/com/example/wear/tiles/hello/HelloWorldTileService.kt

private fun tileLayout(): LayoutElement {
    val text = getString(R.string.hello_tile_body)
    return LayoutElementBuilders.Box.Builder()
        .setVerticalAlignment(LayoutElementBuilders.VERTICAL_ALIGN_CENTER)
        .setWidth(DimensionBuilders.expand())
        .setHeight(DimensionBuilders.expand())
        .addContent(
            LayoutElementBuilders.Text.Builder()
                .setText(text)
                .build()
        )
        .build()
}

We create a Text element and set it inside a Box so that we can do some basic alignment.

And that's your first Wear OS Tile created! Let's install this tile and see what it looks like.

4. Test your tile on a device

With the start module selected in the run configuration dropdown, you could install the app (the start module) on your device or emulator and manually install the tile, as a user would.

Instead, let's use Direct Surface Launch, a feature introduced with Android Studio Dolphin, to create a new run configuration to launch our tile directly from Android Studio. Select "Edit Configurations..." from the dropdown in the top panel.

Run configuration dropdown from the top panel in Android Studio. Edit configurations is highlighted.

Click the "Add new configuration" button and choose "Wear OS Tile". Add a descriptive name then select the Tiles_Code_Lab.start module and the HelloWorldTileService tile.

Press "OK" to finish.

Edit Configuration menu with a Wear OS Tile called HelloTile being configured.

Direct Surface Launch allows us to quickly test tiles on a Wear OS emulator or physical device. Try it out by running "HelloTile". It should look like the screenshot below.

Round watch showing 'Time to create a tile!' in white writing on a black background

5. Build a messaging tile

Round watch showing 5 round buttons arranged in a 2x3 pyramid. The 1st and 3rd button show initials in a purple text, the 2nd and 4th show profile pictures, and the last button is a search icon. Below the buttons is a purple compact chip that reads 'New' in black text.

The messaging tile we're about to build is more typical of a real-world tile. Unlike the HelloWorld example, this one loads data from a local repository, fetches images to display from the network and handles interactions to open the app, directly from the tile.

MessagingTileService

MessagingTileService extends the CoroutinesTileService class we saw earlier.

The main difference between this and the previous example is that we're now observing data from the repository, and also fetching image data from the network.

For any potentially long-running work (e.g. network calls), it would be more appropriate to use something like WorkManager, because the tile service functions have relatively short time-outs. In this codelab, we won't introduce WorkManager—to try it out yourself, check out this codelab.

MessagingTileRenderer

MessagingTileRenderer extends the TileRenderer class (another abstraction from Horologist Tiles). It's completely synchronous - state is passed to the renderer functions, which makes it easier to use in tests and Android Studio previews.

In the next step, we'll look at how to add Android Studio previews for tiles.

6. Add preview functions

We can preview tile UI in Android Studio using TileLayoutPreview (and similar) from Horologist Tiles. This shortens the feedback loop when developing UI, making it a lot faster to iterate.

We'll use tooling from Jetpack Compose to see this preview—that's why you'll see the @Composable annotation on the preview function below. You can learn more about composable previews but it's not necessary to complete this codelab.

Add a composable preview for the MessagingTileRenderer at the end of the file.

start/src/main/java/com/example/wear/tiles/messaging/tile/MessagingTileRenderer.kt

@WearDevicePreview
@Composable
fun MessagingTileRendererPreview() {
    TileLayoutPreview(
        state = MessagingTileState(MessagingRepo.knownContacts),
        resourceState = emptyMap(),
        renderer = MessagingTileRenderer(LocalContext.current)
    )
}

Note that the composable function uses TileLayoutPreview; we cannot preview tile layouts directly.

Use the "Split" editor mode to see a preview of the tile:

split screen view of Android Studio with preview code on the left and an image of the tile on the right.

We're passing artificial data in MessagingTileState, and we don't have any resource state (yet) so we can pass an empty map.

In the next step, we'll use Tiles Material to update the layout.

7. Add Tiles Material

Tiles Material provides pre-built Material components and layouts, allowing you to create tiles that embrace the latest Material design for Wear OS.

Add the Tiles Material dependency to your build.gradle file:

start/build.gradle

implementation "androidx.wear.tiles:tiles-material:$tilesVersion"

Depending on the complexity of your design, it can be useful to colocate the layout code with the renderer, using top-level functions in the same file to encapsulate a logical unit of the UI.

Add the code for the button to the bottom of the renderer file, and the preview as well:

start/src/main/java/MessagingTileRenderer.kt

private fun searchLayout(
    context: Context,
    clickable: ModifiersBuilders.Clickable,
) = Button.Builder(context, clickable)
    .setContentDescription(context.getString(R.string.tile_messaging_search))
    .setIconContent(MessagingTileRenderer.ID_IC_SEARCH)
    .setButtonColors(ButtonColors.secondaryButtonColors(MessagingTileTheme.colors))
    .build()

@IconSizePreview
@Composable
private fun SearchButtonPreview() {
    LayoutElementPreview(
        searchLayout(
            context = LocalContext.current,
            clickable = emptyClickable
        )
    ) {
        addIdToImageMapping(
            MessagingTileRenderer.ID_IC_SEARCH,
            drawableResToImageResource(R.drawable.ic_search_24)
        )
    }
}

LayoutElementPreview is similar to TileLayoutPreview but it's used for individual components like a button, chip or label. The trailing lambda at the end lets us specify the resource ID mapping (to image resources), so here we're mapping ID_IC_SEARCH to the search image resource.

Using the "Split" editor mode, we can see a preview of the search button:

A vertically stacked set of previews, tile on the top, and a search icon button below.

We can do something similar to build the contact layout too:

start/src/main/java/com/example/wear/tiles/messaging/tile/MessagingTileRenderer.kt

private fun contactLayout(
    context: Context,
    contact: Contact,
    clickable: ModifiersBuilders.Clickable,
) = Button.Builder(context, clickable)
    .setContentDescription(contact.name)
    .apply {
        if (contact.avatarUrl != null) {
            setImageContent(contact.imageResourceId())
        } else {
            setTextContent(contact.initials)
            setButtonColors(ButtonColors.secondaryButtonColors(MessagingTileTheme.colors))
        }
    }
    .build()

Tiles Material doesn't just include components. Instead of using a series of nested columns and rows, we can use layouts from Tiles Material to quickly achieve our desired look.

Here, we can use PrimaryLayout and MultiButtonLayout to arrange 4 contacts and the search button. Update the messagingTileLayout() function in MessagingTileRenderer with these layouts:

start/src/main/java/com/example/wear/tiles/messaging/tile/MessagingTileRenderer.kt

private fun messagingTileLayout(
    context: Context,
    deviceParameters: DeviceParametersBuilders.DeviceParameters,
    state: MessagingTileState
) = PrimaryLayout.Builder(deviceParameters)
    .setContent(
        MultiButtonLayout.Builder()
            .apply {
                // In a PrimaryLayout with a compact chip at the bottom, we can fit 5 buttons.
                // We're only taking the first 4 contacts so that we can fit a Search button too.
                state.contacts.take(4).forEach { contact ->
                    addButtonContent(
                        contactLayout(
                            context = context,
                            contact = contact,
                            clickable = emptyClickable
                        )
                    )
                }
            }
            .addButtonContent(searchLayout(context, emptyClickable))
            .build()
    )
    .build()

Preview of tile with 5 buttons in a 2x3 pyramid. The 2nd and 3rd buttons are blue filled circles, indicating missing images.

MultiButtonLayout supports up to 7 buttons, and will lay them out with the appropriate spacing for you. Let's add the "New" chip to the PrimaryLayout too, on the PrimaryLayout builder in the messagingTileLayout() function:

start/src/main/java/com/example/wear/tiles/messaging/tile/MessagingTileRenderer.kt

.setPrimaryChipContent(
    CompactChip.Builder(
        /* context = */ context,
        /* text = */ context.getString(R.string.tile_messaging_create_new),
        /* clickable = */ emptyClickable,
        /* deviceParameters = */ deviceParameters
    )
        .setChipColors(ChipColors.primaryChipColors(MessagingTileTheme.colors))
        .build()
)

preview of tile with 5 buttons and a compact chip below that reads 'new'

In the next step, we'll fix the missing images.

8. Add images

Displaying a local image on a tile is a straightforward task: provide a mapping from a string ID (that you use in your layout) to the image, using the convenience function from Horologist Tiles to load the drawable and transform it into an image resource. An example is available in the SearchButtonPreview:

start/src/main/java/com/example/wear/tiles/messaging/tile/MessagingTileRenderer.kt

addIdToImageMapping(
    ID_IC_SEARCH,
    drawableResToImageResource(R.drawable.ic_search_24)
)

For the messaging tile, we also need to load images from the network (not just local resources), and for that we use Coil, a Kotlin coroutine-based image loader.

The code is already written for this:

start/src/main/java/com/example/wear/tiles/messaging/tile/MessagingTileService.kt

override suspend fun resourcesRequest(requestParams: ResourcesRequest): Resources {
    val avatars = imageLoader.fetchAvatarsFromNetwork(
        context = this@MessagingTileService,
        requestParams = requestParams,
        tileState = latestTileState()
    )
    return renderer.produceRequestedResources(avatars, requestParams)
}

Since the tile renderer is completely synchronous, the tile service is what fetches bitmaps from the network. As before, depending on the size of the image, it could be more appropriate to use WorkManager to fetch the images ahead of time, but for this codelab we're fetching them directly.

We pass the avatars map (Contact to Bitmap) to the renderer as "state" for the resources. Now the renderer can transform these bitmaps to image resources for tiles.

This code is also already written:

start/src/main/java/com/example/wear/tiles/messaging/tile/MessagingTileRenderer.kt

override fun ResourceBuilders.Resources.Builder.produceRequestedResources(
    resourceState: Map<Contact, Bitmap>,
    deviceParameters: DeviceParametersBuilders.DeviceParameters,
    resourceIds: MutableList<String>
) {
    addIdToImageMapping(
        ID_IC_SEARCH,
        drawableResToImageResource(R.drawable.ic_search_24)
    )

    resourceState.forEach { (contact, bitmap) ->
        addIdToImageMapping(
            /* id = */ contact.imageResourceId(),
            /* image = */ bitmap.toImageResource()
        )
    }
}

So, if the service is fetching the bitmaps, and the renderer is transforming those bitmaps to image resources, why is the tile not displaying images?

It is! If you run the tile on a device (with internet access), you should see the images do indeed load. The problem is solely within our preview, because we're still passing an emptyMap() for the resourceState.

For the real tile, we're fetching bitmaps from the network and mapping them to different contacts, but for previews and tests, we don't need to hit the network at all.

Update MessagingTileRendererPreview() so that we're providing bitmaps for the two contacts that need one:

start/src/main/java/com/example/wear/tiles/messaging/tile/MessagingTileRenderer.kt

@WearDevicePreview
@Composable
fun MessagingTileRendererPreview() {
    val state = MessagingTileState(MessagingRepo.knownContacts)
    val context = LocalContext.current
    TileLayoutPreview(
        state = state,
        resourceState = mapOf(
            state.contacts[1] to (context.getDrawable(R.drawable.ali) as BitmapDrawable).bitmap,
            state.contacts[2] to (context.getDrawable(R.drawable.taylor) as BitmapDrawable).bitmap,
        ),
        renderer = MessagingTileRenderer(context)
    )
}

Now, if we refresh the preview, the images should display:

preview of tile with 5 buttons, this time with photos in the two buttons that were blue circles

In the next step, we'll handle clicks on each of the elements.

9. Handle interactions

One of the most useful things we can do with a tile is provide shortcuts to critical user journeys. This is different from the app launcher which just opens the app - here, we have space to provide contextual shortcuts into a specific screen in your app.

So far, we've been using emptyClickable for the chip and each of the buttons. This is fine for previews, which aren't interactive, but let's take a look at how to add actions for the elements.

Two builders from the ‘ActionBuilders' class define Clickable actions: LoadAction and LaunchAction.

LoadAction

A LoadAction can be used if you want to perform logic in the tile service when the user clicks an element, e.g. incrementing a counter.

.setClickable(
    Clickable.Builder()
        .setId(ID_CLICK_INCREMENT_COUNTER)
        .setOnClick(ActionBuilders.LoadAction.Builder().build())
        .build()
    )
)

When this is clicked, onTileRequest will be called in your service (tileRequest in CoroutinesTileService) so it's a good opportunity to refresh the tile UI:

override suspend fun tileRequest(requestParams: TileRequest): Tile {
    if (requestParams.state.lastClickableId == ID_CLICK_INCREMENT_COUNTER) {
        // increment counter
    }
    // return an updated tile
}

LaunchAction

LaunchAction can be used to launch an activity. In MessagingTileRenderer let's update the clickable for the search button.

The search button is defined by the searchLayout() function in MessagingTileRenderer. It already takes a Clickable as a parameter, but so far, we've been passing emptyClickable, a no-op implementation which does nothing when the button is clicked.

Let's update messagingTileLayout() so that it passes a real click action. Add the searchButtonClickable parameter, and pass it to searchLayout():

start/src/main/java/com/example/wear/tiles/messaging/tile/MessagingTileRenderer.kt

private fun messagingTileLayout(
    context: Context,
    deviceParameters: DeviceParametersBuilders.DeviceParameters,
    state: MessagingTileState,
    searchButtonClickable: ModifiersBuilders.Clickable
...
    .addButtonContent(searchLayout(context, searchButtonClickable))

We also need to update renderTile which is where we call messagingTileLayout, since we just added a new parameter (searchButtonClickable). We'll use the launchActivityClickable() function to create a new clickable, passing the openSearch() ActionBuilder as the action:

start/src/main/java/com/example/wear/tiles/messaging/tile/MessagingTileRenderer.kt

override fun renderTile(
    state: MessagingTileState,
    deviceParameters: DeviceParametersBuilders.DeviceParameters
): LayoutElementBuilders.LayoutElement {
    return messagingTileLayout(
        context = context,
        deviceParameters = deviceParameters,
        state = state,
        searchButtonClickable = launchActivityClickable("search_button", openSearch())
    )
}

Open launchActivityClickable to see how these functions (already defined) work:

start/src/main/java/com/example/wear/tiles/messaging/tile/ClickableActions.kt

internal fun launchActivityClickable(
    clickableId: String,
    androidActivity: ActionBuilders.AndroidActivity
) = ModifiersBuilders.Clickable.Builder()
    .setId(clickableId)
    .setOnClick(
        ActionBuilders.LaunchAction.Builder()
            .setAndroidActivity(androidActivity)
            .build()
    )
    .build()

It's very similar to the LoadAction - the main difference is that we call setAndroidActivity. In the same file, we've got various ActionBuilder.AndroidActivity examples.

For openSearch, which we're using for this clickable, we call setMessagingActivity and pass a string extra to identify which button click this was.

start/src/main/java/com/example/wear/tiles/messaging/tile/ClickableActions.kt

internal fun openSearch() = ActionBuilders.AndroidActivity.Builder()
    .setMessagingActivity()
    .addKeyToExtraMapping(
        MainActivity.EXTRA_JOURNEY,
        ActionBuilders.stringExtra(MainActivity.EXTRA_JOURNEY_SEARCH)
    )
    .build()

...

internal fun ActionBuilders.AndroidActivity.Builder.setMessagingActivity(): ActionBuilders.AndroidActivity.Builder {
    return setPackageName("com.example.wear.tiles")
        .setClassName("com.example.wear.tiles.messaging.MainActivity")
}

Run the tile and click the search button. It should open the MainActivity and display text to confirm the search button was clicked.

Adding actions for the others are similar. ClickableActions contains the functions you need. If you need a hint, check out MessagingTileRenderer from the finished module.

10. Congratulations

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

What's next?

For more information, check out the Golden Tiles implementations on GitHub and the Wear OS Tiles guide.