agentleFS
giolaqgiolaq/tv-build-harness/skills/kmp-compose-tv/SKILL.md

kmp-compose-tv

Skill4 starsChanged 5 months agoMIT-0View on GitHub

Compose for TV patterns: TvLazyRow/Column, focusable cards, FocusRequester, D-pad navigation, and 10-foot UI composables

The file

We split this skill into the rules it gives an agent

3 rules. About 0 are common, 1 is distinctive, and 2 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈1

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 2

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Always provide key = { it.id } for stable focus restoration

    line 37
  2. Distinctive:

    Reuse this pattern: Don't build cards from scratch — copy TVCard and adjust content.

    line 181
  3. Very specific:

    Key insight: No Jetpack Navigation library.

    line 222

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Compose for TV

The Android TV app uses androidx.tv:tv-foundation and androidx.tv:tv-material for TV-optimized Compose components. These provide built-in D-pad navigation, focus handling, and TV-appropriate interaction patterns.

Core TV components

TvLazyRow — horizontal content rails

import androidx.tv.foundation.lazy.list.TvLazyRow
import androidx.tv.foundation.lazy.list.items

TvLazyRow(
    contentPadding = PaddingValues(horizontal = 48.dp),
    horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
    items(contentItems, key = { it.id }) { item ->
        TVCard(
            item = item,
            onItemClick = { /* navigate to detail */ },
            onItemFocused = { /* update hero banner */ },
        )
    }
}

When to use: Any horizontal scrolling content row — category rails, "continue watching", recommendations.

Key points:

  • Always provide key = { it.id } for stable focus restoration
  • contentPadding with 48dp horizontal gives breathing room from screen edges
  • Arrangement.spacedBy(16.dp) for consistent card spacing

TvLazyColumn — vertical scrollable layout

import androidx.tv.foundation.lazy.list.TvLazyColumn

TvLazyColumn(
    modifier = Modifier.fillMaxWidth().weight(1f),
    contentPadding = PaddingValues(top = 24.dp, bottom = 48.dp),
) {
    categoryList.forEachIndexed { index, (genre, items) ->
        item {
            ContentRow(title = genre, items = items)
        }
    }
}

When to use: Home screen layout containing multiple rails, settings lists, any vertical scroll.

Surface (tv-material) — focusable containers

import androidx.tv.material3.Surface
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.ClickableSurfaceDefaults

@OptIn(ExperimentalTvMaterial3Api::class)
Surface(
    onClick = { onItemClick(item) },
    modifier = modifier.width(220.dp).aspectRatio(16f / 9f),
    tonalElevation = if (isFocused) 8.dp else 2.dp,
    colors = ClickableSurfaceDefaults.colors(
        containerColor = Color.Transparent,
        focusedContainerColor = Color.Transparent,
    ),
    shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
) {
    // Card content
}

Key: Surface from androidx.tv.material3 (not regular material3) handles focus states natively. It responds to D-pad select without custom click handling.

Focus management

FocusRequester — programmatic focus control

val focusRequester = remember { FocusRequester() }

// Apply to the element that should receive initial focus
TvLazyRow {
    items(items) { item ->
        TVCard(
            modifier = if (item == items.first()) {
                Modifier.focusRequester(focusRequester)
            } else Modifier,
        )
    }
}

// Request focus after content loads
LaunchedEffect(items) {
    if (items.isNotEmpty()) {
        focusRequester.requestFocus()
    }
}

When to use:

  • Setting initial focus when a screen loads
  • Restoring focus after data refresh
  • Moving focus programmatically (e.g., after dialog dismissal)

onFocusChanged — reacting to focus

Modifier.onFocusChanged { focusState ->
    isFocused = focusState.isFocused
    if (focusState.isFocused) {
        onItemFocused(item)  // e.g., update hero banner
    }
}

Pattern: The template uses focus changes on cards to drive the hero banner. When a card receives focus, it notifies the parent, which updates the hero image with a debounced crossfade.

Focus-driven animations

