From dd11e8d3b292ae950a4dd39d7b02e97cc2c62588 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 14 Mar 2026 11:20:31 +0100 Subject: [PATCH] fix: scroll list tiles into view on dpad focus --- lib/widgets/focusable_list_tile.dart | 188 ++++++++++++++++++++++++--- 1 file changed, 168 insertions(+), 20 deletions(-) diff --git a/lib/widgets/focusable_list_tile.dart b/lib/widgets/focusable_list_tile.dart index 7ad0ccf6..b2470125 100644 --- a/lib/widgets/focusable_list_tile.dart +++ b/lib/widgets/focusable_list_tile.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../focus/dpad_navigator.dart'; +import '../utils/scroll_utils.dart'; /// A ListTile that accepts a FocusNode for keyboard/controller navigation. /// @@ -81,6 +82,51 @@ class FocusableListTile extends StatefulWidget { class _FocusableListTileState extends State { bool _suppressionConsumed = false; bool _isHoveredOrFocused = false; + late FocusNode _effectiveFocusNode; + bool _ownsNode = false; + + @override + void initState() { + super.initState(); + _initFocusNode(); + } + + @override + void didUpdateWidget(FocusableListTile oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.focusNode != oldWidget.focusNode) { + _disposeFocusNode(); + _initFocusNode(); + } + } + + @override + void dispose() { + _disposeFocusNode(); + super.dispose(); + } + + void _initFocusNode() { + if (widget.focusNode != null) { + _effectiveFocusNode = widget.focusNode!; + _ownsNode = false; + } else { + _effectiveFocusNode = FocusNode(); + _ownsNode = true; + } + _effectiveFocusNode.addListener(_onFocusChange); + } + + void _disposeFocusNode() { + _effectiveFocusNode.removeListener(_onFocusChange); + if (_ownsNode) _effectiveFocusNode.dispose(); + } + + void _onFocusChange() { + if (_effectiveFocusNode.hasFocus) { + scrollContextToCenter(context); + } + } @override Widget build(BuildContext context) { @@ -104,7 +150,7 @@ class _FocusableListTileState extends State { enabled: widget.enabled, selected: widget.selected, contentPadding: widget.contentPadding, - focusNode: widget.suppressInitialSelect ? null : widget.focusNode, + focusNode: widget.suppressInitialSelect ? null : _effectiveFocusNode, autofocus: widget.suppressInitialSelect ? false : widget.autofocus, hoverColor: widget.hoverColor, textColor: textColor, @@ -117,7 +163,7 @@ class _FocusableListTileState extends State { } return Focus( - focusNode: widget.focusNode, + focusNode: _effectiveFocusNode, autofocus: widget.autofocus, onKeyEvent: (node, event) { if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) { @@ -138,7 +184,7 @@ class _FocusableListTileState extends State { /// /// Uses Flutter's native RadioListTile focus support - no custom styling wrapper. /// Requires a [RadioGroup] ancestor to manage selection state. -class FocusableRadioListTile extends StatelessWidget { +class FocusableRadioListTile extends StatefulWidget { /// The primary content of the list tile. final Widget? title; @@ -175,18 +221,69 @@ class FocusableRadioListTile extends StatelessWidget { this.enabled, }); + @override + State> createState() => _FocusableRadioListTileState(); +} + +class _FocusableRadioListTileState extends State> { + late FocusNode _effectiveFocusNode; + bool _ownsNode = false; + + @override + void initState() { + super.initState(); + _initFocusNode(); + } + + @override + void didUpdateWidget(FocusableRadioListTile oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.focusNode != oldWidget.focusNode) { + _disposeFocusNode(); + _initFocusNode(); + } + } + + @override + void dispose() { + _disposeFocusNode(); + super.dispose(); + } + + void _initFocusNode() { + if (widget.focusNode != null) { + _effectiveFocusNode = widget.focusNode!; + _ownsNode = false; + } else { + _effectiveFocusNode = FocusNode(); + _ownsNode = true; + } + _effectiveFocusNode.addListener(_onFocusChange); + } + + void _disposeFocusNode() { + _effectiveFocusNode.removeListener(_onFocusChange); + if (_ownsNode) _effectiveFocusNode.dispose(); + } + + void _onFocusChange() { + if (_effectiveFocusNode.hasFocus) { + scrollContextToCenter(context); + } + } + @override Widget build(BuildContext context) { return RadioListTile( - title: title, - subtitle: subtitle, - secondary: secondary, - value: value, + title: widget.title, + subtitle: widget.subtitle, + secondary: widget.secondary, + value: widget.value, // groupValue and onChanged provided by RadioGroup ancestor - dense: dense, - focusNode: focusNode, - autofocus: autofocus, - enabled: enabled, + dense: widget.dense, + focusNode: _effectiveFocusNode, + autofocus: widget.autofocus, + enabled: widget.enabled, ); } } @@ -194,7 +291,7 @@ class FocusableRadioListTile extends StatelessWidget { /// A SwitchListTile that accepts a FocusNode for keyboard/controller navigation. /// /// Uses Flutter's native SwitchListTile focus support - no custom styling wrapper. -class FocusableSwitchListTile extends StatelessWidget { +class FocusableSwitchListTile extends StatefulWidget { /// The primary content of the list tile. final Widget? title; @@ -231,17 +328,68 @@ class FocusableSwitchListTile extends StatelessWidget { this.autofocus = false, }); + @override + State createState() => _FocusableSwitchListTileState(); +} + +class _FocusableSwitchListTileState extends State { + late FocusNode _effectiveFocusNode; + bool _ownsNode = false; + + @override + void initState() { + super.initState(); + _initFocusNode(); + } + + @override + void didUpdateWidget(FocusableSwitchListTile oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.focusNode != oldWidget.focusNode) { + _disposeFocusNode(); + _initFocusNode(); + } + } + + @override + void dispose() { + _disposeFocusNode(); + super.dispose(); + } + + void _initFocusNode() { + if (widget.focusNode != null) { + _effectiveFocusNode = widget.focusNode!; + _ownsNode = false; + } else { + _effectiveFocusNode = FocusNode(); + _ownsNode = true; + } + _effectiveFocusNode.addListener(_onFocusChange); + } + + void _disposeFocusNode() { + _effectiveFocusNode.removeListener(_onFocusChange); + if (_ownsNode) _effectiveFocusNode.dispose(); + } + + void _onFocusChange() { + if (_effectiveFocusNode.hasFocus) { + scrollContextToCenter(context); + } + } + @override Widget build(BuildContext context) { return SwitchListTile( - title: title, - subtitle: subtitle, - secondary: secondary, - value: value, - onChanged: onChanged, - dense: dense, - focusNode: focusNode, - autofocus: autofocus, + title: widget.title, + subtitle: widget.subtitle, + secondary: widget.secondary, + value: widget.value, + onChanged: widget.onChanged, + dense: widget.dense, + focusNode: _effectiveFocusNode, + autofocus: widget.autofocus, ); } }