Compose for Wear: CurvedRow() and CurvedText()

Compose UI is not just for phones, tablets, foldables, notebooks, and desktops. Compose UI is for watches as well, via the Compose for Wear set of libraries.

(Google calls it “Wear Compose” on that page, but that just makes me think “Wear Compose? There! There Compose!”).

(and, yes, I’m old)

Compose for Wear has a bunch of composables designed for the watch experience. In particular, Compose for Wear has support for having content curve to match the edges of a round Wear OS device.

The Compose for Wear edition of Scaffold() has a timeText parameter. This is a slot API, taking a composable as a value, where typically you will see that composable delegate purely to TimeText(). That gives you the current time across the top of the watch screen, including curving that time on round screens:

TimeText() on a Round Watch

The implementation of TimeText() uses CurvedRow() and CurvedText() to accomplish this, if the code is running on a round device. Otherwise, it uses the normal Row() and Text() composables,

TimeText() is a bit overblown, particularly for a blog post, so this sample project has a SimpleTimeText() composable with a subset of the functionality:

@ExperimentalWearMaterialApi
@Composable
fun SimpleTimeText(
    modifier: Modifier = Modifier,
    timeSource: TimeSource = TimeTextDefaults.timeSource(TimeTextDefaults.timeFormat()),
    timeTextStyle: TextStyle = TimeTextDefaults.timeTextStyle(),
    contentPadding: PaddingValues = PaddingValues(4.dp)
) {
    val timeText = timeSource.currentTime

    if (LocalConfiguration.current.isScreenRound) {
        CurvedRow(modifier.padding(contentPadding)) {
            CurvedText(
                text = timeText,
                style = CurvedTextStyle(timeTextStyle)
            )
        }
    } else {
        Row(
            modifier = modifier
                .fillMaxSize()
                .padding(contentPadding),
            verticalAlignment = Alignment.Top,
            horizontalArrangement = Arrangement.Center
        ) {
            Text(
                text = timeText,
                style = timeTextStyle,
            )
        }
    }
}

We can determine whether or not the screen is round from the isScreenRound property on the Configuration, which we get via LocalConfiguration.current. If the screen is round, we display the current time in a CurvedText() and wrap that in a CurvedRow(). CurvedText() knows how to have the letters of the text follow the curve of the screen, and CurvedRow() knows how to have child composables follow the curve of the screen.

The timeText slot parameter in Scaffold() puts the time at the top of the screen by default. That position is controlled by the anchor parameter to CurvedRow(), where the default anchor is 270f. anchor is measured in degrees, and 270f is the value for the top of the screen (probably for historical reasons).

SampleRow() in that sample project lets us display multiple separate strings via individual CurvedText() composables, in a CurvedRow() with a custom anchor value:

@Composable
private fun SampleRow(anchor: Float, modifier: Modifier, vararg textBits: String) {
    CurvedRow(
        modifier = modifier.padding(4.dp),
        anchor = anchor
    ) {
        textBits.forEach { CurvedText(it, modifier = Modifier.padding(end = 8.dp)) }
    }
}

SampleRow() accepts a Modifier and tailors it to add a bit of padding to the CurvedRow().

We can then use SampleRow() to display text in other positions on the screen:

@ExperimentalWearMaterialApi
@Composable
fun MainScreen() {
    Scaffold(
        timeText = {
            SimpleTimeText()
        },
        content = {
            Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                Text(text = "Hello, world!")

                SampleRow(anchor = 180f, modifier = Modifier.align(Alignment.CenterStart), "one", "two", "three")
                SampleRow(anchor = 0f, modifier = Modifier.align(Alignment.CenterEnd), "uno", "dos", "tres")
                SampleRow(anchor = 90f, modifier = Modifier.align(Alignment.BottomCenter), "eins", "zwei", "drei")
            }
        }
    )
}

An anchor of 0f is the end edge of the screen, 90f is the bottom, and 180f is the start edge. Note that we also use align() to control the positioning within the Box(), with values that line up with our chosen anchor values.

The result is that we have text on all four edges, plus a centered “Hello, world!”:

Sample Compose UI on a Round Watch

CurvedRow() does not handle consecutive bits of CurvedText() all that well — ideally, use a single CurvedText() with the combined text. However, CurvedRow() is not limited to CurvedText(), and I hope to explore that more in a future blog post.