Pantrix UI

Focus beneath a bar

Why, in Compose Multiplatform, keyboard focus is lost behind a bar in content that scrolls beneath the bar, what the library gives for it and how it is tested. The problem is not Pantrix's own: it is seen the same way in every Compose app that scrolls its content beneath a bottom bar, a top bar or the system bar.

Which of what is said here was seen on a device, which was read in the source of Compose Multiplatform 1.12.1 and which was not verified is written separately in section 7.

1. The problem

Symptom

Cause

Compose brings the item that takes the focus into the visible area: the item sends a request, every scroller on its way meets the request in turn and scrolls the item to its own edge, no farther. What the scroller counts as "visible" is its whole size. The content padding (a lazy list's contentPadding, or a padding inside a scroll) is part of that area; the scroller does not know it. The bottom edge of a list that reaches under the bar is behind the bar.

Its little sibling: the library's focus ring is drawn outside the item. When the scroller brings the item to its edge, one side of the ring is outside the scrolling area and is cut off.

2. The solution

First the question: must the content scroll beneath the bar?

CaseWay
The bar is opaque and there is nothing to be seen behind itThe padding is given to the scroller itself, not to the content: LazyColumn(Modifier.padding(padding)). The scrolling area ends at the bar and its edge is the bar's top; no problem is left. No API is needed.
The content must pass beneath the bar (a translucent bar, behind the system bar on an edge with no bar)The content says how much room it leaves at its ends; the request to bring into view is grown by that much. This is what follows.

The second way has one piece: a modifier that stands over the content and grows every request that passes by the padding. The item says "show me"; the modifier makes it "show me and the 64dp under me"; the scroller leaves the item 64dp inside its edge, above the bar. Compose gives a public and stable interface for this: BringIntoViewModifierNode (androidx.compose.ui.relocation).

Four details:

DetailWhat is done
1A scroller asked for more than it holds does not scroll the excess, and passes it to the one above it as the user's drag: a sheet it stands in moves, a pull-to-refresh list shows its markNo more is asked than the content reaches. The scrolled content is recognised by its size (it is larger than its place) and the request is cut at its bounds
2A lazy list lays out its items one by one: how far the content reaches cannot be known from an itemThe modifier is put on each item and the whole padding is asked for; the list's contentPadding must be at least that much
3While the keyboard is open the scrolling area ends above the keyboard (imePadding) and the bottom bar is behind the keyboard: it does not cover the areaThe keyboard's share is taken off the padding (padding - WindowInsets.ime.asPaddingValues()); if it is not, at the first key the page jumps up by the bar's height
4The focus ring is outside the itemIn a column or a row the ring asks for its room itself. In a lazy list it cannot: the list adds the ring's room to its contentPadding and tells its items the same value

Why not BringIntoViewSpec

The tool Compose shows for this job is LocalBringIntoViewSpec: the scroller is told "this much of your area does not count as visible". It was the demo's first solution; it was left for three reasons:

It has one advantage: while the scrolling area shrinks (while the keyboard opens), Compose's own tracking that keeps the focused item visible uses that rule too. The modifier only grows requests; this is written in section 5.

3. With Pantrix

The library gives one modifier (com.pantrixui.foundation.a11y):

public fun Modifier.scrollPadding(padding: PaddingValues): Modifier

It does not add padding; it tells the padding the content leaves. It is put over what is scrolled, inside the scroller.

3.1. A scrolling column or row

After the scroll, before the padding it speaks of:

PantrixScaffold(bottomBar = { insets -> AppTabs(Modifier.padding(insets)) }) { padding ->
    Column(
        modifier = Modifier
            .fillMaxSize()
            .verticalScroll(rememberScrollState())
            .scrollPadding(padding)
            .padding(padding)
    ) {
        // …
    }
}

Here no more can be asked: the content is recognised by its size and the request is cut at its bounds. If the content has no such padding the last item comes to the edge, and nothing passes to the one above. The focus ring asks for its room itself; if at the end of the content there is room for a ring after the bar's share (most pages have a margin of their own), the last item's ring is whole too.

In a row that scrolls sideways, the start and the end of padding are read by the reading direction. The demo's sideways-scrolling frame leaves a ring's room at both its ends and says so like this:

val ring = PaddingValues(horizontal = PantrixTheme.platform.focusRingReach)
Box(Modifier.horizontalScroll(state).scrollPadding(ring).padding(ring)) { content() }

3.2. A lazy list

On each item; the value is the list's contentPadding:

PantrixScaffold(bottomBar = { insets -> AppTabs(Modifier.padding(insets)) }) { padding ->
    // The bars' share and, above and below, a ring's room.
    val room = padding + PaddingValues(vertical = PantrixTheme.platform.focusRingReach)
    LazyColumn(contentPadding = room) {
        items(orders, key = { it.id }) { order ->
            OrderRow(order, Modifier.scrollPadding(room))
        }
    }
}

A row it is not put on behaves as before: it comes to the edge. If the list's contentPadding is less than what is said, the list is asked for what it does not have (section 2, detail 1): the two values must come from the same variable. In grids (LazyVerticalGrid) the use is the same.

3.3. While the keyboard is open

A screen with a text field ends the scrolling area above the keyboard. Meanwhile the bottom bar is behind the keyboard; the keyboard's share is taken off the padding. Compose's subtraction of PaddingValues does this: it does not go below zero and reads its value at the moment it is asked, so the screen is not composed again while the keyboard moves.

Column(
    Modifier
        .imePadding()
        .verticalScroll(scroll)
        .scrollPadding(padding - WindowInsets.ime.asPaddingValues())
        .padding(padding)
)

The demo's page is like this (App.kt, DemoPage).

3.4. Scrollers inside

They are not touched. A row that scrolls sideways inside the column brings its item to its own edge, as always; the request that comes out of it passes through the column's modifier, and the column shows the row above the bar. A text field's cursor stays as it is in its own box.

4. Without Pantrix

A modifier and its node. It is written with stable APIs; no expect/actual is needed.

fun Modifier.scrollPadding(padding: PaddingValues): Modifier = this then ScrollPaddingElement(padding)

private data class ScrollPaddingElement(val padding: PaddingValues) : ModifierNodeElement<ScrollPaddingNode>() {
    override fun create() = ScrollPaddingNode(padding)

    override fun update(node: ScrollPaddingNode) {
        node.padding = padding
    }
}

private class ScrollPaddingNode(var padding: PaddingValues) :
    Modifier.Node(),
    BringIntoViewModifierNode {
    override suspend fun bringIntoView(
        childCoordinates: LayoutCoordinates,
        boundsProvider: () -> Rect?
    ) {
        // The request goes to the scroller above in this node's own coordinates, grown.
        bringIntoView {
            val asked = if (isAttached && childCoordinates.isAttached) boundsProvider() else null
            asked?.let { rect ->
                val own = requireLayoutCoordinates()
                val here = rect.translate(own.localBoundingBoxOf(childCoordinates, clipBounds = false).topLeft)
                val direction = requireLayoutDirection()
                val grown = with(requireDensity()) {
                    Rect(
                        left = here.left - padding.calculateLeftPadding(direction).roundToPx(),
                        top = here.top - padding.calculateTopPadding().roundToPx(),
                        right = here.right + padding.calculateRightPadding(direction).roundToPx(),
                        bottom = here.bottom + padding.calculateBottomPadding().roundToPx()
                    )
                }
                // In the content of a scrolling column: no more is asked than the content reaches. On a lazy
                // list's item this line is left out; there the list's contentPadding holds the bound.
                grown.intersect(Rect(0f, 0f, own.size.width.toFloat(), own.size.height.toFloat()))
            }
        }
    }
}

BringIntoViewModifierNode and bringIntoView: androidx.compose.ui.relocation; requireLayoutCoordinates, requireLayoutDirection, requireDensity: androidx.compose.ui.node.

In this bare form the two uses are two separate modifiers (one that cuts at the bounds and one that does not). Pantrix's is one: it finds the scrolled content by its size, cuts where it finds it, and where it does not (a lazy list, content that fits its scroller) asks for the whole padding (RoomInView.kt, withRoom).

In the inner lambda isAttached is checked: the request is asked again on every frame for as long as the scroller scrolls for it, and by then the modifier may have left the composition; requireLayoutCoordinates() throws on a node that is not attached.

5. What the modifier does not do

6. Testing

7. Verification

Seen

Tests

Read in the sources

Compose Multiplatform 1.12.1, foundation and ui:

Not verified

8. Where it is in this repository

WhatWhere
The modifierui/foundation/src/commonMain/kotlin/com/pantrixui/foundation/a11y/ScrollPadding.kt
The reckoning that finds the scrolled content and cuts the request at its boundsRoomInView.kt in the same package (withRoom); the focus ring uses it too (FocusRing.kt)
As much as a ring reaches outPantrixPlatform.focusRingReach (Platform.kt)
TestsScrollPaddingTest, FocusRingInViewTest (ui/foundation/src/commonTest/.../a11y)
The demo's pageapp/shared/src/commonMain/kotlin/com/pantrixui/cmp/demo/App.kt (DemoPage)
The demo's sideways-scrolling frameapp/shared/.../demo/page/HorizontalScrollFrame.kt
The example in the demo"Focus in a list that scrolls beneath the bars" on the Scaffold page