Beyond Font Scaling: Large Content Viewer with Compose

Reading time: about 6 minutes

Published

Beyond Font Scaling: Large Content Viewer with Compose
  1. Large Content Viewer on iOS
  2. Accessibility Considerations
  3. Building Navigation Bar Item Preview
  4. Links in the Blog Post

If you’ve worked with accessibility issues and have a bottom bar with multiple items, you’ve probably come across a problem with larger font sizes, where it’s impossible to scale the bottom navigation bar texts properly with larger font sizes. You might have wondered whether there are any alternative ways to support larger font sizes beyond just, well, font scaling.

That’s what happened to me. There was a bottom bar with five items with long texts. The developer who implemented the bottom bar had restricted font scaling because larger font sizes made the bottom bar’s items essentially unreadable. I started investigating options and came across one solution: a large content viewer from iOS. As Compose doesn’t support this out of the box, I had to build it.

This blog post explains what a Large Content Viewer is and how to build similar functionality with Compose. I call it the item previewer in this blog post. But before diving into that, let’s discuss the large content viewer on iOS.

Large Content Viewer on iOS

Large content viewer is an accessibility tool for iOS that helps, for example, low-vision users display non-scaling elements, such as bottom-bar items, as previews with a larger font size. It’s enabled only when the Large text (an accessibility setting) is enabled.

Here’s an example of what it looks like on the Files app:

iOS's Files app, Shared tab open, with Shared menu item displayed on top of the screen as a card with the folder icon and text ‘Shared’.

Accessibility Considerations

There are accessibility considerations for this solution. First of all, yes, it can be WCAG-compliant. WCAG, which stands for Web Content Accessibility Guidelines, is the standard used, for example, in legislation. It applies to mobile apps as well. It states in success criteria 1.1.4 Resize text that

Except for captions and images of text, text can be resized without assistive technology up to 200 percent without loss of content or functionality.

If the mechanism is not the default font size setting, then another supported mechanism would suffice to pass the success criteria. With this solution, users can resize text without assistive technology up to 200 percent.

However, this solution isn't very discoverable because it’s not a standard way to display fixed-size text. Also, the solution I’m presenting in this blog post is only for pointer-based interaction, as it relies on the long-press action. I will later write a blog post on how to support this for different assistive technologies.

Now that these considerations have been discussed, let’s talk about the actual implementation.

Building Navigation Bar Item Preview

I’ve built a small example to demonstrate how to build the item previewer for Compose. It consists of a Scaffold with a bottom bar, and fixed font size for the bottom bar’s text labels. You can find the full code on GitHub Gist.

The example looks like this with font size set to 200%:

When the user long-presses a navigation item, it is displayed at the top of the screen. Once they release the long press, the tab changes. Also, if they just tap the item, nothing is shown.

There are three things we need to implement to match the large content viewer behavior:

  1. Detect when the user long-presses instead of tapping.

  2. Show the preview of the selected item.

  3. Enable the preview only when the font size is bigger than the default.

Let’s start from the top.

Detect the Long Press

To detect a long press, we’re going to use InteractionSource. There are other options out there, but they don’t work in our case. As we’re using Material 3’s NavigationBarItem, it doesn’t allow long clicks by default, and using combinedClickable modifier doesn’t work, and neither does the pointerInput with detectLongPress because of the order of modifiers in the internal NavigationBarItem component. With that, I mean that the modifier chain inside the NavigationBarItem first includes the user-defined modifiers, and after them, the selectable modifier, which overrides the behavior of those aforementioned modifiers.

One thing to note is that the previewed item is stored as a state variable. NavItem is a custom-defined data class.

var previewedItem by remember { mutableStateOf<NavItem?>(null) }

Back to InteractionSource. Luckily, we can use it with NavigationBarItem, and utilize its methods to detect long presses. Let’s define an interaction source for each navbar item inside the map:


items.map { item ->
    val interactionSource = remember { MutableInteractionSource() }
    ...
}

We also need to know the duration of a long press. We can get it from LocalViewConfiguration:


val viewConfiguration = LocalViewConfiguration.current

// Long press duration

viewConfiguration.longPressTimeoutMillis

Next, we need to collect the interactions, specifically those of type PressInteraction. We do it inside LaunchedEffect:


LaunchedEffect(interactionSource) {
    interactionSource.interactions.collectLatest { interaction ->
        when (interaction) {
            is PressInteraction.Press -> {
                delay(viewConfiguration.longPressTimeoutMillis)
                    previewedItem = item
                }

                is PressInteraction.Cancel -> {
                    previewedItem = null
                }
                is PressInteraction.Release -> {
                    selectedItem = item
                    previewedItem = null
                }
            }
        }
    }

}

So, we listen to changes in interactionSource, and collect the interactions. When the interaction is a press-interaction, we first delay the amount of viewConfiguration.longPressTimeoutMillis to only set the previewed item when the user has held the press for a long enough time for it to count as a long press.

If the interaction is cancelled, for example, if the user moves their pointer input out of the bottom bar item without actually releasing it (and it would otherwise count as a long press or click), we set the previewedItem to null. And if the user releases the press (so, doesn’t cancel it but either lifts their finger or otherwise releases the pointer input), then it counts as either a press or a long press. We set the selected item to the current item, and previewedItem to null. This way, the click works as it should, and if the interaction is counted as a long click, the tab changes and the preview disappears.

Display the Preview

Next, we want to display the item the user is previewing. We first define the preview component:


@Composable
private fun PreviewItem(
    item: NavItem,
    modifier: Modifier = Modifier
) {
    Column(
        modifier = modifier
            .clip(
                RoundedCornerShape(8.dp)
            )
            .background(NavigationBarDefaults.containerColor)
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Icon(
            painter = painterResource(item.selectedIcon),
            contentDescription = null,
        )
        Text(
            text = item.label
        )
    }
}

It displays the selected item’s icon and the text without preventing text scaling.

Then, in the parent component, but outside of the bottom bar code, we display the PreviewItem component, if the previewItem is not null:


previewedItem?.let { item ->
    Box(modifier = Modifier.fillMaxSize()) {
        PreviewItem(
            modifier = Modifier
                .align(Alignment.Center),
            item = item
        )       
    }
}

The Box is a wrapper that centers the preview the same way the large content viewer does with its preview item. We pass in a modifier to align the PreviewItem to the center of the Box.

Enabling the Preview

The final step is to enable the item preview only when the font size is bigger than the fixed text size. One way is to utilize the font scale and enable the preview when the font size is bigger than 100%:


val fontScale = LocalDensity.current.fontScale
val previewEnabled by remember(fontScale) {
    mutableStateOf(fontScale > 1f)
}

And finally, wrap the item preview with this boolean:


if (previewEnabled) {
    previewedItem?.let { item ->
        ...
    }
}

And that’s how we’ve created behaviour similar to iOS’s large content viewer with Jetpack Compose. You can find The full code on GitHub Gist.

Links in the Blog Post