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 into three 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

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.

git clone https://github.com/android/codelab-wear-tiles.git
cd codelab-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 methods:

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

The first method returns a Resources object that maps string IDs to the image resources 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 SuspendingTileService, 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.

SuspendingTileService provides two suspending functions, which are coroutine equivalents 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

Once the tile service is registered in the manifest, 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, just as a user would.

However, for development, 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 SuspendingTileService 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.

MessagingTileRenderer

MessagingTileRenderer extends the SingleTileLayoutRenderer 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 the Tile preview functions that were released in version 1.4 of the Jetpack Tiles library ( currently in alpha). This shortens the feedback loop when developing UI, increasing development velocity.

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

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

@Preview(device = WearDevices.SMALL_ROUND)
@Preview(device = WearDevices.LARGE_ROUND)
fun messagingTileLayoutPreview(context: Context): TilePreviewData {
    return TilePreviewData { request ->
        MessagingTileRenderer(context).renderTimeline(
            MessagingTileState(knownContacts),
            request
        )
    }
}

Note that the @Composable annotation is not provided—although Tiles uses the same preview UI as Composable functions, Tiles do not make use of Compose, and are not composable.

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.

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.protolayout:protolayout-material:$protoLayoutVersion"

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()

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)
    .setResponsiveContentInsetEnabled(true)
    .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()

96fee80361af2c0f.png

MultiButtonLayout supports up to 7 buttons, and will lay them out with the appropriate spacing for you.

Let's add a "New" CompactChip as PrimaryLayout's "primary" chip 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()
    )

2041bdca8a46458b.png

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

8. Add images

At a high level, Tiles consist of two things: layout elements (which references resources by string ids) and the resources themselves (which can be images).

Making a local image available is a straightforward task: whilst you can't use Android drawable resources directly, you can trivially convert them to the required format using a convenience function provided by Horologist. Then, use the function addIdToImageMapping to associate the image with the resource identifier. For example:

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

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

For remote images, use Coil, a Kotlin coroutine-based image loader, to load the over the network.

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 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: List<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 haven't passed any resources to TilePreviewData().

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.

We need to make two changes. First, create a function previewResources(), that returns a Resources object:

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

private fun previewResources() = Resources.Builder()
    .addIdToImageMapping(ID_IC_SEARCH, drawableResToImageResource(R.drawable.ic_search_24))
    .addIdToImageMapping(knownContacts[1].imageResourceId(), drawableResToImageResource(R.drawable.ali))
    .addIdToImageMapping(knownContacts[2].imageResourceId(), drawableResToImageResource(R.drawable.taylor))
    .build()

Second, update messagingTileLayoutPreview() to pass in the resources:

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

@Preview(device = WearDevices.SMALL_ROUND)
@Preview(device = WearDevices.LARGE_ROUND)
fun messagingTileLayoutPreview(context: Context): TilePreviewData {
    return TilePreviewData({ previewResources() }) { request ->
        MessagingTileRenderer(context).renderTimeline(
            MessagingTileState(knownContacts),
            request
        )
    }
}

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

3142b42717407059.png

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 SuspendingTileService) 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.

  1. Add a new parameter, searchButtonClickable (of type ModifiersBuilders.Clickable).
  2. Pass this to the existing searchLayout() function.

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 (be sure to run the "messaging" tile, not the "hello" 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, the Wear OS Tiles guide and the design guidelines.