coocaa_flutter_focus

coocaa_flutter_focus 0.4.4 is a Flutter focus and interaction library for TV apps,
remote controls, directional keyboards, gamepads, and touch input.
It provides a small public API around Flutter's FocusNode system, with
behavior tuned for large-screen interfaces: arrow-key traversal, focus groups,
focus memory, edge handling, focus-id lookup, and automatic scroll visibility.
Platform Support
This package uses Flutter framework APIs only. It has no native plugin code and no platform-specific imports.
It is best suited for Android TV, large-screen Android apps, desktop keyboard apps, and web keyboard navigation. iOS is supported at the framework level when an external keyboard or directional input is available. Pointer actions work on every Flutter platform that provides touch, mouse, or stylus input.
Features
- Global directional focus coordination through
FocusController. - Focus registration and lifecycle handling through
FocusableWidget. - Logical focus areas through
FocusableGroup. - Group edge modes:
crossing,greedy, andblocked. - Group inner-focus modes:
greedyandcrossing. - Group focus memory and
onBeforeFocusEnteroverrides. - Focus lookup and request by string
focusId. - Automatic scrolling for single taps and long-press directional movement.
- Configurable scroll edge offset and momentum through
FocusScrollConfig. - Scroll lifecycle observation through
addScrollListenerandFocusScrollEvent. - Back-key interception with newest-first callback order.
- Unified tap, double-tap, and long-press actions for touch, mouse, keyboard, TV remote, and gamepad input.
- Runtime switches for pointer support and all focus-system interaction.
- Test coverage for traversal, nested groups, scrolling, long press, and cache invalidation.
Installation
Add the package to your Flutter project:
dependencies:
coocaa_flutter_focus: ^0.4.4
For local development:
dependencies:
coocaa_flutter_focus:
path: ../coocaa_flutter_focus
Then import the public library entry:
import 'package:coocaa_flutter_focus/coocaa_flutter_focus.dart';
Quick Start
Initialize the controller once near app startup. Use
FocusController.instance.navigatorKey if you want the controller to coordinate
with your app navigator.
Key setup
FocusController.instance ..init() ..updateConfig(scrollEdgeOffset: 80); MaterialApp( navigatorKey: FocusController.instance.navigatorKey, home: const FocusDemoPage(), );
import 'package:coocaa_flutter_focus/coocaa_flutter_focus.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
FocusController.instance
..init()
..updateConfig(scrollEdgeOffset: 80);
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
navigatorKey: FocusController.instance.navigatorKey,
home: const FocusDemoPage(),
);
}
}
class FocusDemoPage extends StatelessWidget {
const FocusDemoPage({super.key});
@override
Widget build(BuildContext context) {
return FocusableGroup(
edgeFocusMode: FocusableGroupEdgeFocusMode.crossing,
child: Row(
children: List<Widget>.generate(4, (int index) {
return Padding(
padding: const EdgeInsets.all(8),
child: FocusableWidget(
focusId: 'card-$index',
autofocus: index == 0,
onEdge: (FocusNode node, LogicalKeyboardKey direction) {
debugPrint('Reached edge: $direction');
return null;
},
child: Builder(
builder: (BuildContext context) {
final bool focused = Focus.of(context).hasFocus;
return AnimatedContainer(
duration: const Duration(milliseconds: 120),
width: 160,
height: 96,
alignment: Alignment.center,
decoration: BoxDecoration(
color: focused ? Colors.blue : Colors.grey.shade700,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Card $index',
style: const TextStyle(color: Colors.white),
),
);
},
),
),
);
}),
),
);
}
}
Dispose the controller when the owning app or test surface is torn down:
@override
void dispose() {
FocusController.instance.dispose();
super.dispose();
}
FocusController
FocusController.instance is the global coordinator.
Common methods:
init()registers keyboard, focus, and metrics listeners.dispose()removes listeners, stops pending scroll activity, and clears controller state.updateConfig(...)updates scrolling and input behavior at runtime. UsetouchEnabledto control pointer actions andinteractionEnabledto suspend directional navigation and allFocusableWidgetactions.setScrollConfig(config)updates onlyFocusScrollConfig.clearScrollEdgeOffset()removes the configured edge offset.isDirectionKey(key)returns true for arrow keys.findNextFocusNode(direction)resolves the next candidate without requesting focus.requestFocus(node, direction: ..., scrollable: true)requests focus and optionally scrolls the node into view.findFocusableById(id)returns a registered node byfocusId.requestFocusById(id, direction: ..., scrollable: true)requests focus byfocusId.addBackInterceptor(callback)intercepts physical and system back requests. It returns a remover callback.removeBackInterceptor(callback)removes a previously registered back interceptor.handleBack()runs registered back interceptors before navigation. Use it when application code explicitly owns the back action.animateScrollPositionTo(position, target, ...)runs the same scroll animator used by focus movement for a specificScrollPosition.stopScrollPosition(position)stops an active focus scroll animation for a specificScrollPosition.addScrollListener(callback)observes controller-driven scroll start, update, end, and cancel events. It returns a remover callback.
Physical back keys run when the matching key is released. Physical and system
back requests run BackInterceptor callbacks newest first before popup
dismissal or route navigation; return true to consume the request. When no
interceptor consumes it, the back request closes a focused popup first, then
calls Navigator.maybePop(), and exits only when no route handles the request.
Low-level registration methods:
registerFocusable(...)andunregisterFocusable(node)are used byFocusableWidget. Call them directly only when building a custom focusable wrapper.registerGroup(...)andunregisterGroup(groupKey)are used byFocusableGroup. Call them directly only when building a custom group wrapper.
FocusableWidget
Wrap every focusable item with FocusableWidget.
Important properties:
focusNode: provide your own node, or let the widget create one.autofocus: request initial focus through Flutter's focus system.canRequestFocusandskipTraversal: mirror standardFocusbehavior.autoScroll: allow or disable focus-driven scroll alignment for this item.focusId: register the node forfindFocusableByIdandrequestFocusById.debugLabel: label the internally created node.onKeyEvent: handle custom key events before default traversal.onFocusChange: observe focus changes.onDirection: override directional traversal. Return a target node to take over the move, return the same node to consume the repeat, or returnnullto use default traversal.onEdge: observe or override edge behavior.onInitNode: receive the effectiveFocusNode.onTap,onDoubleTap, andonLongPress: receive the sameFocusableActionDetailsshape for pointer and keyboard-style activation.onTapStateChanged: observe pressed state transitions while an activation is pending or being recognized.enableLongPress: enable or disable long-press recognition independently of theonLongPresscallback.requestFocusOnPointerAction: request focus before a pointer action callback; defaults totrue.activationKeys: customize the keyboard, remote, and gamepad keys that activate this widget.
Unified Actions
FocusableWidget exposes the same action callbacks for touch, mouse, keyboard,
TV remote, and gamepad input:
FocusableWidget(
onTap: (FocusableActionDetails details) {
openItem();
},
onDoubleTap: (FocusableActionDetails details) {
addToFavorites();
},
onLongPress: (FocusableActionDetails details) {
openContextMenu();
},
child: const ItemCard(),
)
Use details.source to distinguish FocusableActionSource.pointer from
FocusableActionSource.keyboard. Pointer actions include positions and the
pointer kind; keyboard-style actions include the triggering logical key.
The default activation keys are Select, Enter, Numpad Enter, Space, and gamepad
A. A double tap suppresses its single-tap callbacks, and a recognized long
press suppresses the tap on release. When onDoubleTap is configured, a single
tap waits for Flutter's double-tap window before firing.
Input Configuration
Both global switches default to true and can be changed at runtime:
FocusController.instance.updateConfig(
touchEnabled: true,
interactionEnabled: true,
);
touchEnabled: falseremovesFocusableWidgetpointer gesture recognizers. Keyboard and TV remote actions continue to work.interactionEnabled: falseconsumes direction keys before traversal and disables tap, double-tap, and long-press actions from every input source. Back-key interception and custom rawonKeyEventhandling remain available.- Re-enabling either switch updates existing actionable widgets immediately. Disabling interaction also cancels pending action timers and directional long-press state.
FocusableGroup
Use FocusableGroup to model a row, panel, section, list, dialog, or any
logical focus area.
Important properties:
limitDirections: directions that may be constrained at this group's edge.edgeFocusMode: controls how traversal behaves when the group has no in-group target.innerFocusMode: controls whether in-group search accepts all directional candidates or only cross-axis-overlapping candidates. It defaults toFocusableGroupInnerFocusMode.greedy.memory: restore the last focused child when entering the group.onGroupFocusChange: reports group enter and leave state.onBeforeFocusEnter: choose a child before group memory or geometry wins.onEdge: observe or override group edge behavior.edgePadding: reserve extra viewport space for this group when scrolling.scrollCenter: center group targets when focus-driven scroll alignment runs.
Geometry
FocusableGroup uses the size of its child and does not expand simply because
it wraps a widget. Provide explicit constraints in application layout, such as
SizedBox, Expanded, or Positioned, when the group should represent a
larger panel or viewport. Directional entry compares the group boundary itself;
focus memory chooses the child to enter but does not alter that boundary.
Edge Modes
FocusableGroupEdgeFocusMode.crossing: the default. Leaving the group prefers candidates that geometrically cross the current edge.FocusableGroupEdgeFocusMode.greedy: if no in-group candidate is found, continue to the nearest candidate on the requested side.FocusableGroupEdgeFocusMode.blocked: block configuredlimitDirections; unconfigured directions behave like greedy traversal.
Inner Focus Modes
FocusableGroupInnerFocusMode.greedy: the default. Search any valid directional target inside the current group before searching outside it.FocusableGroupInnerFocusMode.crossing: only accept in-group targets that overlap the current focus on the cross axis. If none qualifies, the existing edge mode and direction limits decide whether to search outside the group.
Scroll Tuning
Use FocusScrollConfig for single-tap and long-press scroll motion:
FocusController.instance.updateConfig(
scrollEdgeOffset: 96,
scrollConfig: const FocusScrollConfig(
singleTapDuration: Duration(milliseconds: 380),
singleTapMinDuration: Duration(milliseconds: 200),
singleTapVelocity: 1250,
singleTapRetargetVelocity: 3000,
longPressStartDelay: Duration(milliseconds: 140),
longPressAccelerationDuration: Duration(milliseconds: 520),
longPressInitialVelocity: 520,
longPressMaxVelocity: 3600,
),
);
scrollEdgeOffset keeps focused content away from the viewport edge. Group
edgePadding can further constrain focus scroll behavior inside nested or
large content sections.
Testing
Widget tests should initialize and dispose the controller explicitly:
void main() {
setUp(() {
FocusController.instance.init();
});
tearDown(() {
FocusController.instance.dispose();
});
}
Run static analysis:
flutter analyze
Run tests:
flutter test
Exported API
Import only the public library entry:
import 'package:coocaa_flutter_focus/coocaa_flutter_focus.dart';
The package exports:
FocusControllerFocusScrollConfigFocusableWidgetFocusableActionCallbackFocusableActionDetailsFocusableActionSourceFocusableActionTypedefaultFocusableActivationKeysFocusableGroupFocusableGroupEdgeFocusModeFocusableGroupInnerFocusModeFocusDirectionCallbackFocusEdgeCallbackGroupFocusChangeCallbackGroupBeforeFocusEnterCallbackBackInterceptorFocusScrollEventFocusScrollListenerFocusScrollPhaseFocusScrollSource
Contact
For questions or feedback, contact wuronghua@coocaa.com.