Configurable padding around FocusNodes in Scrollables (#96815)
This commit is contained in:
parent
82d4dbb6cf
commit
81baee4360
@ -759,6 +759,16 @@ class FocusNode with DiagnosticableTreeMixin, ChangeNotifier {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the amount of additional space to reveal around the attached widget
|
||||||
|
/// when focused inside a scrolling container via [Scrollable.ensureVisible].
|
||||||
|
///
|
||||||
|
/// For example, a value of `EdgeInsets.all(16.0)` ensures 16 pixels of
|
||||||
|
/// the adjacent widget are visible when this node receives focus.
|
||||||
|
///
|
||||||
|
/// By default, this returns [FocusManager.defaultEnsureVisiblePadding] from the
|
||||||
|
/// associated [FocusManager], or [EdgeInsets.zero].
|
||||||
|
EdgeInsets get ensureVisiblePadding => _manager?.defaultEnsureVisiblePadding ?? EdgeInsets.zero;
|
||||||
|
|
||||||
/// Returns the size of the attached widget's [RenderObject], in logical
|
/// Returns the size of the attached widget's [RenderObject], in logical
|
||||||
/// units.
|
/// units.
|
||||||
///
|
///
|
||||||
@ -1710,6 +1720,20 @@ class FocusManager with DiagnosticableTreeMixin, ChangeNotifier {
|
|||||||
return handled;
|
return handled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The default amount of additonal space to reveal when a widget is focused
|
||||||
|
/// inside a scrolling container via [Scrollable.ensureVisible].
|
||||||
|
///
|
||||||
|
/// Defaults to [EdgeInsets.zero], which does not add any additional space
|
||||||
|
/// when widgets are revealed.
|
||||||
|
///
|
||||||
|
/// For example, a value of `EdgeInsets.all(16.0)` ensures 16 pixels of
|
||||||
|
/// the adjacent widget are visible when focusing a widget inside of a
|
||||||
|
/// scrolling container.
|
||||||
|
///
|
||||||
|
/// Individual [FocusNode]s may increase or decrease this padding, use
|
||||||
|
/// [FocusNode.ensureVisiblePadding] to obtain a node's desired padding.
|
||||||
|
EdgeInsets defaultEnsureVisiblePadding = EdgeInsets.zero;
|
||||||
|
|
||||||
/// The node that currently has the primary focus.
|
/// The node that currently has the primary focus.
|
||||||
FocusNode? get primaryFocus => _primaryFocus;
|
FocusNode? get primaryFocus => _primaryFocus;
|
||||||
FocusNode? _primaryFocus;
|
FocusNode? _primaryFocus;
|
||||||
|
@ -36,7 +36,7 @@ void _focusAndEnsureVisible(
|
|||||||
ScrollPositionAlignmentPolicy alignmentPolicy = ScrollPositionAlignmentPolicy.explicit,
|
ScrollPositionAlignmentPolicy alignmentPolicy = ScrollPositionAlignmentPolicy.explicit,
|
||||||
}) {
|
}) {
|
||||||
node.requestFocus();
|
node.requestFocus();
|
||||||
Scrollable.ensureVisible(node.context!, alignment: 1.0, alignmentPolicy: alignmentPolicy);
|
Scrollable.ensureVisible(node.context!, alignment: 1.0, padding: node.ensureVisiblePadding, alignmentPolicy: alignmentPolicy);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A class to temporarily hold information about FocusTraversalGroups when
|
// A class to temporarily hold information about FocusTraversalGroups when
|
||||||
|
@ -346,6 +346,7 @@ class _PagePosition extends ScrollPositionWithSingleContext implements PageMetri
|
|||||||
Future<void> ensureVisible(
|
Future<void> ensureVisible(
|
||||||
RenderObject object, {
|
RenderObject object, {
|
||||||
double alignment = 0.0,
|
double alignment = 0.0,
|
||||||
|
EdgeInsets padding = EdgeInsets.zero,
|
||||||
Duration duration = Duration.zero,
|
Duration duration = Duration.zero,
|
||||||
Curve curve = Curves.ease,
|
Curve curve = Curves.ease,
|
||||||
ScrollPositionAlignmentPolicy alignmentPolicy = ScrollPositionAlignmentPolicy.explicit,
|
ScrollPositionAlignmentPolicy alignmentPolicy = ScrollPositionAlignmentPolicy.explicit,
|
||||||
|
@ -676,6 +676,10 @@ 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.
|
||||||
///
|
///
|
||||||
|
/// The [padding] is used to add extra space around the [object] when revealing it.
|
||||||
|
/// For example, `EdgeInsets.only(bottom: 16.0)` will ensure an additional 16 pixels
|
||||||
|
/// of space are visible below the [object].
|
||||||
|
///
|
||||||
/// The optional `targetRenderObject` parameter is used to determine which area
|
/// The optional `targetRenderObject` parameter is used to determine which area
|
||||||
/// of that object should be as visible as possible. If `targetRenderObject`
|
/// of that object should be as visible as possible. If `targetRenderObject`
|
||||||
/// is null, the entire [RenderObject] (as defined by its
|
/// is null, the entire [RenderObject] (as defined by its
|
||||||
@ -686,9 +690,12 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics {
|
|||||||
///
|
///
|
||||||
/// * [ScrollPositionAlignmentPolicy] for the way in which `alignment` is
|
/// * [ScrollPositionAlignmentPolicy] for the way in which `alignment` is
|
||||||
/// applied, and the way the given `object` is aligned.
|
/// applied, and the way the given `object` is aligned.
|
||||||
|
/// * [FocusNode.ensureVisiblePadding] which specifies the [padding] used when
|
||||||
|
/// a widget is focused via focus traversal.
|
||||||
Future<void> ensureVisible(
|
Future<void> ensureVisible(
|
||||||
RenderObject object, {
|
RenderObject object, {
|
||||||
double alignment = 0.0,
|
double alignment = 0.0,
|
||||||
|
EdgeInsets padding = EdgeInsets.zero,
|
||||||
Duration duration = Duration.zero,
|
Duration duration = Duration.zero,
|
||||||
Curve curve = Curves.ease,
|
Curve curve = Curves.ease,
|
||||||
ScrollPositionAlignmentPolicy alignmentPolicy = ScrollPositionAlignmentPolicy.explicit,
|
ScrollPositionAlignmentPolicy alignmentPolicy = ScrollPositionAlignmentPolicy.explicit,
|
||||||
@ -699,14 +706,18 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics {
|
|||||||
final RenderAbstractViewport viewport = RenderAbstractViewport.of(object)!;
|
final RenderAbstractViewport viewport = RenderAbstractViewport.of(object)!;
|
||||||
assert(viewport != null);
|
assert(viewport != null);
|
||||||
|
|
||||||
Rect? targetRect;
|
Rect targetRect;
|
||||||
if (targetRenderObject != null && targetRenderObject != object) {
|
if (targetRenderObject != null && targetRenderObject != object) {
|
||||||
targetRect = MatrixUtils.transformRect(
|
targetRect = MatrixUtils.transformRect(
|
||||||
targetRenderObject.getTransformTo(object),
|
targetRenderObject.getTransformTo(object),
|
||||||
object.paintBounds.intersect(targetRenderObject.paintBounds),
|
object.paintBounds.intersect(targetRenderObject.paintBounds),
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
targetRect = object.paintBounds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
targetRect = padding.inflateRect(targetRect);
|
||||||
|
|
||||||
double target;
|
double target;
|
||||||
switch (alignmentPolicy) {
|
switch (alignmentPolicy) {
|
||||||
case ScrollPositionAlignmentPolicy.explicit:
|
case ScrollPositionAlignmentPolicy.explicit:
|
||||||
|
@ -311,9 +311,19 @@ class Scrollable extends StatefulWidget {
|
|||||||
|
|
||||||
/// Scrolls the scrollables that enclose the given context so as to make the
|
/// Scrolls the scrollables that enclose the given context so as to make the
|
||||||
/// given context visible.
|
/// given context visible.
|
||||||
|
///
|
||||||
|
/// The [padding] is used to add extra space around the [context]'s
|
||||||
|
/// associated widget when revealing it. For example, `EdgeInsets.only(bottom: 16.0)`
|
||||||
|
/// will ensure an additional 16 pixels of space are visible below the widget.
|
||||||
|
///
|
||||||
|
/// See also:
|
||||||
|
///
|
||||||
|
/// * [FocusNode.ensureVisiblePadding] which specifies the [padding] used when
|
||||||
|
/// a widget is focused via focus traversal.
|
||||||
static Future<void> ensureVisible(
|
static Future<void> ensureVisible(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
double alignment = 0.0,
|
double alignment = 0.0,
|
||||||
|
EdgeInsets padding = EdgeInsets.zero,
|
||||||
Duration duration = Duration.zero,
|
Duration duration = Duration.zero,
|
||||||
Curve curve = Curves.ease,
|
Curve curve = Curves.ease,
|
||||||
ScrollPositionAlignmentPolicy alignmentPolicy = ScrollPositionAlignmentPolicy.explicit,
|
ScrollPositionAlignmentPolicy alignmentPolicy = ScrollPositionAlignmentPolicy.explicit,
|
||||||
@ -332,6 +342,7 @@ class Scrollable extends StatefulWidget {
|
|||||||
futures.add(scrollable.position.ensureVisible(
|
futures.add(scrollable.position.ensureVisible(
|
||||||
context.findRenderObject()!,
|
context.findRenderObject()!,
|
||||||
alignment: alignment,
|
alignment: alignment,
|
||||||
|
padding: padding,
|
||||||
duration: duration,
|
duration: duration,
|
||||||
curve: curve,
|
curve: curve,
|
||||||
alignmentPolicy: alignmentPolicy,
|
alignmentPolicy: alignmentPolicy,
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
// Use of this source code is governed by a BSD-style license that can be
|
// Use of this source code is governed by a BSD-style license that can be
|
||||||
// found in the LICENSE file.
|
// found in the LICENSE file.
|
||||||
|
|
||||||
|
import 'dart:math';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@ -1874,6 +1875,438 @@ void main() {
|
|||||||
expect(controller.offset, equals(0.0));
|
expect(controller.offset, equals(0.0));
|
||||||
}, skip: isBrowser, variant: KeySimulatorTransitModeVariant.all()); // https://github.com/flutter/flutter/issues/35347
|
}, skip: isBrowser, variant: KeySimulatorTransitModeVariant.all()); // https://github.com/flutter/flutter/issues/35347
|
||||||
|
|
||||||
|
testWidgets('Focus traversal inside a vertical scrollable applies ensure visible padding.', (WidgetTester tester) async {
|
||||||
|
tester.binding.focusManager.defaultEnsureVisiblePadding = const EdgeInsets.all(50.0);
|
||||||
|
|
||||||
|
addTearDown(() {
|
||||||
|
tester.binding.focusManager.defaultEnsureVisiblePadding = EdgeInsets.zero;
|
||||||
|
});
|
||||||
|
|
||||||
|
const double minScrollExtent = 0.0;
|
||||||
|
const double maxScrollExtent = 700.0;
|
||||||
|
|
||||||
|
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(
|
||||||
|
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, taking padding into account.
|
||||||
|
for (int i = 1; i <= 2; ++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 = 3; i <= 10; ++i) {
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||||
|
await tester.pump();
|
||||||
|
final double expectedOffset = min(100.0 * (i - 3) + 50.0, maxScrollExtent);
|
||||||
|
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(maxScrollExtent));
|
||||||
|
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
||||||
|
await tester.pump();
|
||||||
|
expect(nodes[10].hasPrimaryFocus, isTrue);
|
||||||
|
expect(controller.offset, equals(maxScrollExtent));
|
||||||
|
|
||||||
|
// 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 >= 9; --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 = 8; i >= 1; --i) {
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
||||||
|
await tester.pump();
|
||||||
|
final double expectedOffset = max(100.0 * (i - 1) - 50.0, minScrollExtent);
|
||||||
|
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: isBrowser, variant: KeySimulatorTransitModeVariant.all()); // https://github.com/flutter/flutter/issues/35347
|
||||||
|
|
||||||
|
testWidgets('Focus traversal inside a horizontal scrollable applies ensure visible padding.', (WidgetTester tester) async {
|
||||||
|
tester.binding.focusManager.defaultEnsureVisiblePadding = const EdgeInsets.all(50.0);
|
||||||
|
|
||||||
|
addTearDown(() {
|
||||||
|
tester.binding.focusManager.defaultEnsureVisiblePadding = EdgeInsets.zero;
|
||||||
|
});
|
||||||
|
|
||||||
|
const double minScrollExtent = 0.0;
|
||||||
|
const double maxScrollExtent = 500.0;
|
||||||
|
|
||||||
|
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, taking padding into account.
|
||||||
|
for (int i = 1; i <= 4; ++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 = 5; i <= 10; ++i) {
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||||
|
await tester.pump();
|
||||||
|
final double expectedOffset = min(100.0 * (i - 5) + 50.0, maxScrollExtent);
|
||||||
|
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(maxScrollExtent));
|
||||||
|
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
|
||||||
|
await tester.pump();
|
||||||
|
expect(nodes[10].hasPrimaryFocus, isTrue);
|
||||||
|
expect(controller.offset, equals(maxScrollExtent));
|
||||||
|
|
||||||
|
// 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 = max(100.0 * (i - 1) - 50.0, minScrollExtent);
|
||||||
|
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: isBrowser, variant: KeySimulatorTransitModeVariant.all()); // https://github.com/flutter/flutter/issues/35347
|
||||||
|
|
||||||
|
testWidgets('Focus traversal inside a vertical scrollable applies asymmetric ensure visible padding.', (WidgetTester tester) async {
|
||||||
|
const double leadingPadding = 25.0;
|
||||||
|
const double trailingPadding = 50.0;
|
||||||
|
|
||||||
|
tester.binding.focusManager.defaultEnsureVisiblePadding = const EdgeInsets.only(top: leadingPadding, bottom: trailingPadding);
|
||||||
|
|
||||||
|
addTearDown(() {
|
||||||
|
tester.binding.focusManager.defaultEnsureVisiblePadding = EdgeInsets.zero;
|
||||||
|
});
|
||||||
|
|
||||||
|
const double minScrollExtent = 0.0;
|
||||||
|
const double maxScrollExtent = 700.0;
|
||||||
|
|
||||||
|
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(
|
||||||
|
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, taking padding into account.
|
||||||
|
for (int i = 1; i <= 2; ++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 = 3; i <= 10; ++i) {
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||||
|
await tester.pump();
|
||||||
|
final double expectedOffset = min(100.0 * (i - 3) + trailingPadding, maxScrollExtent);
|
||||||
|
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(maxScrollExtent));
|
||||||
|
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
||||||
|
await tester.pump();
|
||||||
|
expect(nodes[10].hasPrimaryFocus, isTrue);
|
||||||
|
expect(controller.offset, equals(maxScrollExtent));
|
||||||
|
|
||||||
|
// 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 >= 9; --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 = 8; i >= 1; --i) {
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
||||||
|
await tester.pump();
|
||||||
|
final double expectedOffset = max(100.0 * (i - 1) - leadingPadding, minScrollExtent);
|
||||||
|
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: isBrowser, variant: KeySimulatorTransitModeVariant.all()); // https://github.com/flutter/flutter/issues/35347
|
||||||
|
|
||||||
|
testWidgets('Focus traversal inside a horizontal scrollable applies asymmetric ensure visible padding.', (WidgetTester tester) async {
|
||||||
|
const double leadingPadding = 25.0;
|
||||||
|
const double trailingPadding = 50.0;
|
||||||
|
|
||||||
|
tester.binding.focusManager.defaultEnsureVisiblePadding = const EdgeInsets.only(left: leadingPadding, right: trailingPadding);
|
||||||
|
|
||||||
|
addTearDown(() {
|
||||||
|
tester.binding.focusManager.defaultEnsureVisiblePadding = EdgeInsets.zero;
|
||||||
|
});
|
||||||
|
|
||||||
|
const double minScrollExtent = 0.0;
|
||||||
|
const double maxScrollExtent = 500.0;
|
||||||
|
|
||||||
|
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, taking padding into account.
|
||||||
|
for (int i = 1; i <= 4; ++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 = 5; i <= 10; ++i) {
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||||
|
await tester.pump();
|
||||||
|
final double expectedOffset = min(100.0 * (i - 5) + trailingPadding, maxScrollExtent);
|
||||||
|
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(maxScrollExtent));
|
||||||
|
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
|
||||||
|
await tester.pump();
|
||||||
|
expect(nodes[10].hasPrimaryFocus, isTrue);
|
||||||
|
expect(controller.offset, equals(maxScrollExtent));
|
||||||
|
|
||||||
|
// 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 = max(100.0 * (i - 1) - leadingPadding, minScrollExtent);
|
||||||
|
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: isBrowser, variant: KeySimulatorTransitModeVariant.all()); // https://github.com/flutter/flutter/issues/35347
|
||||||
|
|
||||||
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');
|
||||||
|
Loading…
x
Reference in New Issue
Block a user