tv-patterns

star 5

Android TV Compose patterns — D-pad focus, FocusRequester, Navigation3 back stack, TV Material3 components, key event handling, and TV-specific UX patterns for Nebula.

gsbakshi By gsbakshi schedule Updated 2/22/2026

name: tv-patterns description: Android TV Compose patterns — D-pad focus, FocusRequester, Navigation3 back stack, TV Material3 components, key event handling, and TV-specific UX patterns for Nebula. user-invocable: false

Android TV Compose Patterns — Nebula Reference

1. Focus — The Golden Rule

Every interactive element must be visually focused AND keyboard-activatable. TV users cannot tap. D-pad Center (KEYCODE_DPAD_CENTER) = click.

Option A: Use TV Material3 (preferred)

import androidx.tv.material3.Card
import androidx.tv.material3.Button

// TV Card handles focus, scale animation, and OK key automatically
Card(onClick = { /* fires on D-pad center too */ }) { content() }
Button(onClick = { }) { Text("Action") }

Option B: Custom Focus with Scale Animation ("Focus as State" pattern)

var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(if (isFocused) 1.05f else 1f, label = "scale")

Box(
    modifier = Modifier
        .scale(scale)
        .border(
            width = if (isFocused) 2.dp else 0.dp,
            color = MaterialTheme.colorScheme.primary,
            shape = RoundedCornerShape(8.dp)
        )
        .onFocusChanged { isFocused = it.isFocused }
        .focusable()
        .clickable { performAction() }
)

2. ⚠️ Critical Modifier Order Rule

focusRequester() MUST appear BEFORE any modifier that adds focusability:

// ✅ CORRECT — focusRequester before focusable
Modifier
    .focusRequester(focusRequester)
    .focusable()

// ❌ WRONG — focusRequester will silently fail to attach
Modifier
    .focusable()
    .focusRequester(focusRequester)

3. Auto-Focus on Panel Entry

Always request focus on the first item when a panel becomes visible:

val firstItemFocus = remember { FocusRequester() }

LaunchedEffect(Unit) {
    runCatching { firstItemFocus.requestFocus() }  // runCatching handles "no focused item" gracefully
}

// CORRECT ORDER: focusRequester before focusable
Box(modifier = Modifier
    .focusRequester(firstItemFocus)  // ← first
    .focusable()                      // ← second
)

4. focusGroup — Fix Erratic Focus in Grids

Without focusGroup, D-pad in lazy lists "jumps erratically" when items recompose during scroll. Wrap each row in focusGroup() to contain traversal:

TvLazyVerticalGrid(columns = TvGridCells.Fixed(5)) {
    // Group items into rows using focusGroup on the row wrapper
    items(apps, key = { it.packageName }) { app ->
        AppItem(app, modifier = Modifier.focusable())
    }
}

// For explicit row-level grouping:
Row(modifier = Modifier.focusGroup()) {
    rowItems.forEach { item ->
        ItemCard(item, modifier = Modifier.focusable())
    }
}

5. Directional Focus Control

Block focus from escaping a zone in a specific direction:

// Nav bar: prevent focus escaping upward past the nav bar
Modifier.focusProperties {
    up = FocusRequester.Cancel    // blocks upward exit
    down = contentAreaFocusRequester  // routes focus into content
}

// Content area: prevent focus going back into nav bar accidentally
Modifier.focusProperties {
    up = FocusRequester.Cancel  // or link explicitly to nav bar item
}

6. Navigation3 Back Stack (Stable Nov 2025)

Two patterns — choose based on whether you need process-death survival:

