Stay focused on Android TV: focus in Flutter

Een tv heeft geen touchscreen en geen muis: alleen een afstandsbediening met pijltjes, OK en terug. Alles wat een gebruiker doet, loopt via focus. Daarom is Android TV de beste plek om te leren hoe focus werkt in Flutter, en wat je daar leert, maakt je app ook op elk ander platform beter. Dit is de geschreven versie van de talk die Koen gaf op Flutter Belgium Meetup #13 bij Aaltra in Melle; de video staat onderaan.
Wat focus is, en waarom het overal telt
Focus bepaalt welk element op het scherm de input krijgt. Op het web en op desktop zie je het als je met Tab door een formulier gaat: mensen die een toetsenbord gebruiken in plaats van een muis, rekenen erop. Maar ook op mobiel telt focus:
- Invoervelden. Het veld waarin je typt, moet duidelijk uitgelicht zijn, niet alleen een knipperende cursor tonen. Gebruikers mogen nooit zelf moeten uitzoeken waar op het scherm ze gegevens aanpassen.
- Schermlezers. Een schermlezer gaat door de elementen die focus kunnen krijgen en leest ze voor. Een element dat geen focus kan krijgen, wordt gewoon overgeslagen.
- Afstandsbedieningen en toetsenborden. Op een tv verplaatsen de pijltjes de focus van het ene element naar het volgende, dus de volgorde moet in de hele UI kloppen.
Flutter draait op mobiel, desktop, web en tv vanuit één codebase. Ontwerp dus niet voor Android, iOS, macOS of Linux, maar voor schermen: een klein scherm, een tablet, een desktop, een tv. Implementeer daarna de juiste interacties voor elk, en focus is daar één van.
Focus in Flutter
De meeste Flutter-widgets regelen focus al zelf. Een ListTile, een ElevatedButton of een InkWell uit Material of Cupertino kan meteen focus krijgen en ziet er dan anders uit. Keyboard listeners en tekstvelden hebben ook focus nodig, anders krijgen ze nooit input.
In onze projecten gebruiken we de list items van Material zelden: we bouwen onze eigen componenten, zodat we er de volledige controle over houden, wat er ook verandert in de volgende versie van Material. Dat betekent ook dat we focus zelf implementeren. Twee tools doen het werk:
FocusNode: één per element dat focus kan krijgen, zoals een knop of een tegel. Veel ingebouwde widgets maken er zelf een aan.FocusScopeNode: een groep elementen, zoals een scherm, een zijbalk of een dialoog. Open je een zijbalk, dan neemt zijn scope de focus; sluit je hem, dan gaat de focus terug naar waar hij was.
In de counter-app is de floating action button bijvoorbeeld het enige element met een focus node; geef hem een focusColor en je ziet wanneer hij focus heeft. Een tv-scherm met een instellingenpaneel heeft twee focus scopes: één voor het scherm en één voor het paneel.
Flutter op Android TV
Android TV is gewoon Android op een ander scherm, dus een Flutter-app draait er zoals hij is. Maar hij verschijnt niet op het startscherm van de tv: je zou hem in de instellingen moeten zoeken om hem te openen, en dat doet niemand. Drie dingen in AndroidManifest.xml lossen dat op:
- een banner: de tegel die voor je app op het startscherm van de tv staat, in plaats van het launcher-icoon;
- de categorie
LEANBACK_LAUNCHER, want Leanback is hoe Android TV zijn tv-interface noemt; - Leanback en het touchscreen als features die niet verplicht zijn, zodat dezelfde app op smartphones, tablets en tv's installeert, als één app in de Play Store.
<manifest ...>
<!-- runs on TV, but a TV is not required: one app for phones, tablets and TVs -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<application android:banner="@drawable/banner" ...>
<activity ...>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>Een tip uit ervaring: de klok van de Android TV-emulator staat bijna elke keer verkeerd als je hem start, en dat al jaren. Met een verkeerde klok falen API-calls op certificaten, dus zet de tijd juist voor je begint te debuggen.
Een afstandsbediening is een toetsenbord
De afstandsbediening van een tv gedraagt zich als een toetsenbord: de pijltjes verplaatsen de focus naar boven, onder, links en rechts, OK selecteert en terug gaat terug. Met Material-widgets heb je niets extra nodig: een lijst knoppen werkt meteen met de pijltjes en met Tab, en aan het einde van een lijst gaat de focus door naar de volgende.
De volgorde zelf bepalen
Standaard volgt Tab de leesvolgorde: van links naar rechts (of van rechts naar links in talen die zo lezen), en dan naar beneden. Op een scherm met twee kolommen springt de focus dus tussen de kolommen. Om eerst de hele eerste kolom te doorlopen en dan de tweede, zet je elke kolom in een FocusTraversalGroup: de focus gaat door alle elementen van een groep voor hij naar de volgende groep gaat.
Om te kiezen welke groep eerst komt, geef je de bovenliggende groep een OrderedTraversalPolicy en elk kind een FocusTraversalOrder met een NumericFocusOrder, vanaf 0. Zonder die policy op de bovenliggende groep wordt de volgorde gewoon genegeerd.
FocusTraversalGroup(
policy: OrderedTraversalPolicy(),
child: Row(
children: [
FocusTraversalOrder(
order: const NumericFocusOrder(1),
child: FocusTraversalGroup(child: blueList),
),
FocusTraversalOrder(
order: const NumericFocusOrder(0),
child: FocusTraversalGroup(child: greenList),
),
],
),
)Wees hier voorzichtig mee: een schermlezer volgt dezelfde volgorde. Dat kan precies zijn wat je wilt (een item dat eerst voorgelezen moet worden), maar controleer of het klopt op elk platform dat focus gebruikt.
Je eigen widget met focus
Een tegel in een tv-app toont meestal een rand of wordt groter als hij focus heeft. Voor een eigen widget bouw je dat zelf: een stateful widget met een eigen FocusNode, een listener die de widget opnieuw bouwt als de focus verandert, en hasFocus om te bepalen hoe hij eruitziet. Dispose de focus node altijd, anders lekt hij geheugen.
class MovieTile extends StatefulWidget {
const MovieTile({required this.movie, this.autofocus = false, super.key});
final Movie movie;
final bool autofocus;
@override
State<MovieTile> createState() => _MovieTileState();
}
class _MovieTileState extends State<MovieTile> {
// a debug label saves a lot of time once the UI gets complex
late final _focusNode = FocusNode(debugLabel: 'MovieTile ${widget.movie.title}');
@override
void initState() {
super.initState();
_focusNode.addListener(_onFocusChanged);
}
void _onFocusChanged() => setState(() {});
@override
void dispose() {
_focusNode.removeListener(_onFocusChanged);
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Focus(
focusNode: _focusNode,
autofocus: widget.autofocus,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
decoration: BoxDecoration(
// transparent when not focused: the tile keeps its size
border: Border.all(
color: _focusNode.hasFocus ? Colors.yellow : Colors.transparent,
width: 3,
),
),
child: Image.network(widget.movie.poster),
),
);
}
}Zonder rand is de afbeelding een paar pixels groter dan met, dus een tegel verspringt als hij focus krijgt. Een transparante rand zonder focus houdt de grootte gelijk.
Focus scopes, autofocus en een instellingenpaneel
Zet elk scherm in een FocusScope met een eigen FocusScopeNode (ook met een debug label) en zet autofocus aan, zodat er iets geselecteerd is zodra de app opent. Autofocus kiest het eerste element dat focus kan krijgen. Komt de inhoud van een API, dan is die er niet in het eerste frame: geef ook het eerste item van de lijst autofocus, zodat het de focus neemt zodra het geladen is.
Voor een instellingenpaneel zoals dat van Android TV zelf luister je naar de toetsen in de focus scope van het scherm: drukt de gebruiker op links en is er links niets meer, open dan het paneel; met rechts sluit je het weer. Elke druk op een toets stuurt twee events, een key down en een key up. Reageer op maar één van de twee, anders zet elke druk de focus twee stappen verder.
Scrollen en padding
Flutter scrolt het element met focus vanzelf in beeld. Toen Koen met Android TV begon, moest hij de beweging naar het volgende en vorige element nog zelf schrijven; dat is intussen opgelost, en een tv-app scrolt nu zoals elke andere Flutter-app. Wat nog werk vraagt, is padding: aan het einde van een lijst wil je misschien wat ruimte na het laatste item, maar je kunt niet scrollen naar ruimte die geen focus kan krijgen. Een eigen traversal policy die die padding toevoegt, lost dat op.
En Apple TV?
tvOS is geen iOS en geen macOS, en Flutter ondersteunt het niet officieel. Op Fluttercon toonde een team hoe ze Flutter op Apple TV draaien met een eigen engine, voor release-, profile- en debug-builds, voor Liberty Global. Ze schreven erover en delen builds om uit te proberen. Het plan is een eigen embedder, zodat Flutter zelf niet aangepast hoeft te worden.
Wil je vandaag met Flutter op Apple TV beginnen, kijk dan op fluttertv.dev: een tvOS-engine, een CLI die werkt zoals die van Flutter, ports van plugins en debuggen op een echte Apple TV.
Bekijk de volledige talk
Stay focused on Android TV, Flutter Belgium Meetup #13, met de live demo's (in het Engels).
Bouw je een app die moet werken met een toetsenbord, een afstandsbediening of een schermlezer? Dat hoort bij elke app die we bouwen.
