Rotary input with Compose

Rotary input refers to input from pieces of your watch that spin or rotate. On average, users spend only a few seconds interacting with their watch. You can enhance your user experience by using Rotary input to allow your user to quickly accomplish various tasks.

The three main sources of rotary input on most watches include the rotating side button (RSB), and either a physical bezel or a touch bezel, which is a circular touch zone around the screen. Though expected behavior may vary based on the type of input, be sure to support rotary input for all essential interactions.

Scroll

Most users expect apps to support the scroll gesture. As the content scrolls on the screen, give the users visual feedback in response to rotary interactions. Visual feedback can include position indicators for vertical scroll or page indicators.

Implement rotary scroll using Compose for Wear OS. This example describes an app with a scaffold and a ScalingLazyColumn which scrolls vertically. Scaffold provides the basic layout structure for Wear OS apps and already has a slot for a scroll indicator. To show the scrolling progress, create a position indicator based on the list state object. Scrollable views, including ScalingLazyColumn, already have a scrollable state for adding rotary input. To receive rotary scroll events, do the following:

  1. Explicitly request focus using FocusRequester.
  2. Add the onRotaryScrollEvent modifier to intercept events that the system generates when a user turns the crown or rotates the bezel. Each rotary event has a set pixel value and scrolls vertically or horizontally. The modifier also has a callback to indicate if the event is consumed, and stops event propagation to its parents when consumed.
val listState = rememberScalingLazyListState()

Scaffold(
    positionIndicator = {
        PositionIndicator(scalingLazyListState = listState)
    }
) {

    val focusRequester = rememberActiveFocusRequester()
    val coroutineScope = rememberCoroutineScope()

    ScalingLazyColumn(
        modifier = Modifier
            .onRotaryScrollEvent {
                coroutineScope.launch {
                    listState.scrollBy(it.verticalScrollPixels)

                    listState.animateScrollBy(0f)
                }
                true
            }
            .focusRequester(focusRequester)
            .focusable(),
        state = listState
    ) { ... }
}

Discrete values

Use rotary interactions to adjust discrete values as well, such as adjusting brightness in settings or selecting the numbers in time picker when setting an alarm.

Similar to ScalingLazyColumn, picker, slider, stepper and other composables need to have focus to receive rotary input. In case of multiple scrollable targets on the screen, such as the hours and minutes in the time picker, create a FocusRequester for each target and handle focus change accordingly when the user taps either hours or minutes.

@Composable
fun TimePicker() {
    var selectedColumn by remember { mutableStateOf(0) }
    val focusRequester1 = remember { FocusRequester() }
    val focusRequester2 = remember { FocusRequester() }

    Row {
       Picker(...)
       Picker(...)
    }

    LaunchedEffect(selectedColumn) {
        listOf(focusRequester1,
               focusRequester2)[selectedColumn]
            .requestFocus()
    }
}

Custom actions

You can also create custom actions that respond to rotary input in your app. For example, use rotary input to zoom in and out or to control volume in a media app.

If your component doesn't natively support scrolling events such as volume control, you can handle scroll events yourself.

// VolumeScreen.kt

val focusRequester: FocusRequester = remember { FocusRequester() }

Column(
    modifier = Modifier
        .fillMaxSize()
        .onRotaryScrollEvent {
            // handle rotary scroll events
            true
        }
        .focusRequester(focusRequester)
        .focusable(),
) { ... }

Create a custom state managed in view model, and a custom callback that is used to process rotary scroll events.

// VolumeViewModel.kt

object VolumeRange(
    public val max: Int = 10
    public val min: Int = 0
)

val volumeState: MutableStateFlow<Int> = ...

fun onVolumeChangeByScroll(pixels: Float) {
    volumeState.value = when {
        pixels > 0 -> min (volumeState.value + 1, VolumeRange.max)
        pixels < 0 -> max (volumeState.value - 1, VolumeRange.min)
    }
}

For the sake of simplicity, the preceding example uses pixel values that, if actually used are likely to be overly sensitive.

Use the callback once you receive the events, as shown in the following snippet.

val focusRequester: FocusRequester = remember { FocusRequester() }
val volumeState by volumeViewModel.volumeState.collectAsState()

Column(
    modifier = Modifier
        .fillMaxSize()
        .onRotaryScrollEvent {
            volumeViewModel
                .onVolumeChangeByScroll(it.verticalScrollPixels)
            true
        }
        .focusRequester(focusRequester)
        .focusable(),
) { ... }

Additional resources

Consider using Horologist, a Google open source project which provides a set of Wear libraries that supplement the functionality provided by Compose for Wear OS and other Wear OS APIs. Horologist provides implementation for advanced use cases, and many device specific details.

For example, sensitivity of different rotary input sources can vary. For smoother transition between values you can rate limit or add snapping or animations for transition. This allows turning speed to feel more natural for users. Horologist includes modifiers for scrollable components and for discrete values. It also includes utilities to handle focus and an Audio UI library to implement volume control with haptics.

For more information, see Horologist on GitHub.