mcpField
C
compose-focus-navigation

compose-focus-navigation

github.com/chrisbanesDevelopment1.1k1.9k installsUpdated 1d ago
Repository

About compose-focus-navigation

compose-focus-navigation is a skill for Jetpack Compose developers configuring and testing focus navigation with keyboards, D-pads, or remotes. It makes exceptional focus paths explicit and tests focus state through the same inputs users rely on.

Key features

  1. 01Add focus requests and state observation when needed
  2. 02Override default spatial search only for specific cases
  3. 03Handle only keys that need custom behavior
  4. 04Restore focus by semantic identity after refresh
  5. 05Test with key input and focused semantics

Use cases

  1. 01Set initial or restored focus
  2. 02Correct a specific directional focus jump
  3. 03Choose stable focus after a list refresh
  4. 04Verify keyboard or D-pad navigation

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/chrisbanes/skills --skill compose-focus-navigation

After installing, type/compose-focus-navigationin Claude Code to confirm it loaded.

Details

From the project README

Compose: focus navigation

Core principle

Focus is stateful UI behavior: make targets and exceptional edges explicit, then drive and verify it through the user's keyboard, D-pad, or remote input.

Procedure

  1. Start with components that already participate in focus. Add a hook only for a requested behavior:
NeedAdd
Normal button/text field/clickable focusNothing extra; use the focusable component
Programmatic initial/restored focusFocusRequester + Modifier.focusRequester(...)
Visual or state reaction to focus changesModifier.onFocusChanged { ... }
Custom interactive surface that is not already focusableModifier.focusable() plus role/semantics as appropriate
  1. Request initial or restored focus from LaunchedEffect, keyed to the condition that makes the target present. For lazy content, keep requesters by stable item id and request only after the item is composed. Inside AnimatedContent, use the content lambda's target consistently for rendered identity, tags, requester ownership, and the effect key; captured outer state gives outgoing and incoming content the same identity. In a review, a finding about requestFocus() running in the composable body is incomplete until it names the event or keyed effect that should issue the request after its target is available.
  2. Keep default spatial search unless a concrete edge, jump, or trap is wrong. Encode only those exceptions with focusProperties.
  3. Handle keys only for behavior that is not normal click or traversal. Consume exactly the handled event; throttle rapid D-pad work at its expensive owner, not across the screen.
  4. Restore by semantic identity after refresh: retain the focused id when it exists, otherwise choose a deterministic fallback.
  5. Test with one concrete key/D-pad interaction and focused semantics. When the behavior under review is user-triggered, do not substitute direct state mutation or leave the input conditional. Use screenshots only for the focus appearance.
  6. Finish when all intentional targets and exceptional edges are encoded, loading/refresh behavior has a stable focus policy, and tests use the same input model as users.

For example, request and observe focus only when both behaviors are required:

val requester = remember { FocusRequester() }

Button(
    onClick = onClick,
    modifier = Modifier
        .focusRequester(requester)
        .onFocusChanged { state -> isFocused = state.isFocused },
) {
    Text("Play")
}

Call focus requests from an effect, not the composable body:

val initialFocus = remember { FocusRequester() }

LaunchedEffect(initialFocus) {
    initialFocus.requestFocus()
}

If the target appears after loading, key the request to the condition:

LaunchedEffect(items.isNotEmpty()) {
    if (items.isNotEmpty()) {
        firstItemRequester.requestFocus()
    }
}

Use focusProperties only when default spatial search is wrong:

Modifier.focusProperties {
    up = headerRequester
    down = firstRowRequester
    left = FocusRequester.Cancel
}

Too many hard-coded links create stale focus graphs. For special key behavior, consume only the handled event:

Modifier.onPreviewKeyEvent { event ->
    if (event.type == KeyEventType.KeyUp && event.key == Key.Back) {
        onBack()
        true
    } else {
        false
    }
}

Test focus through user input:

composeTestRule.onNodeWithTag("screen").performKeyInput {
    pressKey(Key.DirectionDown)
}

composeTestRule.onNodeWithTag("play-button").assertIsFocused()

Broader test-shape choices are in Compose UI testing patterns.

Comments

Markdown supported