dpad 3.0.0 copy "dpad: ^3.0.0" to clipboard
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

δΈ­ζ–‡ζ–‡ζ‘£

Dpad Logo

Pub Version Platform License

Focus that behaves the way TV users expect β€” regions with memory, beam-based directional traversal, press feedback, and a remote that never goes dead.

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 DpadRegion keeps 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: true outlines 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:

  1. One autofocus per 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.

  2. One DpadRegion per visual section. Sidebar, each shelf row, each grid. Region-first traversal and focus memory are what make navigation feel native.

  3. Use memoryKey on regions inside section switchers. Plain state dies with the subtree; keyed memory survives any rebuild.

  4. 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.

  5. stop the edges of anchored panels, wrap carousels. A sidebar should never let focus fall off the screen; an episode strip can loop.

  6. Wire actions through onSelect, not the child's onPressed. DpadFocusable is the single focus stop; a wrapped Material button is just visuals.

  7. Don't fight text fields. Leave TextFields bare (not wrapped in DpadFocusable); arrows edit text mid-string and navigate away at the edges automatically.

  8. 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 #

  • DpadTraversalPolicy is a FocusTraversalPolicy β€” plain Focus/ElevatedButton widgets participate in navigation, dialogs trap focus through their own FocusScope, and popping a route restores focus to the item that opened it, all natively.
  • DpadFocusable excludes its child's focus by default (excludeChildFocus: true) so wrapping a button never creates two d-pad stops. Set it to false when the child must own focus (e.g. a TextField).
  • 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 #


Make Flutter shine on the big screen! πŸš€πŸ“Ίβœ¨

16
likes
160
points
992
downloads
screenshot

Documentation

Documentation
API reference

Publisher

verified publisherfluttercandies.com

Weekly Downloads

Production-grade D-pad navigation for Flutter TV apps. Focus regions with memory, TV-correct directional traversal, focus effects and remote-key handling.

Repository (GitHub)
View/report issues

Topics

#android-tv #dpad-navigation #focus-management #fire-tv #apple-tv

License

MIT (license)

Dependencies

flutter

More

Packages that depend on dpad