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 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/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:
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 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.
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 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:
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:
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()
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()
)
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:
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.