Unverified Commit 0d27fdce authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

Scroll scrollable to keep focused control visible. (#44965)

Before this change, it was possible to move the focus onto a control that was no longer in the view using focus traversal. This changes that, so that when a control is focused, it makes sure that if it is the child of a scrollable, that the scrollable attempts to keep it in view. If it is already in view, then nothing scrolls.

When asked to move in a direction, the focus traversal code tries to find a control to move to inside the scrollable first, and then looks for things outside of the scrollable only once the scrollable has reached its limit in that direction.
parent 7a0911b4
...@@ -97,7 +97,6 @@ abstract class Action extends Diagnosticable { ...@@ -97,7 +97,6 @@ abstract class Action extends Diagnosticable {
/// null `node`. If the information available from a focus node is /// null `node`. If the information available from a focus node is
/// needed in the action, use [ActionDispatcher.invokeFocusedAction] instead. /// needed in the action, use [ActionDispatcher.invokeFocusedAction] instead.
@protected @protected
@mustCallSuper
void invoke(FocusNode node, covariant Intent intent); void invoke(FocusNode node, covariant Intent intent);
@override @override
......
...@@ -12,6 +12,8 @@ import 'basic.dart'; ...@@ -12,6 +12,8 @@ import 'basic.dart';
import 'editable_text.dart'; import 'editable_text.dart';
import 'focus_manager.dart'; import 'focus_manager.dart';
import 'framework.dart'; import 'framework.dart';
import 'scroll_position.dart';
import 'scrollable.dart';
/// A direction along either the horizontal or vertical axes. /// A direction along either the horizontal or vertical axes.
/// ///
...@@ -146,6 +148,12 @@ abstract class FocusTraversalPolicy { ...@@ -146,6 +148,12 @@ abstract class FocusTraversalPolicy {
bool inDirection(FocusNode currentNode, TraversalDirection direction); bool inDirection(FocusNode currentNode, TraversalDirection direction);
} }
@protected
void _focusAndEnsureVisible(FocusNode node, {ScrollPositionAlignmentPolicy alignmentPolicy = ScrollPositionAlignmentPolicy.explicit}) {
node.requestFocus();
Scrollable.ensureVisible(node.context, alignment: 1.0, alignmentPolicy: alignmentPolicy);
}
/// A policy data object for use by the [DirectionalFocusTraversalPolicyMixin] /// A policy data object for use by the [DirectionalFocusTraversalPolicyMixin]
class _DirectionalPolicyDataEntry { class _DirectionalPolicyDataEntry {
const _DirectionalPolicyDataEntry({@required this.direction, @required this.node}) const _DirectionalPolicyDataEntry({@required this.direction, @required this.node})
...@@ -327,6 +335,32 @@ mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy { ...@@ -327,6 +335,32 @@ mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy {
invalidateScopeData(nearestScope); invalidateScopeData(nearestScope);
return false; return false;
} }
// Returns true if successfully popped the history.
bool popOrInvalidate(TraversalDirection direction) {
final FocusNode lastNode = policyData.history.removeLast().node;
if (Scrollable.of(lastNode.context) != Scrollable.of(primaryFocus.context)) {
invalidateScopeData(nearestScope);
return false;
}
ScrollPositionAlignmentPolicy alignmentPolicy;
switch(direction) {
case TraversalDirection.up:
case TraversalDirection.left:
alignmentPolicy = ScrollPositionAlignmentPolicy.keepVisibleAtStart;
break;
case TraversalDirection.right:
case TraversalDirection.down:
alignmentPolicy = ScrollPositionAlignmentPolicy.keepVisibleAtEnd;
break;
}
_focusAndEnsureVisible(
lastNode,
alignmentPolicy: alignmentPolicy,
);
return true;
}
switch (direction) { switch (direction) {
case TraversalDirection.down: case TraversalDirection.down:
case TraversalDirection.up: case TraversalDirection.up:
...@@ -338,17 +372,21 @@ mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy { ...@@ -338,17 +372,21 @@ mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy {
break; break;
case TraversalDirection.up: case TraversalDirection.up:
case TraversalDirection.down: case TraversalDirection.down:
policyData.history.removeLast().node.requestFocus(); if (popOrInvalidate(direction)) {
return true; return true;
} }
break; break;
}
break;
case TraversalDirection.left: case TraversalDirection.left:
case TraversalDirection.right: case TraversalDirection.right:
switch (policyData.history.first.direction) { switch (policyData.history.first.direction) {
case TraversalDirection.left: case TraversalDirection.left:
case TraversalDirection.right: case TraversalDirection.right:
policyData.history.removeLast().node.requestFocus(); if (popOrInvalidate(direction)) {
return true; return true;
}
break;
case TraversalDirection.up: case TraversalDirection.up:
case TraversalDirection.down: case TraversalDirection.down:
// Reset the policy data if we change directions. // Reset the policy data if we change directions.
...@@ -358,7 +396,6 @@ mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy { ...@@ -358,7 +396,6 @@ mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy {
} }
} }
if (policyData != null && policyData.history.isEmpty) { if (policyData != null && policyData.history.isEmpty) {
// Reset the policy data if we change directions.
invalidateScopeData(nearestScope); invalidateScopeData(nearestScope);
} }
return false; return false;
...@@ -400,22 +437,44 @@ mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy { ...@@ -400,22 +437,44 @@ mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy {
final FocusScopeNode nearestScope = currentNode.nearestScope; final FocusScopeNode nearestScope = currentNode.nearestScope;
final FocusNode focusedChild = nearestScope.focusedChild; final FocusNode focusedChild = nearestScope.focusedChild;
if (focusedChild == null) { if (focusedChild == null) {
final FocusNode firstFocus = findFirstFocusInDirection(currentNode, direction); final FocusNode firstFocus = findFirstFocusInDirection(currentNode, direction) ?? currentNode;
(firstFocus ?? currentNode).requestFocus(); switch (direction) {
case TraversalDirection.up:
case TraversalDirection.left:
_focusAndEnsureVisible(
firstFocus,
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart,
);
break;
case TraversalDirection.right:
case TraversalDirection.down:
_focusAndEnsureVisible(
firstFocus,
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd,
);
break;
}
return true; return true;
} }
if (_popPolicyDataIfNeeded(direction, nearestScope, focusedChild)) { if (_popPolicyDataIfNeeded(direction, nearestScope, focusedChild)) {
return true; return true;
} }
FocusNode found; FocusNode found;
final ScrollableState focusedScrollable = Scrollable.of(focusedChild.context);
switch (direction) { switch (direction) {
case TraversalDirection.down: case TraversalDirection.down:
case TraversalDirection.up: case TraversalDirection.up:
final Iterable<FocusNode> eligibleNodes = _sortAndFilterVertically( Iterable<FocusNode> eligibleNodes = _sortAndFilterVertically(
direction, direction,
focusedChild.rect, focusedChild.rect,
nearestScope.traversalDescendants, nearestScope.traversalDescendants,
); );
if (focusedScrollable != null && !focusedScrollable.position.atEdge) {
final Iterable<FocusNode> filteredEligibleNodes = eligibleNodes.where((FocusNode node) => Scrollable.of(node.context) == focusedScrollable);
if (filteredEligibleNodes.isNotEmpty) {
eligibleNodes = filteredEligibleNodes;
}
}
if (eligibleNodes.isEmpty) { if (eligibleNodes.isEmpty) {
break; break;
} }
...@@ -439,7 +498,13 @@ mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy { ...@@ -439,7 +498,13 @@ mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy {
break; break;
case TraversalDirection.right: case TraversalDirection.right:
case TraversalDirection.left: case TraversalDirection.left:
final Iterable<FocusNode> eligibleNodes = _sortAndFilterHorizontally(direction, focusedChild.rect, nearestScope); Iterable<FocusNode> eligibleNodes = _sortAndFilterHorizontally(direction, focusedChild.rect, nearestScope);
if (focusedScrollable != null && !focusedScrollable.position.atEdge) {
final Iterable<FocusNode> filteredEligibleNodes = eligibleNodes.where((FocusNode node) => Scrollable.of(node.context) == focusedScrollable);
if (filteredEligibleNodes.isNotEmpty) {
eligibleNodes = filteredEligibleNodes;
}
}
if (eligibleNodes.isEmpty) { if (eligibleNodes.isEmpty) {
break; break;
} }
...@@ -464,7 +529,22 @@ mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy { ...@@ -464,7 +529,22 @@ mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy {
} }
if (found != null) { if (found != null) {
_pushPolicyData(direction, nearestScope, focusedChild); _pushPolicyData(direction, nearestScope, focusedChild);
found.requestFocus(); switch (direction) {
case TraversalDirection.up:
case TraversalDirection.left:
_focusAndEnsureVisible(
found,
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart,
);
break;
case TraversalDirection.down:
case TraversalDirection.right:
_focusAndEnsureVisible(
found,
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd,
);
break;
}
return true; return true;
} }
return false; return false;
...@@ -525,7 +605,12 @@ class WidgetOrderFocusTraversalPolicy extends FocusTraversalPolicy with Directio ...@@ -525,7 +605,12 @@ class WidgetOrderFocusTraversalPolicy extends FocusTraversalPolicy with Directio
if (focusedChild == null) { if (focusedChild == null) {
final FocusNode firstFocus = findFirstFocus(currentNode); final FocusNode firstFocus = findFirstFocus(currentNode);
if (firstFocus != null) { if (firstFocus != null) {
firstFocus.requestFocus(); _focusAndEnsureVisible(
firstFocus,
alignmentPolicy: forward
? ScrollPositionAlignmentPolicy.keepVisibleAtEnd
: ScrollPositionAlignmentPolicy.keepVisibleAtStart,
);
return true; return true;
} }
} }
...@@ -540,12 +625,12 @@ class WidgetOrderFocusTraversalPolicy extends FocusTraversalPolicy with Directio ...@@ -540,12 +625,12 @@ class WidgetOrderFocusTraversalPolicy extends FocusTraversalPolicy with Directio
} }
if (forward) { if (forward) {
if (previousNode == focusedChild) { if (previousNode == focusedChild) {
visited.requestFocus(); _focusAndEnsureVisible(visited, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd);
return false; // short circuit the traversal. return false; // short circuit the traversal.
} }
} else { } else {
if (previousNode != null && visited == focusedChild) { if (previousNode != null && visited == focusedChild) {
previousNode.requestFocus(); _focusAndEnsureVisible(previousNode, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart);
return false; // short circuit the traversal. return false; // short circuit the traversal.
} }
} }
...@@ -558,12 +643,12 @@ class WidgetOrderFocusTraversalPolicy extends FocusTraversalPolicy with Directio ...@@ -558,12 +643,12 @@ class WidgetOrderFocusTraversalPolicy extends FocusTraversalPolicy with Directio
if (visit(nearestScope)) { if (visit(nearestScope)) {
if (forward) { if (forward) {
if (firstNode != null) { if (firstNode != null) {
firstNode.requestFocus(); _focusAndEnsureVisible(firstNode, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd);
return true; return true;
} }
} else { } else {
if (lastNode != null) { if (lastNode != null) {
lastNode.requestFocus(); _focusAndEnsureVisible(lastNode, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart);
return true; return true;
} }
} }
...@@ -694,17 +779,22 @@ class ReadingOrderTraversalPolicy extends FocusTraversalPolicy with DirectionalF ...@@ -694,17 +779,22 @@ class ReadingOrderTraversalPolicy extends FocusTraversalPolicy with DirectionalF
if (focusedChild == null) { if (focusedChild == null) {
final FocusNode firstFocus = findFirstFocus(currentNode); final FocusNode firstFocus = findFirstFocus(currentNode);
if (firstFocus != null) { if (firstFocus != null) {
firstFocus.requestFocus(); _focusAndEnsureVisible(
firstFocus,
alignmentPolicy: forward
? ScrollPositionAlignmentPolicy.keepVisibleAtEnd
: ScrollPositionAlignmentPolicy.keepVisibleAtStart,
);
return true; return true;
} }
} }
final List<FocusNode> sortedNodes = _sortByGeometry(nearestScope).toList(); final List<FocusNode> sortedNodes = _sortByGeometry(nearestScope).toList();
if (forward && focusedChild == sortedNodes.last) { if (forward && focusedChild == sortedNodes.last) {
sortedNodes.first.requestFocus(); _focusAndEnsureVisible(sortedNodes.first, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd);
return true; return true;
} }
if (!forward && focusedChild == sortedNodes.first) { if (!forward && focusedChild == sortedNodes.first) {
sortedNodes.last.requestFocus(); _focusAndEnsureVisible(sortedNodes.last, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart);
return true; return true;
} }
...@@ -712,7 +802,12 @@ class ReadingOrderTraversalPolicy extends FocusTraversalPolicy with DirectionalF ...@@ -712,7 +802,12 @@ class ReadingOrderTraversalPolicy extends FocusTraversalPolicy with DirectionalF
FocusNode previousNode; FocusNode previousNode;
for (FocusNode node in maybeFlipped) { for (FocusNode node in maybeFlipped) {
if (previousNode == focusedChild) { if (previousNode == focusedChild) {
node.requestFocus(); _focusAndEnsureVisible(
node,
alignmentPolicy: forward
? ScrollPositionAlignmentPolicy.keepVisibleAtEnd
: ScrollPositionAlignmentPolicy.keepVisibleAtStart,
);
return true; return true;
} }
previousNode = node; previousNode = node;
...@@ -846,10 +941,7 @@ class RequestFocusAction extends _RequestFocusActionBase { ...@@ -846,10 +941,7 @@ class RequestFocusAction extends _RequestFocusActionBase {
static const LocalKey key = ValueKey<Type>(RequestFocusAction); static const LocalKey key = ValueKey<Type>(RequestFocusAction);
@override @override
void invoke(FocusNode node, Intent intent) { void invoke(FocusNode node, Intent intent) => _focusAndEnsureVisible(node);
super.invoke(node, intent);
node.requestFocus();
}
} }
/// An [Action] that moves the focus to the next focusable node in the focus /// An [Action] that moves the focus to the next focusable node in the focus
...@@ -865,10 +957,7 @@ class NextFocusAction extends _RequestFocusActionBase { ...@@ -865,10 +957,7 @@ class NextFocusAction extends _RequestFocusActionBase {
static const LocalKey key = ValueKey<Type>(NextFocusAction); static const LocalKey key = ValueKey<Type>(NextFocusAction);
@override @override
void invoke(FocusNode node, Intent intent) { void invoke(FocusNode node, Intent intent) => node.nextFocus();
super.invoke(node, intent);
node.nextFocus();
}
} }
/// An [Action] that moves the focus to the previous focusable node in the focus /// An [Action] that moves the focus to the previous focusable node in the focus
...@@ -885,10 +974,7 @@ class PreviousFocusAction extends _RequestFocusActionBase { ...@@ -885,10 +974,7 @@ class PreviousFocusAction extends _RequestFocusActionBase {
static const LocalKey key = ValueKey<Type>(PreviousFocusAction); static const LocalKey key = ValueKey<Type>(PreviousFocusAction);
@override @override
void invoke(FocusNode node, Intent intent) { void invoke(FocusNode node, Intent intent) => node.previousFocus();
super.invoke(node, intent);
node.previousFocus();
}
} }
/// An [Intent] that represents moving to the next focusable node in the given /// An [Intent] that represents moving to the next focusable node in the given
...@@ -935,7 +1021,6 @@ class DirectionalFocusAction extends _RequestFocusActionBase { ...@@ -935,7 +1021,6 @@ class DirectionalFocusAction extends _RequestFocusActionBase {
@override @override
void invoke(FocusNode node, DirectionalFocusIntent intent) { void invoke(FocusNode node, DirectionalFocusIntent intent) {
super.invoke(node, intent);
if (!intent.ignoreTextFields || node.context.widget is! EditableText) { if (!intent.ignoreTextFields || node.context.widget is! EditableText) {
node.focusInDirection(intent.direction); node.focusInDirection(intent.direction);
} }
......
...@@ -22,6 +22,32 @@ import 'scroll_physics.dart'; ...@@ -22,6 +22,32 @@ import 'scroll_physics.dart';
export 'scroll_activity.dart' show ScrollHoldController; export 'scroll_activity.dart' show ScrollHoldController;
/// The policy to use when applying the `alignment` parameter of
/// [ScrollPosition.ensureVisible].
enum ScrollPositionAlignmentPolicy {
/// Use the `alignment` property of [ScrollPosition.ensureVisible] to decide
/// where to align the visible object.
explicit,
/// Find the bottom edge of the scroll container, and scroll the container, if
/// necessary, to show the bottom of the object.
///
/// For example, find the bottom edge of the scroll container. If the bottom
/// edge of the item is below the bottom edge of the scroll container, scroll
/// the item so that the bottom of the item is just visible. If the entire
/// item is already visible, then do nothing.
keepVisibleAtEnd,
/// Find the top edge of the scroll container, and scroll the container if
/// necessary to show the top of the object.
///
/// For example, find the top edge of the scroll container. If the top edge of
/// the item is above the top edge of the scroll container, scroll the item so
/// that the top of the item is just visible. If the entire item is already
/// visible, then do nothing.
keepVisibleAtStart,
}
/// Determines which portion of the content is visible in a scroll view. /// Determines which portion of the content is visible in a scroll view.
/// ///
/// The [pixels] value determines the scroll offset that the scroll view uses to /// The [pixels] value determines the scroll offset that the scroll view uses to
...@@ -497,17 +523,41 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics { ...@@ -497,17 +523,41 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics {
/// Animates the position such that the given object is as visible as possible /// Animates the position such that the given object is as visible as possible
/// by just scrolling this position. /// by just scrolling this position.
///
/// See also:
///
/// * [ScrollPositionAlignmentPolicy] for the way in which `alignment` is
/// applied, and the way the given `object` is aligned.
Future<void> ensureVisible( Future<void> ensureVisible(
RenderObject object, { RenderObject object, {
double alignment = 0.0, double alignment = 0.0,
Duration duration = Duration.zero, Duration duration = Duration.zero,
Curve curve = Curves.ease, Curve curve = Curves.ease,
ScrollPositionAlignmentPolicy alignmentPolicy = ScrollPositionAlignmentPolicy.explicit,
}) { }) {
assert(alignmentPolicy != null);
assert(object.attached); assert(object.attached);
final RenderAbstractViewport viewport = RenderAbstractViewport.of(object); final RenderAbstractViewport viewport = RenderAbstractViewport.of(object);
assert(viewport != null); assert(viewport != null);
final double target = viewport.getOffsetToReveal(object, alignment).offset.clamp(minScrollExtent, maxScrollExtent); double target;
switch (alignmentPolicy) {
case ScrollPositionAlignmentPolicy.explicit:
target = viewport.getOffsetToReveal(object, alignment).offset.clamp(minScrollExtent, maxScrollExtent);
break;
case ScrollPositionAlignmentPolicy.keepVisibleAtEnd:
target = viewport.getOffsetToReveal(object, 1.0).offset.clamp(minScrollExtent, maxScrollExtent);
if (target < pixels) {
target = pixels;
}
break;
case ScrollPositionAlignmentPolicy.keepVisibleAtStart:
target = viewport.getOffsetToReveal(object, 0.0).offset.clamp(minScrollExtent, maxScrollExtent);
if (target > pixels) {
target = pixels;
}
break;
}
if (target == pixels) if (target == pixels)
return Future<void>.value(); return Future<void>.value();
......
...@@ -240,6 +240,7 @@ class Scrollable extends StatefulWidget { ...@@ -240,6 +240,7 @@ class Scrollable extends StatefulWidget {
double alignment = 0.0, double alignment = 0.0,
Duration duration = Duration.zero, Duration duration = Duration.zero,
Curve curve = Curves.ease, Curve curve = Curves.ease,
ScrollPositionAlignmentPolicy alignmentPolicy = ScrollPositionAlignmentPolicy.explicit,
}) { }) {
final List<Future<void>> futures = <Future<void>>[]; final List<Future<void>> futures = <Future<void>>[];
...@@ -250,6 +251,7 @@ class Scrollable extends StatefulWidget { ...@@ -250,6 +251,7 @@ class Scrollable extends StatefulWidget {
alignment: alignment, alignment: alignment,
duration: duration, duration: duration,
curve: curve, curve: curve,
alignmentPolicy: alignmentPolicy,
)); ));
context = scrollable.context; context = scrollable.context;
scrollable = Scrollable.of(context); scrollable = Scrollable.of(context);
......
...@@ -50,6 +50,7 @@ void main() { ...@@ -50,6 +50,7 @@ void main() {
expect(secondFocusNode.hasFocus, isFalse); expect(secondFocusNode.hasFocus, isFalse);
expect(scope.hasFocus, isTrue); expect(scope.hasFocus, isTrue);
}); });
testWidgets('Move focus to next node.', (WidgetTester tester) async { testWidgets('Move focus to next node.', (WidgetTester tester) async {
final GlobalKey key1 = GlobalKey(debugLabel: '1'); final GlobalKey key1 = GlobalKey(debugLabel: '1');
final GlobalKey key2 = GlobalKey(debugLabel: '2'); final GlobalKey key2 = GlobalKey(debugLabel: '2');
...@@ -166,6 +167,7 @@ void main() { ...@@ -166,6 +167,7 @@ void main() {
expect(secondFocusNode.hasFocus, isTrue); expect(secondFocusNode.hasFocus, isTrue);
expect(scope.hasFocus, isTrue); expect(scope.hasFocus, isTrue);
}); });
testWidgets('Move focus to previous node.', (WidgetTester tester) async { testWidgets('Move focus to previous node.', (WidgetTester tester) async {
final GlobalKey key1 = GlobalKey(debugLabel: '1'); final GlobalKey key1 = GlobalKey(debugLabel: '1');
final GlobalKey key2 = GlobalKey(debugLabel: '2'); final GlobalKey key2 = GlobalKey(debugLabel: '2');
...@@ -239,6 +241,7 @@ void main() { ...@@ -239,6 +241,7 @@ void main() {
expect(secondFocusNode.hasFocus, isFalse); expect(secondFocusNode.hasFocus, isFalse);
expect(scope.hasFocus, isTrue); expect(scope.hasFocus, isTrue);
}); });
testWidgets('Find the initial focus when a route is pushed or popped.', (WidgetTester tester) async { testWidgets('Find the initial focus when a route is pushed or popped.', (WidgetTester tester) async {
final GlobalKey key1 = GlobalKey(debugLabel: '1'); final GlobalKey key1 = GlobalKey(debugLabel: '1');
final GlobalKey key2 = GlobalKey(debugLabel: '2'); final GlobalKey key2 = GlobalKey(debugLabel: '2');
...@@ -305,6 +308,7 @@ void main() { ...@@ -305,6 +308,7 @@ void main() {
expect(scope.hasFocus, isTrue); expect(scope.hasFocus, isTrue);
}); });
}); });
group(ReadingOrderTraversalPolicy, () { group(ReadingOrderTraversalPolicy, () {
testWidgets('Find the initial focus if there is none yet.', (WidgetTester tester) async { testWidgets('Find the initial focus if there is none yet.', (WidgetTester tester) async {
final GlobalKey key1 = GlobalKey(debugLabel: '1'); final GlobalKey key1 = GlobalKey(debugLabel: '1');
...@@ -344,6 +348,7 @@ void main() { ...@@ -344,6 +348,7 @@ void main() {
expect(secondFocusNode.hasFocus, isFalse); expect(secondFocusNode.hasFocus, isFalse);
expect(scope.hasFocus, isTrue); expect(scope.hasFocus, isTrue);
}); });
testWidgets('Move reading focus to next node.', (WidgetTester tester) async { testWidgets('Move reading focus to next node.', (WidgetTester tester) async {
final GlobalKey key1 = GlobalKey(debugLabel: '1'); final GlobalKey key1 = GlobalKey(debugLabel: '1');
final GlobalKey key2 = GlobalKey(debugLabel: '2'); final GlobalKey key2 = GlobalKey(debugLabel: '2');
...@@ -458,6 +463,7 @@ void main() { ...@@ -458,6 +463,7 @@ void main() {
expect(secondFocusNode.hasFocus, isTrue); expect(secondFocusNode.hasFocus, isTrue);
expect(scope.hasFocus, isTrue); expect(scope.hasFocus, isTrue);
}); });
testWidgets('Move reading focus to previous node.', (WidgetTester tester) async { testWidgets('Move reading focus to previous node.', (WidgetTester tester) async {
final GlobalKey key1 = GlobalKey(debugLabel: '1'); final GlobalKey key1 = GlobalKey(debugLabel: '1');
final GlobalKey key2 = GlobalKey(debugLabel: '2'); final GlobalKey key2 = GlobalKey(debugLabel: '2');
...@@ -532,6 +538,7 @@ void main() { ...@@ -532,6 +538,7 @@ void main() {
expect(scope.hasFocus, isTrue); expect(scope.hasFocus, isTrue);
}); });
}); });
group(DirectionalFocusTraversalPolicyMixin, () { group(DirectionalFocusTraversalPolicyMixin, () {
testWidgets('Move focus in all directions.', (WidgetTester tester) async { testWidgets('Move focus in all directions.', (WidgetTester tester) async {
final GlobalKey upperLeftKey = GlobalKey(debugLabel: 'upperLeftKey'); final GlobalKey upperLeftKey = GlobalKey(debugLabel: 'upperLeftKey');
...@@ -672,6 +679,7 @@ void main() { ...@@ -672,6 +679,7 @@ void main() {
expect(lowerRightNode.hasFocus, isFalse); expect(lowerRightNode.hasFocus, isFalse);
expect(scope.hasFocus, isTrue); expect(scope.hasFocus, isTrue);
}); });
testWidgets('Directional focus avoids hysterisis.', (WidgetTester tester) async { testWidgets('Directional focus avoids hysterisis.', (WidgetTester tester) async {
final List<GlobalKey> keys = <GlobalKey>[ final List<GlobalKey> keys = <GlobalKey>[
GlobalKey(debugLabel: 'row 1:1'), GlobalKey(debugLabel: 'row 1:1'),
...@@ -809,6 +817,7 @@ void main() { ...@@ -809,6 +817,7 @@ void main() {
expectState(<bool>[null, false, null, true, null, null]); expectState(<bool>[null, false, null, true, null, null]);
clear(); clear();
}); });
testWidgets('Can find first focus in all directions.', (WidgetTester tester) async { testWidgets('Can find first focus in all directions.', (WidgetTester tester) async {
final GlobalKey upperLeftKey = GlobalKey(debugLabel: 'upperLeftKey'); final GlobalKey upperLeftKey = GlobalKey(debugLabel: 'upperLeftKey');
final GlobalKey upperRightKey = GlobalKey(debugLabel: 'upperRightKey'); final GlobalKey upperRightKey = GlobalKey(debugLabel: 'upperRightKey');
...@@ -868,6 +877,7 @@ void main() { ...@@ -868,6 +877,7 @@ void main() {
expect(policy.findFirstFocusInDirection(scope, TraversalDirection.left), equals(upperRightNode)); expect(policy.findFirstFocusInDirection(scope, TraversalDirection.left), equals(upperRightNode));
expect(policy.findFirstFocusInDirection(scope, TraversalDirection.right), equals(upperLeftNode)); expect(policy.findFirstFocusInDirection(scope, TraversalDirection.right), equals(upperLeftNode));
}); });
testWidgets('Can find focus when policy data dirty', (WidgetTester tester) async { testWidgets('Can find focus when policy data dirty', (WidgetTester tester) async {
final FocusNode focusTop = FocusNode(debugLabel: 'top'); final FocusNode focusTop = FocusNode(debugLabel: 'top');
final FocusNode focusCenter = FocusNode(debugLabel: 'center'); final FocusNode focusCenter = FocusNode(debugLabel: 'center');
...@@ -918,6 +928,7 @@ void main() { ...@@ -918,6 +928,7 @@ void main() {
expect(focusCenter.hasFocus, isFalse); expect(focusCenter.hasFocus, isFalse);
expect(focusTop.hasFocus, isTrue); expect(focusTop.hasFocus, isTrue);
}); });
testWidgets('Focus traversal actions are invoked when shortcuts are used.', (WidgetTester tester) async { testWidgets('Focus traversal actions are invoked when shortcuts are used.', (WidgetTester tester) async {
final GlobalKey upperLeftKey = GlobalKey(debugLabel: 'upperLeftKey'); final GlobalKey upperLeftKey = GlobalKey(debugLabel: 'upperLeftKey');
final GlobalKey upperRightKey = GlobalKey(debugLabel: 'upperRightKey'); final GlobalKey upperRightKey = GlobalKey(debugLabel: 'upperRightKey');
...@@ -1006,6 +1017,203 @@ void main() { ...@@ -1006,6 +1017,203 @@ void main() {
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
expect(Focus.of(upperLeftKey.currentContext).hasPrimaryFocus, isTrue); expect(Focus.of(upperLeftKey.currentContext).hasPrimaryFocus, isTrue);
}, skip: kIsWeb); }, skip: kIsWeb);
testWidgets('Focus traversal inside a vertical scrollable scrolls to stay visible.', (WidgetTester tester) async {
final List<int> items = List<int>.generate(11, (int index) => index).toList();
final List<FocusNode> nodes = List<FocusNode>.generate(11, (int index) => FocusNode(debugLabel: 'Item ${index + 1}')).toList();
final FocusNode topNode = FocusNode(debugLabel: 'Header');
final FocusNode bottomNode = FocusNode(debugLabel: 'Footer');
final ScrollController controller = ScrollController();
await tester.pumpWidget(
MaterialApp(
home: Column(
children: <Widget>[
Focus(focusNode: topNode, child: Container(height: 100)),
Expanded(
child: ListView(
scrollDirection: Axis.vertical,
controller: controller,
children: items.map<Widget>((int item) {
return Focus(
focusNode: nodes[item],
child: Container(height: 100),
);
}).toList(),
),
),
Focus(focusNode: bottomNode, child: Container(height: 100)),
],
),
),
);
// Start at the top
expect(controller.offset, equals(0.0));
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(topNode.hasPrimaryFocus, isTrue);
expect(controller.offset, equals(0.0));
// Enter the list.
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(nodes[0].hasPrimaryFocus, isTrue);
expect(controller.offset, equals(0.0));
// Go down until we hit the bottom of the visible area.
for (int i = 1; i <= 4; ++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) {
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
final double expectedOffset = 100.0 * (i - 5) + 200.0;
expect(controller.offset, equals(expectedOffset), reason: "Focusing item $i didn't cause a scroll to $expectedOffset");
}
// Now go one more, and see that the footer gets focused.
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(bottomNode.hasPrimaryFocus, isTrue);
expect(controller.offset, equals(100.0 * (10 - 5) + 200.0));
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pump();
expect(nodes[10].hasPrimaryFocus, isTrue);
expect(controller.offset, equals(100.0 * (10 - 5) + 200.0));
// Now reverse directions and go back to the top.
// These should not cause a scroll.
final double lowestOffset = controller.offset;
for (int i = 10; i >= 8; --i) {
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pump();
expect(controller.offset, equals(lowestOffset), reason: 'Focusing item $i caused a scroll');
}
// These should all cause a scroll.
for (int i = 7; i >= 1; --i) {
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pump();
final double expectedOffset = 100.0 * (i - 1);
expect(controller.offset, equals(expectedOffset), reason: "Focusing item $i didn't cause a scroll");
}
// Back at the top.
expect(nodes[0].hasPrimaryFocus, isTrue);
expect(controller.offset, equals(0.0));
// Now we jump to the header.
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pump();
expect(topNode.hasPrimaryFocus, isTrue);
expect(controller.offset, equals(0.0));
}, skip: kIsWeb);
testWidgets('Focus traversal inside a horizontal scrollable scrolls to stay visible.', (WidgetTester tester) async {
final List<int> items = List<int>.generate(11, (int index) => index).toList();
final List<FocusNode> nodes = List<FocusNode>.generate(11, (int index) => FocusNode(debugLabel: 'Item ${index + 1}')).toList();
final FocusNode leftNode = FocusNode(debugLabel: 'Left Side');
final FocusNode rightNode = FocusNode(debugLabel: 'Right Side');
final ScrollController controller = ScrollController();
await tester.pumpWidget(
MaterialApp(
home: Row(
children: <Widget>[
Focus(focusNode: leftNode, child: Container(width: 100)),
Expanded(
child: ListView(
scrollDirection: Axis.horizontal,
controller: controller,
children: items.map<Widget>((int item) {
return Focus(
focusNode: nodes[item],
child: Container(width: 100),
);
}).toList(),
),
),
Focus(focusNode: rightNode, child: Container(width: 100)),
],
),
),
);
// Start at the right
expect(controller.offset, equals(0.0));
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
expect(leftNode.hasPrimaryFocus, isTrue);
expect(controller.offset, equals(0.0));
// Enter the list.
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
expect(nodes[0].hasPrimaryFocus, isTrue);
expect(controller.offset, equals(0.0));
// Go right until we hit the right of the visible area.
for (int i = 1; i <= 6; ++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) {
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
final double expectedOffset = 100.0 * (i - 5);
expect(controller.offset, equals(expectedOffset), reason: "Focusing item $i didn't cause a scroll to $expectedOffset");
}
// Now go one more, and see that the right edge gets focused.
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
expect(rightNode.hasPrimaryFocus, isTrue);
expect(controller.offset, equals(100.0 * 5));
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
await tester.pump();
expect(nodes[10].hasPrimaryFocus, isTrue);
expect(controller.offset, equals(100.0 * 5));
// Now reverse directions and go back to the left.
// These should not cause a scroll.
final double lowestOffset = controller.offset;
for (int i = 10; i >= 7; --i) {
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
await tester.pump();
expect(controller.offset, equals(lowestOffset), reason: 'Focusing item $i caused a scroll');
}
// These should all cause a scroll.
for (int i = 6; i >= 1; --i) {
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
await tester.pump();
final double expectedOffset = 100.0 * (i - 1);
expect(controller.offset, equals(expectedOffset), reason: "Focusing item $i didn't cause a scroll");
}
// Back at the left side of the scrollable.
expect(nodes[0].hasPrimaryFocus, isTrue);
expect(controller.offset, equals(0.0));
// Now we jump to the left edge of the app.
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
await tester.pump();
expect(leftNode.hasPrimaryFocus, isTrue);
expect(controller.offset, equals(0.0));
}, skip: kIsWeb);
testWidgets('Arrow focus traversal actions can be re-enabled for text fields.', (WidgetTester tester) async { testWidgets('Arrow focus traversal actions can be re-enabled for text fields.', (WidgetTester tester) async {
final GlobalKey upperLeftKey = GlobalKey(debugLabel: 'upperLeftKey'); final GlobalKey upperLeftKey = GlobalKey(debugLabel: 'upperLeftKey');
final GlobalKey upperRightKey = GlobalKey(debugLabel: 'upperRightKey'); final GlobalKey upperRightKey = GlobalKey(debugLabel: 'upperRightKey');
...@@ -1128,13 +1336,36 @@ void main() { ...@@ -1128,13 +1336,36 @@ void main() {
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
expect(focusNodeUpperLeft.hasPrimaryFocus, isTrue); expect(focusNodeUpperLeft.hasPrimaryFocus, isTrue);
}); });
testWidgets('Focus traversal does not break when no focusable is available on a MaterialApp', (WidgetTester tester) async { testWidgets('Focus traversal does not break when no focusable is available on a MaterialApp', (WidgetTester tester) async {
final List<RawKeyEvent> events = <RawKeyEvent>[]; final List<RawKeyEvent> events = <RawKeyEvent>[];
await tester.pumpWidget(MaterialApp(home: Container()));
RawKeyboard.instance.addListener((RawKeyEvent event) {
events.add(event);
});
await tester.idle();
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.idle();
expect(events.length, 2);
});
testWidgets('Focus traversal does not break when no focusable is available on a WidgetsApp', (WidgetTester tester) async {
final List<RawKeyEvent> events = <RawKeyEvent>[];
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( WidgetsApp(
home: Container() color: Colors.white,
) onGenerateRoute: (RouteSettings settings) => PageRouteBuilder<void>(
settings: settings,
pageBuilder: (BuildContext context, Animation<double> animation1, Animation<double> animation2) {
return const Placeholder();
},
),
),
); );
RawKeyboard.instance.addListener((RawKeyEvent event) { RawKeyboard.instance.addListener((RawKeyEvent event) {
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:async'; import 'dart:async';
import 'dart:ui';
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
...@@ -13,7 +14,7 @@ ScrollController _controller = ScrollController( ...@@ -13,7 +14,7 @@ ScrollController _controller = ScrollController(
); );
class ThePositiveNumbers extends StatelessWidget { class ThePositiveNumbers extends StatelessWidget {
const ThePositiveNumbers({ @required this.from }); const ThePositiveNumbers({@required this.from});
final int from; final int from;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
...@@ -57,17 +58,17 @@ Future<void> performTest(WidgetTester tester, bool maintainState) async { ...@@ -57,17 +58,17 @@ Future<void> performTest(WidgetTester tester, bool maintainState) async {
// we're 600 pixels high, each item is 100 pixels high, scroll position is // we're 600 pixels high, each item is 100 pixels high, scroll position is
// 110.0, so we should have 7 items, 1..7. // 110.0, so we should have 7 items, 1..7.
expect(find.text('0'), findsNothing); expect(find.text('0'), findsNothing, reason: 'with maintainState: $maintainState');
expect(find.text('1'), findsOneWidget); expect(find.text('1'), findsOneWidget, reason: 'with maintainState: $maintainState');
expect(find.text('2'), findsOneWidget); expect(find.text('2'), findsOneWidget, reason: 'with maintainState: $maintainState');
expect(find.text('3'), findsOneWidget); expect(find.text('3'), findsOneWidget, reason: 'with maintainState: $maintainState');
expect(find.text('4'), findsOneWidget); expect(find.text('4'), findsOneWidget, reason: 'with maintainState: $maintainState');
expect(find.text('5'), findsOneWidget); expect(find.text('5'), findsOneWidget, reason: 'with maintainState: $maintainState');
expect(find.text('6'), findsOneWidget); expect(find.text('6'), findsOneWidget, reason: 'with maintainState: $maintainState');
expect(find.text('7'), findsOneWidget); expect(find.text('7'), findsOneWidget, reason: 'with maintainState: $maintainState');
expect(find.text('8'), findsNothing); expect(find.text('8'), findsNothing, reason: 'with maintainState: $maintainState');
expect(find.text('10'), findsNothing); expect(find.text('10'), findsNothing, reason: 'with maintainState: $maintainState');
expect(find.text('100'), findsNothing); expect(find.text('100'), findsNothing, reason: 'with maintainState: $maintainState');
tester.state<ScrollableState>(find.byType(Scrollable)).position.jumpTo(1000.0); tester.state<ScrollableState>(find.byType(Scrollable)).position.jumpTo(1000.0);
await tester.pump(const Duration(seconds: 1)); await tester.pump(const Duration(seconds: 1));
...@@ -75,39 +76,39 @@ Future<void> performTest(WidgetTester tester, bool maintainState) async { ...@@ -75,39 +76,39 @@ Future<void> performTest(WidgetTester tester, bool maintainState) async {
// we're 600 pixels high, each item is 100 pixels high, scroll position is // we're 600 pixels high, each item is 100 pixels high, scroll position is
// 1000, so we should have exactly 6 items, 10..15. // 1000, so we should have exactly 6 items, 10..15.
expect(find.text('0'), findsNothing); expect(find.text('0'), findsNothing, reason: 'with maintainState: $maintainState');
expect(find.text('1'), findsNothing); expect(find.text('1'), findsNothing, reason: 'with maintainState: $maintainState');
expect(find.text('8'), findsNothing); expect(find.text('8'), findsNothing, reason: 'with maintainState: $maintainState');
expect(find.text('9'), findsNothing); expect(find.text('9'), findsNothing, reason: 'with maintainState: $maintainState');
expect(find.text('10'), findsOneWidget); expect(find.text('10'), findsOneWidget, reason: 'with maintainState: $maintainState');
expect(find.text('11'), findsOneWidget); expect(find.text('11'), findsOneWidget, reason: 'with maintainState: $maintainState');
expect(find.text('12'), findsOneWidget); expect(find.text('12'), findsOneWidget, reason: 'with maintainState: $maintainState');
expect(find.text('13'), findsOneWidget); expect(find.text('13'), findsOneWidget, reason: 'with maintainState: $maintainState');
expect(find.text('14'), findsOneWidget); expect(find.text('14'), findsOneWidget, reason: 'with maintainState: $maintainState');
expect(find.text('15'), findsOneWidget); expect(find.text('15'), findsOneWidget, reason: 'with maintainState: $maintainState');
expect(find.text('16'), findsNothing); expect(find.text('16'), findsNothing, reason: 'with maintainState: $maintainState');
expect(find.text('100'), findsNothing); expect(find.text('100'), findsNothing, reason: 'with maintainState: $maintainState');
navigatorKey.currentState.pushNamed('/second'); navigatorKey.currentState.pushNamed('/second');
await tester.pump(); // navigating always takes two frames, one to start... await tester.pump(); // navigating always takes two frames, one to start...
await tester.pump(const Duration(seconds: 1)); // ...and one to end the transition await tester.pump(const Duration(seconds: 1)); // ...and one to end the transition
// the second list is now visible, starting at 10001 // the second list is now visible, starting at 10001
expect(find.text('0'), findsNothing); expect(find.text('0'), findsNothing, reason: 'with maintainState: $maintainState');
expect(find.text('1'), findsNothing); expect(find.text('1'), findsNothing, reason: 'with maintainState: $maintainState');
expect(find.text('10'), findsNothing); expect(find.text('10'), findsNothing, reason: 'with maintainState: $maintainState');
expect(find.text('11'), findsNothing); expect(find.text('11'), findsNothing, reason: 'with maintainState: $maintainState');
expect(find.text('10000'), findsNothing); expect(find.text('10000'), findsNothing, reason: 'with maintainState: $maintainState');
expect(find.text('10001'), findsOneWidget); expect(find.text('10001'), findsOneWidget, reason: 'with maintainState: $maintainState');
expect(find.text('10002'), findsOneWidget); expect(find.text('10002'), findsOneWidget, reason: 'with maintainState: $maintainState');
expect(find.text('10003'), findsOneWidget); expect(find.text('10003'), findsOneWidget, reason: 'with maintainState: $maintainState');
expect(find.text('10004'), findsOneWidget); expect(find.text('10004'), findsOneWidget, reason: 'with maintainState: $maintainState');
expect(find.text('10005'), findsOneWidget); expect(find.text('10005'), findsOneWidget, reason: 'with maintainState: $maintainState');
expect(find.text('10006'), findsOneWidget); expect(find.text('10006'), findsOneWidget, reason: 'with maintainState: $maintainState');
expect(find.text('10007'), findsOneWidget); expect(find.text('10007'), findsOneWidget, reason: 'with maintainState: $maintainState');
expect(find.text('10008'), findsNothing); expect(find.text('10008'), findsNothing, reason: 'with maintainState: $maintainState');
expect(find.text('10010'), findsNothing); expect(find.text('10010'), findsNothing, reason: 'with maintainState: $maintainState');
expect(find.text('10100'), findsNothing); expect(find.text('10100'), findsNothing, reason: 'with maintainState: $maintainState');
navigatorKey.currentState.pop(); navigatorKey.currentState.pop();
await tester.pump(); // again, navigating always takes two frames await tester.pump(); // again, navigating always takes two frames
...@@ -115,25 +116,25 @@ Future<void> performTest(WidgetTester tester, bool maintainState) async { ...@@ -115,25 +116,25 @@ Future<void> performTest(WidgetTester tester, bool maintainState) async {
// Ensure we don't clamp the scroll offset even during the navigation. // Ensure we don't clamp the scroll offset even during the navigation.
// https://github.com/flutter/flutter/issues/4883 // https://github.com/flutter/flutter/issues/4883
final ScrollableState state = tester.state(find.byType(Scrollable).first); final ScrollableState state = tester.state(find.byType(Scrollable).first);
expect(state.position.pixels, equals(1000.0)); expect(state.position.pixels, equals(1000.0), reason: 'with maintainState: $maintainState');
await tester.pump(const Duration(seconds: 1)); await tester.pump(const Duration(seconds: 1));
// we're 600 pixels high, each item is 100 pixels high, scroll position is // we're 600 pixels high, each item is 100 pixels high, scroll position is
// 1000, so we should have exactly 6 items, 10..15. // 1000, so we should have exactly 6 items, 10..15.
expect(find.text('0'), findsNothing); expect(find.text('0'), findsNothing, reason: 'with maintainState: $maintainState');
expect(find.text('1'), findsNothing); expect(find.text('1'), findsNothing, reason: 'with maintainState: $maintainState');
expect(find.text('8'), findsNothing); expect(find.text('8'), findsNothing, reason: 'with maintainState: $maintainState');
expect(find.text('9'), findsNothing); expect(find.text('9'), findsNothing, reason: 'with maintainState: $maintainState');
expect(find.text('10'), findsOneWidget); expect(find.text('10'), findsOneWidget, reason: 'with maintainState: $maintainState');
expect(find.text('11'), findsOneWidget); expect(find.text('11'), findsOneWidget, reason: 'with maintainState: $maintainState');
expect(find.text('12'), findsOneWidget); expect(find.text('12'), findsOneWidget, reason: 'with maintainState: $maintainState');
expect(find.text('13'), findsOneWidget); expect(find.text('13'), findsOneWidget, reason: 'with maintainState: $maintainState');
expect(find.text('14'), findsOneWidget); expect(find.text('14'), findsOneWidget, reason: 'with maintainState: $maintainState');
expect(find.text('15'), findsOneWidget); expect(find.text('15'), findsOneWidget, reason: 'with maintainState: $maintainState');
expect(find.text('16'), findsNothing); expect(find.text('16'), findsNothing, reason: 'with maintainState: $maintainState');
expect(find.text('100'), findsNothing); expect(find.text('100'), findsNothing, reason: 'with maintainState: $maintainState');
} }
void main() { void main() {
...@@ -141,4 +142,67 @@ void main() { ...@@ -141,4 +142,67 @@ void main() {
await performTest(tester, true); await performTest(tester, true);
await performTest(tester, false); await performTest(tester, false);
}); });
testWidgets('scroll alignment is honored by ensureVisible', (WidgetTester tester) async {
final List<int> items = List<int>.generate(11, (int index) => index).toList();
final List<FocusNode> nodes = List<FocusNode>.generate(11, (int index) => FocusNode(debugLabel: 'Item ${index + 1}')).toList();
final ScrollController controller = ScrollController();
await tester.pumpWidget(
MaterialApp(
home: ListView(
scrollDirection: Axis.vertical,
controller: controller,
children: items.map<Widget>((int item) {
return Focus(
key: ValueKey<int>(item),
focusNode: nodes[item],
child: Container(height: 110),
);
}).toList(),
),
),
);
controller.position.ensureVisible(
tester.renderObject(find.byKey(const ValueKey<int>(0))),
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart,
);
expect(controller.position.pixels, equals(0.0));
controller.position.ensureVisible(
tester.renderObject(find.byKey(const ValueKey<int>(1))),
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart,
);
expect(controller.position.pixels, equals(0.0));
controller.position.ensureVisible(
tester.renderObject(find.byKey(const ValueKey<int>(1))),
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd,
);
expect(controller.position.pixels, equals(0.0));
controller.position.ensureVisible(
tester.renderObject(find.byKey(const ValueKey<int>(4))),
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart,
);
expect(controller.position.pixels, equals(0.0));
controller.position.ensureVisible(
tester.renderObject(find.byKey(const ValueKey<int>(5))),
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart,
);
expect(controller.position.pixels, equals(0.0));
controller.position.ensureVisible(
tester.renderObject(find.byKey(const ValueKey<int>(5))),
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd,
);
expect(controller.position.pixels, equals(60.0));
controller.position.ensureVisible(
tester.renderObject(find.byKey(const ValueKey<int>(0))),
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart,
);
expect(controller.position.pixels, equals(0.0));
});
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment