Skip to content

Commit a0bfde9

Browse files
authored
Fix ensureVisible and default focus traversal for reversed scrollables (#128756)
Fixes flutter/flutter#128749 The ScrollPositionAlignmentPolicy does not account for AxisDirection, which meant default focus traversal of reversed scrollables did not work. The policy doesn't know about the axis direction, so this is corrected in the ScrollPosition where all the information is available before calling on the viewport for the new target scroll offset. This fixes that by flipping the policy (unless explicit) so that focus traversal works.
1 parent 79bdc6d commit a0bfde9

File tree

2 files changed

+279
-2
lines changed

2 files changed

+279
-2
lines changed

packages/flutter/lib/src/widgets/scroll_position.dart

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -757,6 +757,26 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics {
757757
context.setSemanticsActions(_semanticActions!);
758758
}
759759

760+
ScrollPositionAlignmentPolicy _maybeFlipAlignment(ScrollPositionAlignmentPolicy alignmentPolicy) {
761+
return switch (alignmentPolicy) {
762+
// Don't flip when explicit.
763+
ScrollPositionAlignmentPolicy.explicit => alignmentPolicy,
764+
ScrollPositionAlignmentPolicy.keepVisibleAtEnd => ScrollPositionAlignmentPolicy.keepVisibleAtStart,
765+
ScrollPositionAlignmentPolicy.keepVisibleAtStart => ScrollPositionAlignmentPolicy.keepVisibleAtEnd,
766+
};
767+
}
768+
769+
ScrollPositionAlignmentPolicy _applyAxisDirectionToAlignmentPolicy(ScrollPositionAlignmentPolicy alignmentPolicy) {
770+
return switch (axisDirection) {
771+
// Start and end alignments must account for axis direction.
772+
// When focus is requested for example, it knows the directionality of the
773+
// keyboard keys initiating traversal, but not the direction of the
774+
// Scrollable.
775+
AxisDirection.up || AxisDirection.left => _maybeFlipAlignment(alignmentPolicy),
776+
AxisDirection.down || AxisDirection.right => alignmentPolicy,
777+
};
778+
}
779+
760780
/// Animates the position such that the given object is as visible as possible
761781
/// by just scrolling this position.
762782
///
@@ -790,7 +810,7 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics {
790810
}
791811

792812
double target;
793-
switch (alignmentPolicy) {
813+
switch (_applyAxisDirectionToAlignmentPolicy(alignmentPolicy)) {
794814
case ScrollPositionAlignmentPolicy.explicit:
795815
target = clampDouble(viewport.getOffsetToReveal(object, alignment, rect: targetRect).offset, minScrollExtent, maxScrollExtent);
796816
case ScrollPositionAlignmentPolicy.keepVisibleAtEnd:

packages/flutter/test/widgets/ensure_visible_test.dart

Lines changed: 258 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import 'package:flutter/rendering.dart';
88
import 'package:flutter/widgets.dart';
99
import 'package:flutter_test/flutter_test.dart';
1010

11-
Finder findKey(int i) => find.byKey(ValueKey<int>(i));
11+
Finder findKey(int i) => find.byKey(ValueKey<int>(i), skipOffstage: false);
1212

1313
Widget buildSingleChildScrollView(Axis scrollDirection, { bool reverse = false }) {
1414
return Directionality(
@@ -147,6 +147,47 @@ void main() {
147147
await tester.pump();
148148
await tester.pump(const Duration(milliseconds: 1020));
149149
expect(tester.getBottomRight(findKey(3)).dy, equals(500.0));
150+
151+
// Regression test for https://github.com/flutter/flutter/issues/128749
152+
// Reset to zero position.
153+
tester.state<ScrollableState>(find.byType(Scrollable)).position.jumpTo(0.0);
154+
await tester.pump();
155+
// 4 is not currently visible as the SingleChildScrollView is contained
156+
// within a centered SizedBox.
157+
expect(tester.getBottomLeft(findKey(4)).dy, equals(100.0));
158+
expect(tester.getBottomLeft(findKey(6)).dy, equals(500.0));
159+
Scrollable.ensureVisible(
160+
findContext(6),
161+
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart,
162+
);
163+
await tester.pump();
164+
Scrollable.ensureVisible(
165+
findContext(5),
166+
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart,
167+
);
168+
await tester.pump();
169+
// 5 and 6 are already visible beyond the top edge, so no change.
170+
expect(tester.getBottomLeft(findKey(4)).dy, equals(100.0));
171+
expect(tester.getBottomLeft(findKey(6)).dy, equals(500.0));
172+
Scrollable.ensureVisible(
173+
findContext(4),
174+
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart,
175+
);
176+
await tester.pump();
177+
// Since it is reversed, 4 should have come into view at the top
178+
// edge of the scrollable, matching the alignment expectation.
179+
expect(tester.getBottomLeft(findKey(4)).dy, equals(300.0));
180+
expect(tester.getBottomLeft(findKey(6)).dy, equals(700.0));
181+
182+
// Bring 6 back into view at the trailing edge, checking the other
183+
// alignment.
184+
Scrollable.ensureVisible(
185+
findContext(6),
186+
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd,
187+
);
188+
await tester.pump();
189+
expect(tester.getBottomLeft(findKey(4)).dy, equals(100.0));
190+
expect(tester.getBottomLeft(findKey(6)).dy, equals(500.0));
150191
});
151192

152193
testWidgets('SingleChildScrollView ensureVisible Axis.horizontal reverse', (WidgetTester tester) async {
@@ -174,6 +215,52 @@ void main() {
174215
await tester.pump();
175216
await tester.pump(const Duration(milliseconds: 1020));
176217
expect(tester.getBottomRight(findKey(3)).dx, equals(700.0));
218+
219+
// Regression test for https://github.com/flutter/flutter/issues/128749
220+
// Reset to zero position.
221+
tester.state<ScrollableState>(find.byType(Scrollable)).position.jumpTo(0.0);
222+
await tester.pump();
223+
// 4 is not currently visible as the SingleChildScrollView is contained
224+
// within a centered SizedBox.
225+
expect(tester.getBottomLeft(findKey(3)).dx, equals(-100.0));
226+
expect(tester.getBottomLeft(findKey(6)).dx, equals(500.0));
227+
Scrollable.ensureVisible(
228+
findContext(6),
229+
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart,
230+
);
231+
await tester.pump();
232+
Scrollable.ensureVisible(
233+
findContext(5),
234+
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart,
235+
);
236+
await tester.pump();
237+
Scrollable.ensureVisible(
238+
findContext(4),
239+
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart,
240+
);
241+
await tester.pump();
242+
// 4, 5 and 6 are already visible beyond the left edge, so no change.
243+
expect(tester.getBottomLeft(findKey(3)).dx, equals(-100.0));
244+
expect(tester.getBottomLeft(findKey(6)).dx, equals(500.0));
245+
Scrollable.ensureVisible(
246+
findContext(3),
247+
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart,
248+
);
249+
await tester.pump();
250+
// Since it is reversed, 3 should have come into view at the leading
251+
// edge of the scrollable, matching the alignment expectation.
252+
expect(tester.getBottomLeft(findKey(3)).dx, equals(100.0));
253+
expect(tester.getBottomLeft(findKey(6)).dx, equals(700.0));
254+
255+
// Bring 6 back into view at the trailing edge, checking the other
256+
// alignment.
257+
Scrollable.ensureVisible(
258+
findContext(6),
259+
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd,
260+
);
261+
await tester.pump();
262+
expect(tester.getBottomLeft(findKey(3)).dx, equals(-100.0));
263+
expect(tester.getBottomLeft(findKey(6)).dx, equals(500.0));
177264
});
178265

179266
testWidgets('SingleChildScrollView ensureVisible rotated child', (WidgetTester tester) async {
@@ -407,6 +494,46 @@ void main() {
407494
await tester.pump();
408495
await tester.pump(const Duration(milliseconds: 1020));
409496
expect(tester.getBottomRight(findKey(3)).dy, equals(500.0));
497+
498+
// Regression test for https://github.com/flutter/flutter/issues/128749
499+
// Reset to zero position.
500+
await prepare(0.0);
501+
// 2 is not currently visible as the ListView is contained
502+
// within a centered SizedBox.
503+
expect(tester.getBottomLeft(findKey(2)).dy, equals(100.0));
504+
expect(tester.getBottomLeft(findKey(0)).dy, equals(500.0));
505+
Scrollable.ensureVisible(
506+
findContext(0),
507+
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart,
508+
);
509+
await tester.pump();
510+
Scrollable.ensureVisible(
511+
findContext(1),
512+
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart,
513+
);
514+
await tester.pump();
515+
// 0 and 1 are already visible beyond the top edge, so no change.
516+
expect(tester.getBottomLeft(findKey(2)).dy, equals(100.0));
517+
expect(tester.getBottomLeft(findKey(0)).dy, equals(500.0));
518+
Scrollable.ensureVisible(
519+
findContext(2),
520+
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart,
521+
);
522+
await tester.pump();
523+
// Since it is reversed, 2 should have come into view at the top
524+
// edge of the scrollable, matching the alignment expectation.
525+
expect(tester.getBottomLeft(findKey(2)).dy, equals(300.0));
526+
expect(tester.getBottomLeft(findKey(0)).dy, equals(700.0));
527+
528+
// Bring 0 back into view at the trailing edge, checking the other
529+
// alignment.
530+
Scrollable.ensureVisible(
531+
findContext(0),
532+
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd,
533+
);
534+
await tester.pump();
535+
expect(tester.getBottomLeft(findKey(2)).dy, equals(100.0));
536+
expect(tester.getBottomLeft(findKey(0)).dy, equals(500.0));
410537
});
411538

412539
testWidgets('ListView ensureVisible Axis.horizontal reverse', (WidgetTester tester) async {
@@ -443,6 +570,51 @@ void main() {
443570
await tester.pump();
444571
await tester.pump(const Duration(milliseconds: 1020));
445572
expect(tester.getBottomRight(findKey(3)).dx, equals(700.0));
573+
574+
// Regression test for https://github.com/flutter/flutter/issues/128749
575+
// Reset to zero position.
576+
await prepare(0.0);
577+
// 3 is not currently visible as the ListView is contained
578+
// within a centered SizedBox.
579+
expect(tester.getBottomLeft(findKey(3)).dx, equals(-100.0));
580+
expect(tester.getBottomLeft(findKey(0)).dx, equals(500.0));
581+
Scrollable.ensureVisible(
582+
findContext(0),
583+
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart,
584+
);
585+
await tester.pump();
586+
Scrollable.ensureVisible(
587+
findContext(1),
588+
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart,
589+
);
590+
await tester.pump();
591+
Scrollable.ensureVisible(
592+
findContext(2),
593+
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart,
594+
);
595+
await tester.pump();
596+
// 0, 1 and 2 are already visible beyond the left edge, so no change.
597+
expect(tester.getBottomLeft(findKey(3)).dx, equals(-100.0));
598+
expect(tester.getBottomLeft(findKey(0)).dx, equals(500.0));
599+
Scrollable.ensureVisible(
600+
findContext(3),
601+
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart,
602+
);
603+
await tester.pump();
604+
// Since it is reversed, 3 should have come into view at the leading
605+
// edge of the scrollable, matching the alignment expectation.
606+
expect(tester.getBottomLeft(findKey(3)).dx, equals(100.0));
607+
expect(tester.getBottomLeft(findKey(0)).dx, equals(700.0));
608+
609+
// Bring 0 back into view at the trailing edge, checking the other
610+
// alignment.
611+
Scrollable.ensureVisible(
612+
findContext(0),
613+
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd,
614+
);
615+
await tester.pump();
616+
expect(tester.getBottomLeft(findKey(3)).dx, equals(-100.0));
617+
expect(tester.getBottomLeft(findKey(0)).dx, equals(500.0));
446618
});
447619

448620
testWidgets('ListView ensureVisible negative child', (WidgetTester tester) async {
@@ -662,6 +834,46 @@ void main() {
662834
await tester.pump();
663835
await tester.pump(const Duration(milliseconds: 1020));
664836
expect(tester.getBottomRight(findKey(3)).dy, equals(500.0));
837+
838+
// Regression test for https://github.com/flutter/flutter/issues/128749
839+
// Reset to zero position.
840+
await prepare(0.0);
841+
// 2 is not currently visible as the ListView is contained
842+
// within a centered SizedBox.
843+
expect(tester.getBottomLeft(findKey(2)).dy, equals(100.0));
844+
expect(tester.getBottomLeft(findKey(0)).dy, equals(500.0));
845+
Scrollable.ensureVisible(
846+
findContext(0),
847+
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart,
848+
);
849+
await tester.pump();
850+
Scrollable.ensureVisible(
851+
findContext(1),
852+
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart,
853+
);
854+
await tester.pump();
855+
// 0 and 1 are already visible beyond the top edge, so no change.
856+
expect(tester.getBottomLeft(findKey(2)).dy, equals(100.0));
857+
expect(tester.getBottomLeft(findKey(0)).dy, equals(500.0));
858+
Scrollable.ensureVisible(
859+
findContext(2),
860+
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart,
861+
);
862+
await tester.pump();
863+
// Since it is reversed, 2 should have come into view at the top
864+
// edge of the scrollable, matching the alignment expectation.
865+
expect(tester.getBottomLeft(findKey(2)).dy, equals(300.0));
866+
expect(tester.getBottomLeft(findKey(0)).dy, equals(700.0));
867+
868+
// Bring 0 back into view at the trailing edge, checking the other
869+
// alignment.
870+
Scrollable.ensureVisible(
871+
findContext(0),
872+
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd,
873+
);
874+
await tester.pump();
875+
expect(tester.getBottomLeft(findKey(2)).dy, equals(100.0));
876+
expect(tester.getBottomLeft(findKey(0)).dy, equals(500.0));
665877
});
666878

667879
testWidgets('ListView ensureVisible Axis.horizontal reverse', (WidgetTester tester) async {
@@ -698,6 +910,51 @@ void main() {
698910
await tester.pump();
699911
await tester.pump(const Duration(milliseconds: 1020));
700912
expect(tester.getBottomRight(findKey(3)).dx, equals(700.0));
913+
914+
// Regression test for https://github.com/flutter/flutter/issues/128749
915+
// Reset to zero position.
916+
await prepare(0.0);
917+
// 3 is not currently visible as the ListView is contained
918+
// within a centered SizedBox.
919+
expect(tester.getBottomLeft(findKey(3)).dx, equals(-100.0));
920+
expect(tester.getBottomLeft(findKey(0)).dx, equals(500.0));
921+
Scrollable.ensureVisible(
922+
findContext(0),
923+
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart,
924+
);
925+
await tester.pump();
926+
Scrollable.ensureVisible(
927+
findContext(1),
928+
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart,
929+
);
930+
await tester.pump();
931+
Scrollable.ensureVisible(
932+
findContext(2),
933+
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart,
934+
);
935+
await tester.pump();
936+
// 0, 1 and 2 are already visible beyond the left edge, so no change.
937+
expect(tester.getBottomLeft(findKey(3)).dx, equals(-100.0));
938+
expect(tester.getBottomLeft(findKey(0)).dx, equals(500.0));
939+
Scrollable.ensureVisible(
940+
findContext(3),
941+
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart,
942+
);
943+
await tester.pump();
944+
// Since it is reversed, 3 should have come into view at the leading
945+
// edge of the scrollable, matching the alignment expectation.
946+
expect(tester.getBottomLeft(findKey(3)).dx, equals(100.0));
947+
expect(tester.getBottomLeft(findKey(0)).dx, equals(700.0));
948+
949+
// Bring 0 back into view at the trailing edge, checking the other
950+
// alignment.
951+
Scrollable.ensureVisible(
952+
findContext(0),
953+
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd,
954+
);
955+
await tester.pump();
956+
expect(tester.getBottomLeft(findKey(3)).dx, equals(-100.0));
957+
expect(tester.getBottomLeft(findKey(0)).dx, equals(500.0));
701958
});
702959
});
703960

0 commit comments

Comments
 (0)