It's a long open issue, but here we go:
Android TV support is to the date a bit shaky. But there are some easier (and some not so easy) workarounds to help us.
Basically start by reading this Medium article:
Adding Android TV support to your Flutter App
The first important thing to do is to add support for the TV remote's Dpad by wrapping your ap in a Shortcut
widget:
return Shortcuts(
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.select): ActivateIntent(),
},
child: MaterialApp(
...
);
This supposedly forwards the select
event from the keyboard to activate
intent. ️
So far all your selectable stuff (buttons, etc.) should work. TextFormFields
probably still won't, though.
Then go check this Flutter issue on GitHub: Focus gets lost on Android TV #49783
Hm... I don't know. It's a lengthy issue and I don't find an easy solution there, except for the RawKeyboardListener
based approach. Which is (unintentionally) the way I kinda went.
When I started the endeavor a couple of days back, I didn't know about the Shortcut
approach, so I looked at this package on pub.dev focusnode_widgets. I checked the code and understood, that the guy who did this, had a very specific problem at hand. So I stripped his example down and re-implemented his class _FocusNodeEnterTapActionableWidget
(he really seem to like looooooooooong class names...).
Note: I won't paste this code here, because there is not that much difference, but what I did, I created a wrapper widget, that creates the desired widgets and is able to handle all necessary dpad events - including the ability to walk over TextFormField
s.
In the following example _FocusableWidget
is the replacement for _FocusNodeEnterTapActionableWidget
.
/// get the type from a generic parameter
Type typeOf<T>() => T;
/// if you have large an scrollable pages with large gaps in between of focusable widgets
/// you need something to go to...
/// I used this to navigate through a help/about page
class FocusableAnchor extends StatelessWidget {
@override
Widget build(BuildContext context) => _FocusableWidget(
autoFocus: false,
handleEnterTapAction: (ctx) {},
child: Container(),
focusedBackgroundColor: Colors.transparent,
nonFocusedBackgroundColor: Colors.transparent,
);
}
/// there's another class I will leave out - it's just a wrapper for a given child
/// but I use a custom Android plugin to determine, whether I'm on a TV or not
/// TV: wrap the child widget, No TV: just use the child widget as-is.
/// this is the base wrapper, that is quite large, because it basically has all
/// (some renamed) parameters, needed to create the following widgets:
/// IconButton, FloatingActionButton, MaterialButton, FlatButton, RaisedButton
/// DropdownButton (doesn't really work though, I don't understand why), ListTile
/// CheckboxListTile, RadioListTile (these both are shaky as well)
/// Switch, ActionChip, TextFormField
class FocusableValue<T extends Widget, U> extends StatelessWidget {
FocusableValue({
Key key,
// the key of the widget to create
this.widgetKey,
// IconButton, FloatingActionButton
this.icon, // DropdownButton
this.onPressed,
this.onLongPress,
this.tooltip,
// ... many more
// explicitly set widget
this.widget,
this.colorFocused = true,
}) : super(key: key);
final Key widgetKey;
final Widget icon;
final void Function() onPressed;
final void Function() onLongPress;
final String tooltip;
final T widget;
final bool colorFocused;
// a former colleague decided to use Kiwi for DI...
final _uiMode = inject<UiModePlugin>();
@override
Widget build(BuildContext context) => FutureBuilder(
future: _uiMode.getUiModeType(),
initialData: null,
builder: (ctx, snap) {
if (!snap.hasData || snap.hasError || snap.data == null) {
return Container();
}
var widget = this.widget;
Function(BuildContext) onAction;
Color focusBgColor;
if (widget == null) {
final type = typeOf<T>();
switch (type) {
case IconButton:
widget = IconButton(
key: widgetKey,
icon: icon,
onPressed: onPressed,
tooltip: tooltip,
// ignore: avoid_as
) as T;
onAction = (ctx) => onPressed();
focusBgColor = colorAltDarkTransparent;
break;
// basically create your target here, e.g. TextFormField
// TextFormField doesn't need to set the onAction function, though
}
}
Decoration _decorate(Color color) {
if (color == null) return null;
return BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(3.0),
boxShadow: kElevationToShadow[1],
);
}
EdgeInsetsGeometry _padding(bool apply) {
if (!apply) return null;
return EdgeInsets.only(
top: 5.0,
right: 5.0,
bottom: 5.0,
left: 5.0,
);
}
BoxConstraints _constraints(bool apply) {
if (!apply) return null;
return BoxConstraints.tightFor(height: 37.0);
}
return UIMode.tv == snap.data
? _FocusableWidget(
autoFocus: false,
handleEnterTapAction: onAction ?? (ctx) {},
child: widget,
focusedBackgroundColor: null,
nonFocusedBackgroundColor: null,
focusedBackgroundDecoration:
colorFocused ? _decorate(focusBgColor) : null,
nonFocusedBackgroundDecoration: null,
padding: colorFocused ? _padding(focusBgColor != null) : null,
constraints:
colorFocused ? _constraints(focusBgColor != null) : null,
)
: widget;
},
);
}
/// for the most widgets, except for DropdownButton, RadioListTile I can use this wrapper
class Focusable<T extends Widget> extends FocusableValue<T, dynamic> {
Focusable({
Key key,
Key widgetKey,
Widget icon,
Function() onPressed,
Function() onLongPress,
String tooltip,
// more parameter to create other widgets
T widget,
bool colorFocused = true,
}) : super(
key: key,
widgetKey: widgetKey,
icon: icon,
onPressed: onPressed,
onLongPress: onLongPress,
tooltip: tooltip,
// pass all the other parameters here
widget: widget,
colorFocused: colorFocused,
);
}
This is tedious, but in most of my cases it worked. I only still need to figure out CheckboxListTile
and RadioListTile
- they don't work within my (very heavily) customized dialog.
I hope, that helps a bit.