val scale by animateFloatAsState(
    targetValue = if (isFocused) 1.08f else 1.0f,
    animationSpec = tween(durationMillis = 200),
    label = "cardScale",
)

Modifier
    .scale(scale)
    .then(
        if (isFocused) {
            Modifier.shadow(16.dp, shape).border(2.dp, Color.White, shape)
        } else Modifier
    )

TV focus UX: Cards scale up 8% + gain a white border + shadow when focused. This triple-cue (size + border + depth) ensures visibility from 10 feet.

Building a TVCard (the template pattern)

The template's TVCard.kt is the canonical focusable content card:

@Composable
fun TVCard(
    item: ContentItem,
    onItemClick: (ContentItem) -> Unit,
    onItemFocused: (ContentItem) -> Unit = {},
    modifier: Modifier = Modifier,
) {
    var isFocused by remember { mutableStateOf(false) }
    val scale by animateFloatAsState(...)

    Surface(
        onClick = { onItemClick(item) },
        modifier = modifier
            .width(220.dp)
            .aspectRatio(16f / 9f)
            .scale(scale)
            .onFocusChanged {
                isFocused = it.isFocused
                if (it.isFocused) onItemFocused(item)
            }
            .then(if (isFocused) focusedModifiers else Modifier),
        // ...
    ) {
        // AsyncImage + gradient overlay + title text
    }
}

Reuse this pattern. Don't build cards from scratch — copy TVCard and adjust content.

D-pad navigation

Compose for TV handles D-pad automatically through the focus system. The TvLazyRow and TvLazyColumn components manage:

  • Left/Right moves focus between items in a row
  • Up/Down moves between rows
  • Select/Enter triggers onClick on the focused Surface

BackHandler for back navigation

import androidx.activity.compose.BackHandler

BackHandler(enabled = showingDetail) {
    showingDetail = false
    selectedItem = null
}

Always use BackHandler for screen-back behavior. The Android TV remote's back button fires through this. Don't intercept hardware key events directly.

Screen composition pattern

The template uses state-based screen switching in MainScreen():

@Composable
private fun MainScreen() {
    var selectedItem by remember { mutableStateOf<ContentItem?>(null) }
    var showingDetail by rememberSaveable { mutableStateOf(false) }

    BackHandler(enabled = showingDetail) { showingDetail = false }

    when {
        showingDetail && selectedItem != null -> ContentDetailScreen(...)
        else -> HomeScreen(...)
    }
}

Key insight: No Jetpack Navigation library. Screen state is managed by boolean flags + when blocks. Simple and avoids Navigation Compose's focus restoration quirks on TV.

Hero banner with focus-driven updates

@Composable
private fun HomeScreen(items: List<ContentItem>, ...) {
    var focusedItem by remember { mutableStateOf(items.firstOrNull()) }

    Column {
        HeroBanner(focusedItem)  // Updates as user navigates rails below
        TvLazyColumn { /* rails that call onFocusChanged = { focusedItem = it } */ }
    }
}

The hero image crossfades with a 250ms debounce to avoid flickering during fast navigation:

LaunchedEffect(item) {
    kotlinx.coroutines.delay(250)
    debouncedItem = item
}
Crossfade(targetState = debouncedItem, animationSpec = tween(600)) { ... }

Anti-patterns

  • Using regular LazyRow/LazyColumn instead of TvLazyRow/TvLazyColumn. The TV variants handle focus traversal between items. Regular Lazy components don't.
  • Adding clickable {} modifier to items inside TV Surface. Surface already handles clicks via D-pad select. Adding clickable causes double-handling.
  • focusRequester.requestFocus() in composition (not in LaunchedEffect/callback). Causes infinite recomposition loops. Always call from effects or event handlers.
  • Nesting focusable Surfaces. A focusable inside a focusable confuses the focus system. Only the leaf-level items should be focusable.
  • Forgetting key in items() calls. Without stable keys, focus position is lost on recomposition. Always use items(list, key = { it.id }).
  • Hero updates without debounce. Fast D-pad scrolling triggers rapid focus changes. Without delay, the hero flickers. Always debounce hero content updates by 200-300ms.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.