Kotlin Jetpack Compose LazyColumn + stickyHeader: focused item hidden under sticky header when navigating with keyboard or with accessibility TalkBack

Summary

A focus‑navigation defect in Jetpack Compose LazyColumn + stickyHeader caused focused items to be partially obscured by the sticky header when navigating upward using keyboard or TalkBack. The automatic scroll‑to‑focused‑item logic failed to account for the height of sticky headers, resulting in inaccessible or hidden content.

Root Cause

The underlying issue stems from how LazyColumn’s focus scroll behavior interacts with stickyHeader layout constraints.

Key factors:

  • FocusRequester + BringIntoViewRequester do not consider sticky header height.
  • stickyHeader is drawn in a separate layer and not included in the viewport offset calculations.
  • Upward focus movement triggers a minimal scroll that aligns the item’s top edge with the viewport’s top, ignoring the header’s overlay.
  • Compose’s internal scroll logic assumes a flat list without overlapping pinned elements.

Why This Happens in Real Systems

Real UI frameworks often treat pinned headers as decorations, not as part of the scrollable content. This leads to:

  • Incorrect viewport math when calculating visibility.
  • Focus events firing before layout stabilization, causing incomplete scroll adjustments.
  • Accessibility services relying on the same flawed scroll‑to‑focus logic, reproducing the issue for TalkBack.

Real-World Impact

This defect creates multiple user‑facing problems:

  • Accessibility failure: TalkBack users cannot see the focused item.
  • Keyboard navigation inconsistency: Downward navigation works, upward navigation breaks.
  • Broken UX expectations: Users think the list is “jumping” or hiding content.
  • QA instability: Behavior varies by device density and header height.

Example or Code (if necessary and relevant)

A minimal workaround uses BringIntoViewRequester with a custom offset to compensate for sticky header height.

@Composable
fun FocusAwareItem(
    headerHeightPx: Int,
    content: @Composable () -> Unit
) {
    val requester = remember { BringIntoViewRequester() }
    val focusRequester = remember { FocusRequester() }

    Box(
        modifier = Modifier
            .focusRequester(focusRequester)
            .onFocusChanged {
                if (it.isFocused) {
                    requester.bringIntoView(
                        BringIntoViewRequester.DefaultBringIntoViewSpec(
                            alignment = Alignment.Top,
                            offset = -headerHeightPx
                        )
                    )
                }
            }
            .bringIntoViewRequester(requester)
    ) {
        content()
    }
}

How Senior Engineers Fix It

Experienced engineers approach this by compensating for the missing viewport offset and stabilizing focus behavior.

Typical solutions:

  • Measure sticky header height and apply a negative offset when bringing items into view.
  • Wrap list items in a custom modifier that triggers bringIntoView with corrected math.
  • Throttle focus events to avoid premature scrolls during recomposition.
  • Replace stickyHeader with a manually positioned pinned header when precise control is required.
  • File upstream bug reports when the issue stems from Compose internals.

Why Juniors Miss It

Less‑experienced engineers often overlook:

  • That stickyHeader is not part of the scrollable item list, so default focus logic ignores it.
  • That focus navigation and accessibility navigation share the same flawed scroll behavior.
  • That bringIntoView is not header‑aware, requiring manual compensation.
  • The need to measure layout elements to compute correct offsets.
  • The subtle difference between visually visible and logically visible in Compose’s layout system.

Juniors typically assume the framework handles all focus‑related scrolling automatically, but pinned headers introduce a layer of complexity that requires explicit engineering intervention.

Leave a Comment