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
In this repository26 other files it gives its agents.
- AGENTS.mdAGENTS.md
- CLAUDE.mdCLAUDE.md
- 10ft-uiSkill
- amazon-devices-vega-app-performanceSkill
- amazon-devices-vega-best-practicesSkill
- amazon-devices-vega-setup-sdkSkill
- android-tv-testingSkill
- changelog-static-siteSkill
- creative-tv-uiSkill
- eas-buildSkill
- expo-tv-configSkill
- firetv-leanbackSkill
- kmp-build-commandsSkill
- kmp-data-layerSkill
- kmp-navigationSkill
- kmp-template-anatomySkill
- kmp-themingSkill
- kmp-verify-patternsSkill
- metaSkill
- rn-manifest-wiringSkill
- rn-shared-ui-catalogSkill
- rn-spatial-navigationSkill
- rn-template-anatomySkill
- rn-themingSkill
- vega-sdkSkill
- video-playerSkill
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
- Very specific: line 37
Always provide key = { it.id } for stable focus restoration
- Distinctive: line 181
Reuse this pattern: Don't build cards from scratch — copy TVCard and adjust content.
- Very specific: line 222
Key insight: No Jetpack Navigation library.
≈ “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.
Files
Compose for TV
The Android TV app uses
androidx.tv:tv-foundationandandroidx.tv:tv-materialfor 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 contentPaddingwith 48dp horizontal gives breathing room from screen edgesArrangement.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
onClickon 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/LazyColumninstead ofTvLazyRow/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
keyinitems()calls. Without stable keys, focus position is lost on recomposition. Always useitems(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.
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
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.

