dpad 3.0.0
dpad: ^3.0.0 copied to clipboard
Production-grade D-pad navigation for Flutter TV apps. Focus regions with memory, TV-correct directional traversal, focus effects and remote-key handling.
πΊ Dpad
D-pad navigation for Flutter TV apps
Why Dpad? #
Flutter's built-in directional focus picks the geometrically closest widget. On a real TV layout that means focus jumps lanes between the sidebar and the content, skips rows, escapes carousels, and forgets where you were. Dpad replaces that engine with the model TV platforms actually use (Android's FocusFinder / Leanback):
- π Regions with memory β a sidebar, a poster row, a grid: each
DpadRegionkeeps focus inside itself first, and when you come back, you land on the item you left. - π Beam-based traversal β candidates aligned with the focused item beat diagonal ones. Down means down, not "down-ish and slightly to the right".
- π§² Edge control per axis β leave, stop, or wrap at every region boundary. Carousels wrap, panels stop, everything else flows.
- π Focus never dies β app starts with focus, pushed pages get initial focus without
autofocus, focus survives list refreshes, dialog dismissals and app resume. A remote with nothing focused is a dead remote; Dpad makes that state unrepresentable. - β― Real remote semantics β select & long-select with pressed-state visuals, back/menu keys, app shortcuts, key repeat β all standing down automatically while a text field is being edited.
- β¨οΈ Text fields done right β mid-text, arrows move the caret; at the caret's edge (and vertically in single-line fields) they navigate away, so a remote-only user is never trapped in a search box. IME composition owns the keys while active.
- π Lazy-list aware β when the next item isn't built yet (
ListView.builder), Dpad scrolls and continues instead of stopping at the cache boundary. - π± Hybrid input β taps and mouse clicks work out of the box for touch TVs and desktop debugging.
- π Built-in focus inspector β
debugOverlay: trueoutlines the focused node with its label, region and size. Focus bugs on TV are invisible without it.
Everything is built on Flutter's own focus system (FocusTraversalPolicy, Shortcuts, Actions) β no key hijacking, full interop with plain Focus widgets, TextFields, and dialogs.
Quick start #
1. Add the dependency #
dependencies:
dpad: ^3.0.0
2. Install the root (one line) #
import 'package:dpad/dpad.dart';
MaterialApp(
builder: Dpad.wrap(), // covers every route, dialog and sheet
home: const HomePage(),
)
3. Make things focusable #
DpadFocusable(
autofocus: true,
onSelect: () => playMovie(movie),
child: PosterCard(movie),
)
That's a working TV app: arrow keys / remote d-pad move focus with a scale-and-border effect (themable), center button calls onSelect, and the focused item auto-scrolls into view with padding for its glow.
Regions: structure your screen like a TV app #
Row(
children: [
// Sidebar: never lets focus fall off the top/bottom of the screen,
// remembers the selected destination.
DpadRegion(
verticalEdge: DpadEdgeBehavior.stop,
child: SidebarColumn(...),
),
Expanded(
child: ListView(
children: [
for (final row in rows)
// Each poster row remembers its position. Down and back up
// returns to the same poster β like every real TV app.
DpadRegion(
child: SizedBox(
height: 200,
child: ListView(scrollDirection: Axis.horizontal,
children: row.cards),
),
),
],
),
),
],
)
Entering a region #
DpadRegion(enter: ...) decides where focus lands when it crosses in:
DpadEnterBehavior |
Landing item |
|---|---|
restore (default) |
The last focused item; falls back to the position-nearest item, the entry item, then the geometric target |
entry |
The item marked DpadFocusable(entry: true) |
nearest |
The geometrically nearest item (plain Flutter behavior) |
Leaving a region #
Each axis independently chooses what happens at the boundary:
DpadEdgeBehavior |
Effect |
|---|---|
leave (default) |
Focus continues to the best target outside |
stop |
Key is consumed, focus stays, onEdge fires (bump animations, sounds) |
wrap |
Focus wraps to the opposite side β carousels |
DpadRegion(
horizontalEdge: DpadEdgeBehavior.wrap, // an endless episode carousel
onEdge: (direction) => playBumpSound(),
onFocusChange: (inside) => setState(() => highlighted = inside),
child: episodeRow,
)
Memory that survives rebuilds #
State-based memory dies when a section switcher rebuilds its subtree β the classic "tabs forget where I was" TV pitfall. Give the region a stable key and the memory persists, position-aware, across any rebuild:
DpadRegion(
memoryKey: 'home/trending-row',
child: trendingRow,
)
Focus effects #
Effects are immutable, const-able, and composable β first effect in the list is the outermost wrapper:
DpadFocusable(
effects: const [
DpadScaleEffect(scale: 1.1), // liftβ¦
DpadGlowEffect(color: Colors.amber), // β¦and glow
],
child: card,
)
Built-ins: DpadScaleEffect (with pressed-state push-down), DpadBorderEffect (layout-shift-free), DpadGlowEffect, DpadElevationEffect, DpadOpacityEffect (dim the rest), DpadTintEffect, DpadCustomEffect.
Set app-wide defaults once:
MaterialApp(
builder: Dpad.wrap(
theme: const DpadThemeData(
effects: [DpadGlowEffect()],
scrollPadding: 64,
),
),
)
Or take full control β including the pressed state of the select key:
DpadFocusable(
onSelect: play,
builder: (context, state, child) => AnimatedScale(
scale: state.pressed ? 0.97 : (state.focused ? 1.06 : 1.0),
duration: const Duration(milliseconds: 120),
child: child,
),
child: card,
)
Remote interactions #
DpadFocusable(
onSelect: () => play(), // center button (or tap)
onLongSelect: () => showOptionsSheet(), // held center button
onFocusChange: (focused) => setState(() => hovered = focused),
onDirection: (direction) { // sliders: consume left/right
if (direction == TraversalDirection.right) { volumeUp(); return true; }
if (direction == TraversalDirection.left) { volumeDown(); return true; }
return false; // up/down keep navigating
},
child: volumeRow,
)
Back, menu, app-level shortcuts, sound feedback and the focus inspector live on the root:
Dpad.wrap(
onBack: () { // back key: pop, or confirm exit at home
if (navigator.canPop()) { navigator.pop(); return true; }
showExitDialog(); return true;
},
onMenu: () => showAboutDialog(),
onFocusChange: (node) { // the app-wide focus "tick" sound
if (node != null) audio.playTick();
},
shortcuts: {
LogicalKeyboardKey.keyS: () => openSearch(),
},
debugOverlay: kDebugMode, // on-screen focus inspector
)
All of these β and directional navigation itself β automatically stand down while a TextField is focused, so typing is never hijacked.
Unusual remote? Remap any key group:
Dpad.wrap(
keySet: const DpadKeySet().copyWith(
select: [LogicalKeyboardKey.f1, ...DpadKeySet.defaultSelect],
),
)
Programmatic control #
final dpad = Dpad.of(context);
dpad.moveDown(); // exactly like a remote key press
dpad.move(TraversalDirection.left);
dpad.select(); // press the focused item
dpad.requestFocus(searchNode); // jump focus (updates region memory)
dpad.ensureVisible(); // padded scroll-into-view
dpad.focused; // the currently focused node
Best practices for TV #
Distilled from the example app β follow these and a complex TV app stays predictable:
-
One
autofocusper screen. Give the primary action (Play, first card)autofocus: true. Everything else is handled: pushed routes, removed items and app resume keep focus alive automatically. -
One
DpadRegionper visual section. Sidebar, each shelf row, each grid. Region-first traversal and focus memory are what make navigation feel native. -
Use
memoryKeyon regions inside section switchers. Plain state dies with the subtree; keyed memory survives any rebuild. -
Lay out shelves with the padding inside the scroll view.
SizedBox( height: cardHeight + 32, // headroom for effects child: ListView.builder( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 36, vertical: 16), ... ), )Scaled and glowing focus effects paint into the padding instead of being clipped at the row edge.
-
stopthe edges of anchored panels,wrapcarousels. A sidebar should never let focus fall off the screen; an episode strip can loop. -
Wire actions through
onSelect, not the child'sonPressed.DpadFocusableis the single focus stop; a wrapped Material button is just visuals. -
Don't fight text fields. Leave
TextFields bare (not wrapped inDpadFocusable); arrows edit text mid-string and navigate away at the edges automatically. -
Debug with
debugOverlay: true. Focus bugs are invisible on a TV across the room; the inspector shows the focused node, its region and geometry live.
How it plays with Flutter #
DpadTraversalPolicyis aFocusTraversalPolicyβ plainFocus/ElevatedButtonwidgets participate in navigation, dialogs trap focus through their ownFocusScope, and popping a route restores focus to the item that opened it, all natively.DpadFocusableexcludes its child's focus by default (excludeChildFocus: true) so wrapping a button never creates two d-pad stops. Set it tofalsewhen the child must own focus (e.g. aTextField).- Tab order falls back to reading order.
Example #
The example is a complete TV streaming UI β expanding sidebar, poster rows with memory, wrap-around episode carousel, search with text input, settings with an onDirection volume slider, long-select context sheets, and an exit-confirmation back flow. Run it on a TV emulator or any desktop:
cd example
flutter run -d macos # or windows, linux, an Android TV emulatorβ¦
Platform support #
| Platform | Input |
|---|---|
| Android TV / Google TV | Remote d-pad, game controllers |
| Amazon Fire TV | Fire TV remotes |
| Apple TV (via web/custom embedder) | Siri Remote arrows |
| Desktop (macOS/Windows/Linux) | Arrow keys β ideal for development |
| Web | Arrow keys (Dpad maps them even where Flutter doesn't) |
Requires Flutter >= 3.24.
Migrating from 2.x #
3.0 is a ground-up rewrite with a smaller, TV-first API. The old global history stack, rule tables and registration flags are replaced by declarative widgets:
| 2.x | 3.0 |
|---|---|
DpadNavigator(child: app) |
MaterialApp(builder: Dpad.wrap()) |
Dpad.navigateUp(context) |
Dpad.of(context).moveUp() |
DpadFocusable(builder: (c, focused, child) ...) |
builder: (c, state, child) with state.focused / state.pressed |
FocusEffects.glow() (closures) |
effects: const [DpadGlowEffect()] (const classes) |
FocusMemoryOptions + history stack |
per-DpadRegion memory (enter: DpadEnterBehavior.restore, the default) |
RegionNavigationOptions + RegionNavigationRule tables |
DpadRegion(enter: ..., horizontalEdge: ..., verticalEdge: ...) |
DpadFocusable(region: 'tabs', isEntryPoint: true) |
wrap the tabs in a DpadRegion, mark one item entry: true |
customShortcuts: |
shortcuts: (now suspended during text editing) |
onBackPressed / onMenuPressed |
onBack (returns bool) / onMenu |
See the CHANGELOG for the full list.
β€οΈ Support #
- π Star on GitHub
- π Like on pub.dev
- π Report issues
Make Flutter shine on the big screen! ππΊβ¨
