1. Introduction
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
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
- Android Studio Koala Feature Drop | 2024.1.2 Canary 1 or later
- 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.
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 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.
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.
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.
5. Build a messaging tile
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:
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()
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()
)
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:
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.
- Add a new parameter,
searchButtonClickable
(of typeModifiersBuilders.Clickable
). - 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.