Skip to content

[framework] Wrong focus navigation with nested list #172854

@romaingyh

Description

@romaingyh

Steps to reproduce

  1. Copy the sample below
  2. With keyboard down arrow, go focus a widget in the last rows
  3. Try to focus back on the first row widgets using up arrow

or

  1. Place a focusable widget (eg: button) in a column
  2. Add a vertical scrollable widget in the column
  3. In this vertical scrollable widget, add n horizontal scrollable widgets with focusable widgets inside
  4. With keyboard down arrow, go focus a widget in the last rows
  5. Try to focus back on the first row widgets using up arrow

Pseudo tree :

Column
-Button
--Vertical scroll
---Horizontal scroll
----Button | Button | Button ...
---Horizontal scroll
----Button | Button | Button ...
....

Expected results

When using up arrow to move focus, if there is any focusable widget inside the vertical scrollable and above the current focused widget, we should scroll up and focus it.

Actual results

The top sticky button is focused because it is closer from focused child in term of absolute distance. There is already a system to handle scrollable when moving focus in the framework but it doesn't take account scrollable axis.

Code sample

Code sample
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  ServicesBinding.instance.keyboard.addHandler((event) {
    if (event is KeyDownEvent) {
      print('KeyDownEvent: ${event.logicalKey.keyLabel}');
    }

    return true;
  });

  runApp(const MainApp());
}

class MainApp extends StatelessWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData.dark(),
      home: Scaffold(
        body: FocusTraversalGroup(
          policy: ReadingOrderTraversalPolicy(
            requestFocusCallback:
                (node, {alignment, alignmentPolicy, curve, duration}) =>
                    FocusTraversalPolicy.defaultTraversalRequestFocusCallback(
                      node,
                      alignment: alignment,
                      alignmentPolicy: alignmentPolicy,
                      curve: curve ?? Curves.easeInOut,
                      duration: duration ?? Duration(milliseconds: 250),
                    ),
          ),
          child: Column(
            spacing: 32,
            children: [
              SizedBox(
                width: double.infinity,
                child: FocusFilledButton(debugLabel: 'sticky button'),
              ),
              Expanded(
                child: SingleChildScrollView(
                  child: Column(
                    spacing: 32,
                    children: [
                      for (var i in List.generate(10, (i) => i))
                        SizedBox(
                          height: 200,
                          child: ListView.separated(
                            scrollDirection: Axis.horizontal,
                            itemCount: 10,
                            itemBuilder: (context, index) => AspectRatio(
                              aspectRatio: 1,
                              child: FocusFilledButton(
                                debugLabel: 'button_${i}_$index',
                                color: Colors.primaries[i % Colors.primaries.length],
                              ),
                            ),
                            separatorBuilder: (_, _) => SizedBox(width: 32),
                          ),
                        ),
                    ],
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class FocusFilledButton extends StatefulWidget {
  const FocusFilledButton({super.key, required this.debugLabel, this.color});

  final String debugLabel;

  final Color? color;

  @override
  State<FocusFilledButton> createState() => _FocusFilledButtonState();
}

class _FocusFilledButtonState extends State<FocusFilledButton> {
  late final FocusNode _focusNode = FocusNode(debugLabel: widget.debugLabel);

  bool _isFocused = false;

  @override
  void dispose() {
    _focusNode.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return FilledButton(
      style: FilledButton.styleFrom(
        backgroundColor: widget.color,
        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(32)),
      ),
      onPressed: () {},
      onFocusChange: (value) {
        setState(() => _isFocused = value);
      },
      focusNode: _focusNode,
      child: Text(_isFocused ? 'Focused' : widget.debugLabel),
    );
  }
}

Screenshots or Video

Actual behavior

Here you can see that after the scroll, when I press up arrow to go back to top, the sticky button takes focus while there are buttons above in the scroll view.

Enregistrement.de.l.ecran.2025-07-28.a.17.02.41.mov
Expected behavior

Now the sticky button only takes focus when the vertical scrollable is at edge.

Enregistrement.de.l.ecran.2025-07-28.a.17.00.50.mov

Logs

Logs
[Paste your logs here]

Flutter Doctor output

Doctor output
[✓] Flutter (Channel stable, 3.32.8, on macOS 15.5 24F74 darwin-arm64, locale fr-FR) [588ms]
    • Flutter version 3.32.8 on channel stable at /Users/romanojw10/Documents/developpement/flutter_arm
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision edada7c56e (3 days ago), 2025-07-25 14:08:03 +0000
    • Engine revision ef0cd00091
    • Dart version 3.8.1
    • DevTools version 2.45.1

[✓] Android toolchain - develop for Android devices (Android SDK version 35.0.0) [1 900ms]
    • Android SDK at /Users/romanojw10/Library/Android/sdk
    • Platform android-36, build-tools 35.0.0
    • ANDROID_HOME = /Users/romanojw10/Library/Android/sdk
    • Java binary at: /Users/romanojw10/Applications/Android Studio.app/Contents/jbr/Contents/Home/bin/java
      This is the JDK bundled with the latest Android Studio installation on this machine.
      To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`.
    • Java version OpenJDK Runtime Environment (build 21.0.5+-13047016-b750.29)
    • All Android licenses accepted.

[✓] Xcode - develop for iOS and macOS (Xcode 16.4) [1 199ms]
    • Xcode at /Applications/Xcode.app/Contents/Developer
    • Build 16F6
    • CocoaPods version 1.16.2

[✗] Chrome - develop for the web (Cannot find Chrome executable at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome) [33ms]
    ! Cannot find Chrome. Try setting CHROME_EXECUTABLE to a Chrome executable.

[✓] Android Studio (version 2024.3) [32ms]
    • Android Studio at /Users/romanojw10/Applications/Android Studio.app/Contents
    • Flutter plugin can be installed from:
      🔨 https://plugins.jetbrains.com/plugin/9212-flutter
    • Dart plugin can be installed from:
      🔨 https://plugins.jetbrains.com/plugin/6351-dart
    • Java version OpenJDK Runtime Environment (build 21.0.5+-13047016-b750.29)

[✓] IntelliJ IDEA Ultimate Edition (version 2024.3.5) [30ms]
    • IntelliJ at /Users/romanojw10/Applications/IntelliJ IDEA Ultimate.app
    • Flutter plugin can be installed from:
      🔨 https://plugins.jetbrains.com/plugin/9212-flutter
    • Dart plugin can be installed from:
      🔨 https://plugins.jetbrains.com/plugin/6351-dart

[✓] VS Code (version 1.101.2) [11ms]
    • VS Code at /Applications/Visual Studio Code.app/Contents
    • Flutter extension version 3.114.0

[✓] Connected device (2 available) [6,2s]
    • sdk google atv64 arm64 (mobile)            • emulator-5554             • android-arm64 • Android 14 (API 34) (emulator)
    • macOS (desktop)                            • macos                     • darwin-arm64  • macOS 15.5 24F74 darwin-arm64

[✓] Network resources [258ms]
    • All expected network resources are available.

! Doctor found issues in 1 category.

Metadata

Metadata

Assignees

No one assigned

    Labels

    r: duplicateIssue is closed as a duplicate of an existing issue

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions