dpad 3.2.0
dpad: ^3.2.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
βΆ Try the live demo β the example app in your browser; arrow keys, Enter and Esc are the remote.
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.2.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 direction independently chooses what happens at the boundary β set the
axis-wide horizontalEdge / verticalEdge, then override any single side
with leftEdge / rightEdge / upEdge / downEdge:
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 |
lineWrap |
Focus steps to the next grid line: right past a row ends on the first cell of the row below (and back); at the grid's first/last line onEdge fires. Lazy grids scroll to reveal the next line. |
DpadRegion(
horizontalEdge: DpadEdgeBehavior.wrap, // an endless episode carousel
onEdge: (direction) => playBumpSound(),
onFocusChange: (inside) => setState(() => highlighted = inside),
child: episodeRow,
)
// A grid: line-wrap rows, and never escape sideways.
DpadRegion(
horizontalEdge: DpadEdgeBehavior.lineWrap,
child: settingsGrid,
)
// A row that stops at its end but still lets users reach the nav rail.
DpadRegion(
horizontalEdge: DpadEdgeBehavior.stop,
leftEdge: DpadEdgeBehavior.leave, // only the rail stays reachable
child: row,
)
Direction names are physical β they name the remote's keys β so they mean the same thing in RTL layouts. Reading-order-aware behavior (grid line wrap, initial focus placement) resolves RTL automatically.
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,
)
Flying highlight #
Per-item effects decorate items in place. For the continuous look of mainstream TV and game interfaces β one highlight that physically slides from item A to item B β turn on the flying highlight:
MaterialApp(
builder: Dpad.wrap(
highlight: const DpadHighlight(), // ring by default, 220ms flights
),
)
While it is on, pair per-item effects with a simple lift (the highlight carries the outline):
theme: const DpadThemeData(effects: [DpadScaleEffect()]),
The flight follows the focused item through scrolls, route transitions and
scale animations, chains smoothly when keys are pressed faster than flights
finish, fades in place on first appearance and fades out when focus is lost.
A blocked move (last item of a row, bottom of a list) bounces the highlight
off the edge when you set nudgeOffset.
It follows shapes, too. The highlight reads each item's own silhouette
(ClipRRect, a BoxDecoration corner radius or circle, ClipOval, physical
models) and morphs its corner radius between them mid-flight β a 12-pixel
poster grows into a circular avatar's full rounding as the highlight flies
there (the example's Library grid demonstrates it). The rounded effects
(ring, spotlight, vignette, glow) follow the shape by default
(followShape: true, an explicit switch); pass an explicit borderRadius
to pin it. Shape data lives somewhere else? Mix your own table with the
shipped detection via DpadHighlight.radiusOf(node).
One configuration type, three placement points β resolved for wherever focus lands, nearest first:
Dpad(highlight: const DpadHighlight(), ...) // app-wide default
DpadHighlightTheme( // a page or section
data: const DpadHighlight(
effects: [DpadHighlightBracketsEffect()], // the game reticle look
),
child: characterGrid,
)
DpadFocusable( // a single item
highlight: DpadHighlight.none, // β¦or opt the item out
child: customPaintedButton,
)
Looks are composable effects β motion knobs live on DpadHighlight
(duration, curve, padding, appearDuration, nudgeOffset), looks on
the effect list. Try curve: Curves.easeOutBack for a springy flight.
Built-ins: DpadHighlightRingEffect (tvOS ring), DpadHighlightGlowEffect,
DpadHighlightSpotlightEffect (translucent fill),
DpadHighlightBracketsEffect (game HUD corners),
DpadHighlightVignetteEffect (dims everything outside the item), and
DpadHighlightCustomEffect for anything else.
The escape hatch is a real API: subclass DpadHighlightEffect or use the
custom effect β both receive the live DpadHighlightState every frame: the
interpolating rect and radius, the tracked node, the flight's timeline
(fromNode, t), plus pressed and visible. That is everything needed to
interpolate values the engine does not β colors, shadows, private shape
tables:
DpadHighlightCustomEffect((context, state) {
final Color color = Color.lerp(
posterColorOf(state.fromNode ?? state.node),
posterColorOf(state.node),
state.t,
)!;
return state.positioned(MyFocusHalo(color: color, radius: state.radius));
})
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],
),
)
Game controllers #
On Android (including Android TV and Fire TV), paired game controllers work
out of the box: the d-pad reports as arrow keys and the A/B buttons are
already mapped to select/back in the default DpadKeySet. Buttons like
gameButtonX, gameButtonY and the shoulders arrive as
LogicalKeyboardKeys too β map them as extra select keys via copyWith, or
as actions via Dpad.shortcuts.
Two things a controller can do that no key remap can reach:
-
Analog sticks report as axis motion events, not key events, and Flutter does not synthesize keys from them. Read them with a gamepad plugin and drive navigation programmatically β
DpadControllerexists exactly for input sources that are not keyboards:gamepads.events?.listen((event) { final dpad = Dpad.of(navigatorContext); switch (event.key) { case GamepadButton.dpadUp: dpad.moveUp(); case GamepadButton.dpadDown: dpad.moveDown(); case GamepadButton.dpadLeft: dpad.moveLeft(); case GamepadButton.dpadRight: dpad.moveRight(); default: break; } }); -
Web and desktop never deliver gamepad input as keyboard events, so controllers need the same plugin glue there (browsers expose gamepads only through the Gamepad API; macOS/Windows treat them as HID gamepads). Desktop mappers like Steam Input or JoyToKey also work β they synthesize real key events, which the default key set already speaks.
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.19.
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! ππΊβ¨
