Skip to content

Commit b5c461a

Browse files
authored
a11y: implement new SemanticsAction "showOnScreen" (v2) (#11156)
* a11y: implement new SemanticsAction "showOnScreen" (v2) This action is triggered when the user swipes (in accessibility mode) to the last visible item of a scrollable list to bring that item fully on screen. This requires engine rolled to flutter/engine#3856. I am in the process of adding tests, but I'd like to get early feedback to see if this approach is OK. * fix null check * review comments * review comments * Add test * fix analyzer warning
1 parent d767ac0 commit b5c461a

File tree

7 files changed

+109
-6
lines changed

7 files changed

+109
-6
lines changed

packages/flutter/lib/src/rendering/object.dart

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -739,7 +739,8 @@ class _RootSemanticsFragment extends _InterestingSemanticsFragment {
739739
assert(parentSemantics == null);
740740
renderObjectOwner._semantics ??= new SemanticsNode.root(
741741
handler: renderObjectOwner is SemanticsActionHandler ? renderObjectOwner as dynamic : null,
742-
owner: renderObjectOwner.owner.semanticsOwner
742+
owner: renderObjectOwner.owner.semanticsOwner,
743+
showOnScreen: renderObjectOwner.showOnScreen,
743744
);
744745
final SemanticsNode node = renderObjectOwner._semantics;
745746
assert(MatrixUtils.matrixEquals(node.transform, new Matrix4.identity()));
@@ -768,7 +769,8 @@ class _ConcreteSemanticsFragment extends _InterestingSemanticsFragment {
768769
@override
769770
SemanticsNode establishSemanticsNode(_SemanticsGeometry geometry, SemanticsNode currentSemantics, SemanticsNode parentSemantics) {
770771
renderObjectOwner._semantics ??= new SemanticsNode(
771-
handler: renderObjectOwner is SemanticsActionHandler ? renderObjectOwner as dynamic : null
772+
handler: renderObjectOwner is SemanticsActionHandler ? renderObjectOwner as dynamic : null,
773+
showOnScreen: renderObjectOwner.showOnScreen,
772774
);
773775
final SemanticsNode node = renderObjectOwner._semantics;
774776
if (geometry != null) {
@@ -812,7 +814,8 @@ class _ImplicitSemanticsFragment extends _InterestingSemanticsFragment {
812814
_haveConcreteNode = currentSemantics == null && annotator != null;
813815
if (haveConcreteNode) {
814816
renderObjectOwner._semantics ??= new SemanticsNode(
815-
handler: renderObjectOwner is SemanticsActionHandler ? renderObjectOwner as dynamic : null
817+
handler: renderObjectOwner is SemanticsActionHandler ? renderObjectOwner as dynamic : null,
818+
showOnScreen: renderObjectOwner.showOnScreen,
816819
);
817820
node = renderObjectOwner._semantics;
818821
} else {
@@ -2777,6 +2780,17 @@ abstract class RenderObject extends AbstractNode implements HitTestTarget {
27772780
@protected
27782781
String debugDescribeChildren(String prefix) => '';
27792782

2783+
2784+
/// Attempt to make this or a descendant RenderObject visible on screen.
2785+
///
2786+
/// If [child] is provided, that [RenderObject] is made visible. If [child] is
2787+
/// omitted, this [RenderObject] is made visible.
2788+
void showOnScreen([RenderObject child]) {
2789+
if (parent is RenderObject) {
2790+
final RenderObject renderParent = parent;
2791+
renderParent.showOnScreen(child ?? this);
2792+
}
2793+
}
27802794
}
27812795

27822796
/// Generic mixin for render objects with one child.

packages/flutter/lib/src/rendering/semantics.dart

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,17 +143,21 @@ class SemanticsNode extends AbstractNode {
143143
/// Each semantic node has a unique identifier that is assigned when the node
144144
/// is created.
145145
SemanticsNode({
146-
SemanticsActionHandler handler
146+
SemanticsActionHandler handler,
147+
VoidCallback showOnScreen,
147148
}) : id = _generateNewId(),
149+
_showOnScreen = showOnScreen,
148150
_actionHandler = handler;
149151

150152
/// Creates a semantic node to represent the root of the semantics tree.
151153
///
152154
/// The root node is assigned an identifier of zero.
153155
SemanticsNode.root({
154156
SemanticsActionHandler handler,
155-
SemanticsOwner owner
157+
VoidCallback showOnScreen,
158+
SemanticsOwner owner,
156159
}) : id = 0,
160+
_showOnScreen = showOnScreen,
157161
_actionHandler = handler {
158162
attach(owner);
159163
}
@@ -171,6 +175,7 @@ class SemanticsNode extends AbstractNode {
171175
final int id;
172176

173177
final SemanticsActionHandler _actionHandler;
178+
final VoidCallback _showOnScreen;
174179

175180
// GEOMETRY
176181
// These are automatically handled by RenderObject's own logic
@@ -734,7 +739,14 @@ class SemanticsOwner extends ChangeNotifier {
734739
void performAction(int id, SemanticsAction action) {
735740
assert(action != null);
736741
final SemanticsActionHandler handler = _getSemanticsActionHandlerForId(id, action);
737-
handler?.performAction(action);
742+
if (handler != null) {
743+
handler.performAction(action);
744+
return;
745+
}
746+
747+
// Default actions if no [handler] was provided.
748+
if (action == SemanticsAction.showOnScreen && _nodes[id]._showOnScreen != null)
749+
_nodes[id]._showOnScreen();
738750
}
739751

740752
SemanticsActionHandler _getSemanticsActionHandlerForPosition(SemanticsNode node, Offset position, SemanticsAction action) {

packages/flutter/lib/src/rendering/viewport.dart

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,24 @@ abstract class RenderViewportBase<ParentDataClass extends ContainerParentDataMix
591591
/// This should be the reverse order of [childrenInPaintOrder].
592592
@protected
593593
Iterable<RenderSliver> get childrenInHitTestOrder;
594+
595+
@override
596+
void showOnScreen([RenderObject child]) {
597+
// Logic duplicated in [_RenderSingleChildViewport.showOnScreen].
598+
if (child != null) {
599+
// Move viewport the smallest distance to bring [child] on screen.
600+
final double leadingEdgeOffset = getOffsetToReveal(child, 0.0);
601+
final double trailingEdgeOffset = getOffsetToReveal(child, 1.0);
602+
final double currentOffset = offset.pixels;
603+
if ((currentOffset - leadingEdgeOffset).abs() < (currentOffset - trailingEdgeOffset).abs()) {
604+
offset.jumpTo(leadingEdgeOffset);
605+
} else {
606+
offset.jumpTo(trailingEdgeOffset);
607+
}
608+
}
609+
// Make sure the viewport itself is on screen.
610+
super.showOnScreen();
611+
}
594612
}
595613

596614
/// A render object that is bigger on the inside.

packages/flutter/lib/src/rendering/viewport_offset.dart

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,10 @@ abstract class ViewportOffset extends ChangeNotifier {
152152
/// being called again, though this should be very rare.
153153
void correctBy(double correction);
154154

155+
/// Jumps the scroll position from its current value to the given value,
156+
/// without animation, and without checking if the new value is in range.
157+
void jumpTo(double pixels);
158+
155159
/// The direction in which the user is trying to change [pixels], relative to
156160
/// the viewport's [RenderViewport.axisDirection].
157161
///
@@ -208,6 +212,11 @@ class _FixedViewportOffset extends ViewportOffset {
208212
_pixels += correction;
209213
}
210214

215+
@override
216+
void jumpTo(double pixels) {
217+
// Do nothing, viewport is fixed.
218+
}
219+
211220
@override
212221
ScrollDirection get userScrollDirection => ScrollDirection.idle;
213222
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,7 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics {
510510
/// If this method changes the scroll position, a sequence of start/update/end
511511
/// scroll notifications will be dispatched. No overscroll notifications can
512512
/// be generated by this method.
513+
@override
513514
void jumpTo(double value);
514515

515516
/// Deprecated. Use [jumpTo] or a custom [ScrollPosition] instead.

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,4 +418,23 @@ class _RenderSingleChildViewport extends RenderBox with RenderObjectWithChildMix
418418

419419
return leadingScrollOffset - (mainAxisExtent - targetMainAxisExtent) * alignment;
420420
}
421+
422+
@override
423+
void showOnScreen([RenderObject child]) {
424+
// Logic duplicated in [RenderViewportBase.showOnScreen].
425+
if (child != null) {
426+
// Move viewport the smallest distance to bring [child] on screen.
427+
final double leadingEdgeOffset = getOffsetToReveal(child, 0.0);
428+
final double trailingEdgeOffset = getOffsetToReveal(child, 1.0);
429+
final double currentOffset = offset.pixels;
430+
if ((currentOffset - leadingEdgeOffset).abs() < (currentOffset - trailingEdgeOffset).abs()) {
431+
offset.jumpTo(leadingEdgeOffset);
432+
} else {
433+
offset.jumpTo(trailingEdgeOffset);
434+
}
435+
}
436+
437+
// Make sure the viewport itself is on screen.
438+
super.showOnScreen();
439+
}
421440
}

packages/flutter/test/widgets/scrollable_semantics_test.dart

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,37 @@ void main() {
3030

3131
await flingDown(tester);
3232
expect(semantics, includesNodeWith(actions: <SemanticsAction>[SemanticsAction.scrollUp, SemanticsAction.scrollDown]));
33+
});
34+
35+
testWidgets('showOnScreen works in scrollable', (WidgetTester tester) async {
36+
new SemanticsTester(tester); // enables semantics tree generation
37+
38+
const double kItemHeight = 40.0;
39+
40+
final List<Widget> containers = <Widget>[];
41+
for (int i = 0; i < 80; i++)
42+
containers.add(new MergeSemantics(child: new Container(
43+
height: kItemHeight,
44+
child: new Text('container $i'),
45+
)));
46+
47+
final ScrollController scrollController = new ScrollController(
48+
initialScrollOffset: kItemHeight / 2,
49+
);
50+
51+
await tester.pumpWidget(new ListView(
52+
controller: scrollController,
53+
children: containers
54+
));
55+
56+
expect(scrollController.offset, kItemHeight / 2);
57+
58+
final int firstContainerId = tester.renderObject(find.byWidget(containers.first)).debugSemantics.id;
59+
tester.binding.pipelineOwner.semanticsOwner.performAction(firstContainerId, SemanticsAction.showOnScreen);
60+
await tester.pump();
61+
await tester.pump(const Duration(seconds: 5));
3362

63+
expect(scrollController.offset, 0.0);
3464
});
3565
}
3666

0 commit comments

Comments
 (0)