-
Notifications
You must be signed in to change notification settings - Fork 29.5k
Fix directional focus in nested scrollables with different axis #172875
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d421f60
d2cf298
c5bfae5
fe86f7b
378d82d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ | |
|
|
||
| import 'dart:ui'; | ||
|
|
||
| import 'package:collection/collection.dart'; | ||
| import 'package:flutter/foundation.dart'; | ||
| import 'package:flutter/material.dart'; | ||
| import 'package:flutter/services.dart'; | ||
|
|
@@ -2562,14 +2563,14 @@ void main() { | |
| expect(controller.offset, equals(0.0)); | ||
|
|
||
| // Go down until we hit the bottom of the visible area. | ||
| for (int i = 1; i <= 4; ++i) { | ||
| for (int i = 1; i <= 3; ++i) { | ||
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); | ||
| await tester.pump(); | ||
| expect(controller.offset, equals(0.0), reason: 'Focusing item $i caused a scroll'); | ||
| } | ||
|
|
||
| // Now keep going down, and the scrollable should scroll automatically. | ||
| for (int i = 5; i <= 10; ++i) { | ||
| for (int i = 4; i <= 10; ++i) { | ||
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); | ||
| await tester.pump(); | ||
| final double expectedOffset = 100.0 * (i - 5) + 200.0; | ||
|
|
@@ -2687,14 +2688,14 @@ void main() { | |
| expect(controller.offset, equals(0.0)); | ||
|
|
||
| // Go right until we hit the right of the visible area. | ||
| for (int i = 1; i <= 6; ++i) { | ||
| for (int i = 1; i <= 5; ++i) { | ||
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); | ||
| await tester.pump(); | ||
| expect(controller.offset, equals(0.0), reason: 'Focusing item $i caused a scroll'); | ||
| } | ||
|
|
||
| // Now keep going right, and the scrollable should scroll automatically. | ||
| for (int i = 7; i <= 10; ++i) { | ||
| for (int i = 6; i <= 10; ++i) { | ||
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); | ||
| await tester.pump(); | ||
| final double expectedOffset = 100.0 * (i - 5); | ||
|
|
@@ -2758,6 +2759,260 @@ void main() { | |
| variant: KeySimulatorTransitModeVariant.all(), | ||
| ); | ||
|
|
||
| testWidgets( | ||
| 'Focus traversal with horizontal scrollables inside a vertical scrollable handles vertical navigation correctly', | ||
| (WidgetTester tester) async { | ||
| // Tester view size is 800x600. | ||
|
|
||
| const double cellHeight = 100; | ||
|
|
||
| const int rowCount = 10; | ||
| const int buttonsPerRow = 5; | ||
|
|
||
| // Create focus nodes for all elements. | ||
| final FocusNode stickyButtonNode = FocusNode(debugLabel: 'Sticky Button'); | ||
| addTearDown(stickyButtonNode.dispose); | ||
|
|
||
| final List<List<FocusNode>> gridNodes = List<List<FocusNode>>.generate( | ||
| rowCount, | ||
| (int row) => List<FocusNode>.generate( | ||
| buttonsPerRow, | ||
| (int col) => FocusNode(debugLabel: 'Button $row-$col'), | ||
| ), | ||
| ); | ||
| addTearDown(() { | ||
| for (final FocusNode node in gridNodes.flattened) { | ||
| node.dispose(); | ||
| } | ||
| }); | ||
|
|
||
| final ScrollController verticalController = ScrollController(); | ||
| addTearDown(verticalController.dispose); | ||
|
|
||
| final List<ScrollController> horizontalControllers = List<ScrollController>.generate( | ||
| rowCount, | ||
| (int index) => ScrollController(debugLabel: 'Horizontal Controller $index'), | ||
| ); | ||
| addTearDown(() { | ||
| for (final ScrollController controller in horizontalControllers) { | ||
| controller.dispose(); | ||
| } | ||
| }); | ||
|
|
||
| await tester.pumpWidget( | ||
| MaterialApp( | ||
| home: Column( | ||
| children: <Widget>[ | ||
| Focus( | ||
| focusNode: stickyButtonNode, | ||
| child: Container(height: cellHeight, color: Colors.blue), | ||
| ), | ||
| Expanded( | ||
| child: ListView.separated( | ||
| controller: verticalController, | ||
| itemCount: rowCount, | ||
| separatorBuilder: (_, _) => const SizedBox(height: 32), | ||
| itemBuilder: (BuildContext context, int rowIndex) { | ||
| return SizedBox( | ||
| height: cellHeight, | ||
| child: ListView.builder( | ||
| controller: horizontalControllers[rowIndex], | ||
| scrollDirection: Axis.horizontal, | ||
| itemCount: buttonsPerRow, | ||
| itemBuilder: (BuildContext context, int colIndex) { | ||
| return Focus( | ||
| focusNode: gridNodes[rowIndex][colIndex], | ||
| child: Container( | ||
| width: cellHeight, | ||
| height: cellHeight, | ||
| color: Colors.primaries[rowIndex % Colors.primaries.length], | ||
| ), | ||
| ); | ||
| }, | ||
| ), | ||
| ); | ||
| }, | ||
| ), | ||
| ), | ||
| ], | ||
| ), | ||
| ), | ||
| ); | ||
|
|
||
| // Start by focusing the sticky button. | ||
| stickyButtonNode.requestFocus(); | ||
| await tester.pump(); | ||
| expect(stickyButtonNode.hasPrimaryFocus, isTrue); | ||
|
|
||
| // Navigate down to the first row - should focus one of the widgets in the first row. | ||
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); | ||
| await tester.pump(); | ||
|
|
||
| // Find which column in the first row got focused. | ||
| int focusedColumn = -1; | ||
| for (int col = 0; col < buttonsPerRow; col++) { | ||
| if (gridNodes[0][col].hasPrimaryFocus) { | ||
| focusedColumn = col; | ||
| break; | ||
| } | ||
| } | ||
| expect(focusedColumn, greaterThanOrEqualTo(0)); // Ensure something in first row is focused. | ||
|
|
||
| // Navigate down through the rows. | ||
| for (int row = 1; row < rowCount; row++) { | ||
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); | ||
| await tester.pump(); | ||
| expect(gridNodes[row][focusedColumn].hasPrimaryFocus, isTrue); | ||
| // Verify vertical scroll happened from the 5th row onwards (500px). | ||
| if (row >= 5) { | ||
| expect(verticalController.offset, greaterThan(0)); | ||
| } | ||
| } | ||
|
|
||
| // Navigate back up - should go to previous rows, not sticky button. | ||
| for (int row = rowCount - 2; row >= 0; row--) { | ||
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); | ||
| await tester.pump(); | ||
| expect(gridNodes[row][focusedColumn].hasPrimaryFocus, isTrue); | ||
| } | ||
|
|
||
| // Only now should we reach the sticky button. | ||
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); | ||
| await tester.pump(); | ||
| expect(stickyButtonNode.hasPrimaryFocus, isTrue); | ||
| }, | ||
| // https://github.com/flutter/flutter/issues/35347 | ||
| skip: isBrowser, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do these skip on browser? The analyzer failure is due to these not being attributed with comments s to why they are being skipped. See other skips for an example.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I copy/pasted the test above which is a bit similar as a base for my test the one above is skipped on browser because of this #35347 Should I add the same comment ? How can I see If this issue break the new test ?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Web tests run in CI here in the PR, if they are affected they should fail without the skip. Otherwise, there has to be a comment there pointing to the reason why the test is skipped, like the others. :) |
||
| variant: KeySimulatorTransitModeVariant.all(), | ||
| ); | ||
|
|
||
| testWidgets( | ||
| 'Focus traversal with vertical scrollables inside a horizontal scrollable handles horizontal navigation correctly', | ||
| (WidgetTester tester) async { | ||
| // Tester view size is 800x600. | ||
|
|
||
| const double cellWidth = 100; | ||
|
|
||
| const int columnCount = 10; | ||
| const int buttonsPerColumn = 10; | ||
|
|
||
| // Create focus nodes for all elements. | ||
| final FocusNode stickyButtonNode = FocusNode(debugLabel: 'Sticky Button'); | ||
| addTearDown(stickyButtonNode.dispose); | ||
|
|
||
| final List<List<FocusNode>> gridNodes = List<List<FocusNode>>.generate( | ||
| columnCount, | ||
| (int column) => List<FocusNode>.generate( | ||
| buttonsPerColumn, | ||
| (int row) => FocusNode(debugLabel: 'Button $column-$row'), | ||
| ), | ||
| ); | ||
| addTearDown(() { | ||
| for (final FocusNode node in gridNodes.flattened) { | ||
| node.dispose(); | ||
| } | ||
| }); | ||
|
|
||
| final ScrollController horizontalController = ScrollController(); | ||
| addTearDown(horizontalController.dispose); | ||
|
|
||
| final List<ScrollController> verticalControllers = List<ScrollController>.generate( | ||
| columnCount, | ||
| (int index) => ScrollController(debugLabel: 'Vertical Controller $index'), | ||
| ); | ||
| addTearDown(() { | ||
| for (final ScrollController controller in verticalControllers) { | ||
| controller.dispose(); | ||
| } | ||
| }); | ||
|
|
||
| await tester.pumpWidget( | ||
| MaterialApp( | ||
| home: Row( | ||
| children: <Widget>[ | ||
| Focus( | ||
| focusNode: stickyButtonNode, | ||
| child: Container(width: cellWidth, color: Colors.blue), | ||
| ), | ||
| Expanded( | ||
| child: ListView.separated( | ||
| scrollDirection: Axis.horizontal, | ||
| controller: horizontalController, | ||
| itemCount: columnCount, | ||
| separatorBuilder: (_, _) => const SizedBox(width: 32), | ||
| itemBuilder: (BuildContext context, int columnIndex) { | ||
| return SizedBox( | ||
| width: cellWidth, | ||
| child: ListView.builder( | ||
| controller: verticalControllers[columnIndex], | ||
| itemCount: buttonsPerColumn, | ||
| itemBuilder: (BuildContext context, int rowIndex) { | ||
| return Focus( | ||
| focusNode: gridNodes[columnIndex][rowIndex], | ||
| child: Container( | ||
| width: cellWidth, | ||
| height: cellWidth, | ||
| color: Colors.red, | ||
| ), | ||
| ); | ||
| }, | ||
| ), | ||
| ); | ||
| }, | ||
| ), | ||
| ), | ||
| ], | ||
| ), | ||
| ), | ||
| ); | ||
|
|
||
| // Start by focusing the sticky button. | ||
| stickyButtonNode.requestFocus(); | ||
| await tester.pump(); | ||
| expect(stickyButtonNode.hasPrimaryFocus, isTrue); | ||
|
|
||
| // Navigate right to the first column - should focus one of the widgets in the first column. | ||
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); | ||
| await tester.pump(); | ||
|
|
||
| // Find which row in the first column got focused. | ||
| int focusedRow = -1; | ||
| for (int row = 0; row < buttonsPerColumn; row++) { | ||
| if (gridNodes[0][row].hasPrimaryFocus) { | ||
| focusedRow = row; | ||
| break; | ||
| } | ||
| } | ||
| expect(focusedRow, greaterThanOrEqualTo(0)); // Ensure something in first column is focused. | ||
|
|
||
| // Navigate right through the columns. | ||
| for (int column = 1; column < columnCount; column++) { | ||
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); | ||
| await tester.pump(); | ||
| expect(gridNodes[column][focusedRow].hasPrimaryFocus, isTrue); | ||
| // Verify horizontal scroll happened from the 7th column onwards (700px). | ||
| if (column >= 6) { | ||
| expect(horizontalController.offset, greaterThan(0)); | ||
| } | ||
| } | ||
|
|
||
| // Navigate back left - should go to previous columns, not sticky button. | ||
| for (int column = columnCount - 2; column >= 0; column--) { | ||
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); | ||
| await tester.pump(); | ||
| expect(gridNodes[column][focusedRow].hasPrimaryFocus, isTrue); | ||
| } | ||
|
|
||
| // Only now should we reach the sticky button. | ||
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); | ||
| await tester.pump(); | ||
| expect(stickyButtonNode.hasPrimaryFocus, isTrue); | ||
| }, | ||
| // https://github.com/flutter/flutter/issues/35347 | ||
| skip: isBrowser, | ||
| variant: KeySimulatorTransitModeVariant.all(), | ||
| ); | ||
|
|
||
| testWidgets( | ||
| 'Arrow focus traversal actions can be re-enabled for text fields.', | ||
| (WidgetTester tester) async { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you share why these tests change?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These tests were ok before the PR but they were actually testing a bad behavior. I shared an explaination and two video before/after in the PR description