Simple (no serialization needed — good for Nebula's ViewScreen enum)

// Plain SnapshotStateList — no @Serializable required
val backStack = remember { mutableStateListOf<Any>(ViewScreen.WIDGETS) }

NavDisplay(
    backStack = backStack,
    onBack = { backStack.removeLastOrNull() },  // ← required parameter
    entryProvider = { key ->
        when (key) {
            is ViewScreen.WIDGETS  -> NavEntry(key) { WidgetAreaPanel() }
            is ViewScreen.APP_GRID -> NavEntry(key) { AppGridPanel() }
            is ViewScreen.BROWSER  -> NavEntry(key) { BrowserPanel() }
            else -> NavEntry(Unit) { /* unknown */ }
        }
    }
)

Saveable (survives config change + process death)

// Keys MUST implement NavKey AND have @Serializable
@Serializable data object Home : NavKey
@Serializable data class Product(val id: String) : NavKey

val backStack = rememberNavBackStack(Home)  // persists across rotation + process death

NavDisplay(
    backStack = backStack,
    onBack = { backStack.removeLastOrNull() },
    entryProvider = entryProvider {           // DSL style
        entry<Home> { HomeScreen() }
        entry<Product> { key -> ProductScreen(key.id) }
    }
)

// Navigate backStack.add(ViewScreen.BROWSER) backStack.removeLastOrNull() // go back

Note: Current Nebula uses when(currentView) with MutableState<ViewScreen>. Migrate to Navigation3 when back stack behavior is needed (browser history, settings drill-down).

4. Key Event Handling

// Intercept before child components see it
Modifier.onPreviewKeyEvent { event ->
    when {
        event.key == Key.DirectionCenter && event.type == KeyEventType.KeyDown -> {
            performPrimaryAction(); true  // consumed
        }
        event.key == Key.Back -> {
            navigateBack(); true
        }
        else -> false  // pass through to children
    }
}

// After child components (for supplementary handling)
Modifier.onKeyEvent { event ->
    if (event.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) {
        // custom behavior
        true
    } else false
}

5. App Grid — LazyVerticalGrid (Standard Compose)

⚠️ TvLazyVerticalGrid was removed in tv-foundation 1.0.0-alpha10+. Use standard LazyVerticalGrid — TV D-pad focus is handled by TV Material3 Card items, not the grid itself.

// tv-foundation 1.0.0-alpha12 only contains: ExperimentalTvFoundationApi, TvImeOptions, TvKeyboardAlignment
// TvLazyVerticalGrid / TvGridCells no longer exist in this version

LazyVerticalGrid(
    columns = GridCells.Fixed(5),
    contentPadding = PaddingValues(16.dp),
    verticalArrangement = Arrangement.spacedBy(8.dp),
    horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
    itemsIndexed(apps, key = { _, app -> app.packageName }) { index, app ->
        AppItem(
            appInfo = app,
            modifier = if (index == 0) Modifier.focusRequester(firstItemFocus) else Modifier,
            onAppClick = { startActivity(it.launchIntent) }
        )
    }
}

6. collectAsStateWithLifecycle — Always Use This

// ✅ Stops collecting when launcher is backgrounded — saves battery/CPU
val state by viewModel.uiState.collectAsStateWithLifecycle()

// ❌ Never use this in Compose — always collects regardless of lifecycle
val state by viewModel.uiState.collectAsState()

7. TV Remote Key Reference

Key KeyCode Usage
OK / D-pad Center KEYCODE_DPAD_CENTER Primary action
D-pad Up/Down/Left/Right KEYCODE_DPAD_* Navigation
Back KEYCODE_BACK Go back / dismiss
Home KEYCODE_HOME Return to launcher
Play/Pause KEYCODE_MEDIA_PLAY_PAUSE Media control
Fast Forward KEYCODE_MEDIA_FAST_FORWARD Skip
Rewind KEYCODE_MEDIA_REWIND Skip back

8. GeckoView in Compose (AndroidView Bridge)

@Composable
fun BrowserView(session: GeckoSession, modifier: Modifier = Modifier) {
    AndroidView(
        factory = { ctx -> GeckoView(ctx).also { it.setSession(session) } },
        update = { view ->
            if (view.session !== session) {
                view.releaseSession()
                view.setSession(session)
            }
        },
        modifier = modifier.fillMaxSize()
    )
}

GeckoView is a SurfaceView — it cannot be a Composable directly.

Install via CLI
npx skills add https://github.com/gsbakshi/nebula-tv --skill tv-patterns
Repository Details
star Stars 5
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator