Unverified Commit 72517f0a authored by Michael Goderbauer's avatar Michael Goderbauer Committed by GitHub

Encode scrolling status into tree (#14536)

parent 248919fa
a031239a5d4e44e60d0ebc62b8c544a9f592fc22 8ac6f6efa177fb548dcdc81f1501f060b2ad1115
...@@ -12,7 +12,7 @@ bool nearEqual(double a, double b, double epsilon) { ...@@ -12,7 +12,7 @@ bool nearEqual(double a, double b, double epsilon) {
assert(epsilon >= 0.0); assert(epsilon >= 0.0);
if (a == null || b == null) if (a == null || b == null)
return a == b; return a == b;
return (a > (b - epsilon)) && (a < (b + epsilon)); return (a > (b - epsilon)) && (a < (b + epsilon)) || a == b;
} }
/// Whether a double is within a given distance of zero. /// Whether a double is within a given distance of zero.
......
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
// 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 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart'; import 'package:flutter/painting.dart';
/// An event sent by the application to notify interested listeners that /// An event sent by the application to notify interested listeners that
...@@ -53,75 +52,6 @@ abstract class SemanticsEvent { ...@@ -53,75 +52,6 @@ abstract class SemanticsEvent {
} }
} }
/// Notifies that a scroll action has been completed.
///
/// This event translates into a `AccessibilityEvent.TYPE_VIEW_SCROLLED` on
/// Android and a `UIAccessibilityPageScrolledNotification` on iOS. It is
/// processed by the accessibility systems of the operating system to provide
/// additional feedback to the user about the state of a scrollable view (e.g.
/// on Android, a ping sound is played to indicate that a scroll action was
/// successful).
class ScrollCompletedSemanticsEvent extends SemanticsEvent {
/// Creates a [ScrollCompletedSemanticsEvent].
///
/// This event should be sent after a scroll action is completed. It is
/// interpreted by assistive technologies to provide additional feedback about
/// the just completed scroll action to the user.
///
/// The parameters [axis], [pixels], [minScrollExtent], and [maxScrollExtent] are
/// required and may not be null.
ScrollCompletedSemanticsEvent({
@required this.axis,
@required this.pixels,
@required this.maxScrollExtent,
@required this.minScrollExtent
}) : assert(axis != null),
assert(pixels != null),
assert(maxScrollExtent != null),
assert(minScrollExtent != null),
super('scroll');
/// The axis in which the scroll view was scrolled.
///
/// See also [ScrollPosition.axis].
final Axis axis;
/// The current scroll position, in logical pixels.
///
/// See also [ScrollPosition.pixels].
final double pixels;
/// The minimum in-range value for [pixels].
///
/// See also [ScrollPosition.minScrollExtent].
final double minScrollExtent;
/// The maximum in-range value for [pixels].
///
/// See also [ScrollPosition.maxScrollExtent].
final double maxScrollExtent;
@override
Map<String, dynamic> getDataMap() {
final Map<String, dynamic> map = <String, dynamic>{
'pixels': pixels.clamp(minScrollExtent, maxScrollExtent),
'minScrollExtent': minScrollExtent,
'maxScrollExtent': maxScrollExtent,
};
switch (axis) {
case Axis.horizontal:
map['axis'] = 'h';
break;
case Axis.vertical:
map['axis'] = 'v';
break;
}
return map;
}
}
/// An event for a semantic announcement. /// An event for a semantic announcement.
/// ///
/// This should be used for announcement that are not seamlessly announced by /// This should be used for announcement that are not seamlessly announced by
......
...@@ -59,11 +59,15 @@ abstract class ScrollMetrics { ...@@ -59,11 +59,15 @@ abstract class ScrollMetrics {
/// The minimum in-range value for [pixels]. /// The minimum in-range value for [pixels].
/// ///
/// The actual [pixels] value might be [outOfRange]. /// The actual [pixels] value might be [outOfRange].
///
/// This value can be negative infinity, if the scroll is unbounded.
double get minScrollExtent; double get minScrollExtent;
/// The maximum in-range value for [pixels]. /// The maximum in-range value for [pixels].
/// ///
/// The actual [pixels] value might be [outOfRange]. /// The actual [pixels] value might be [outOfRange].
///
/// This value can be infinity, if the scroll is unbounded.
double get maxScrollExtent; double get maxScrollExtent;
/// The current scroll position, in logical pixels along the [axisDirection]. /// The current scroll position, in logical pixels along the [axisDirection].
......
...@@ -275,7 +275,6 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin ...@@ -275,7 +275,6 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin
final ScrollPosition oldPosition = position; final ScrollPosition oldPosition = position;
if (oldPosition != null) { if (oldPosition != null) {
controller?.detach(oldPosition); controller?.detach(oldPosition);
oldPosition.removeListener(_sendSemanticsScrollEvent);
// It's important that we not dispose the old position until after the // It's important that we not dispose the old position until after the
// viewport has had a chance to unregister its listeners from the old // viewport has had a chance to unregister its listeners from the old
// position. So, schedule a microtask to do it. // position. So, schedule a microtask to do it.
...@@ -284,30 +283,10 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin ...@@ -284,30 +283,10 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin
_position = controller?.createScrollPosition(_physics, this, oldPosition) _position = controller?.createScrollPosition(_physics, this, oldPosition)
?? new ScrollPositionWithSingleContext(physics: _physics, context: this, oldPosition: oldPosition); ?? new ScrollPositionWithSingleContext(physics: _physics, context: this, oldPosition: oldPosition);
_position.addListener(_sendSemanticsScrollEvent);
assert(position != null); assert(position != null);
controller?.attach(position); controller?.attach(position);
} }
bool _semanticsScrollEventScheduled = false;
void _sendSemanticsScrollEvent() {
if (_semanticsScrollEventScheduled)
return;
SchedulerBinding.instance.addPostFrameCallback((Duration timestamp) {
final _RenderExcludableScrollSemantics render = _excludableScrollSemanticsKey.currentContext?.findRenderObject();
render?.sendSemanticsEvent(new ScrollCompletedSemanticsEvent(
axis: position.axis,
pixels: position.pixels,
minScrollExtent: position.minScrollExtent,
maxScrollExtent: position.maxScrollExtent,
));
_semanticsScrollEventScheduled = false;
});
_semanticsScrollEventScheduled = true;
}
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
...@@ -529,6 +508,7 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin ...@@ -529,6 +508,7 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin
result = new _ExcludableScrollSemantics( result = new _ExcludableScrollSemantics(
key: _excludableScrollSemanticsKey, key: _excludableScrollSemanticsKey,
child: result, child: result,
position: position,
); );
} }
...@@ -557,28 +537,61 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin ...@@ -557,28 +537,61 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin
/// node, which is annotated with the scrolling actions, will house the /// node, which is annotated with the scrolling actions, will house the
/// scrollable children. /// scrollable children.
class _ExcludableScrollSemantics extends SingleChildRenderObjectWidget { class _ExcludableScrollSemantics extends SingleChildRenderObjectWidget {
const _ExcludableScrollSemantics({ Key key, Widget child }) : super(key: key, child: child); const _ExcludableScrollSemantics({
Key key,
@required this.position,
Widget child
}) : assert(position != null), super(key: key, child: child);
final ScrollPosition position;
@override @override
_RenderExcludableScrollSemantics createRenderObject(BuildContext context) => new _RenderExcludableScrollSemantics(); _RenderExcludableScrollSemantics createRenderObject(BuildContext context) => new _RenderExcludableScrollSemantics(position: position);
@override
void updateRenderObject(BuildContext context, _RenderExcludableScrollSemantics renderObject) {
renderObject.position = position;
}
} }
class _RenderExcludableScrollSemantics extends RenderProxyBox { class _RenderExcludableScrollSemantics extends RenderProxyBox {
_RenderExcludableScrollSemantics({ RenderBox child }) : super(child); _RenderExcludableScrollSemantics({
@required ScrollPosition position,
RenderBox child,
}) : _position = position, assert(position != null), super(child) {
position.addListener(markNeedsSemanticsUpdate);
}
/// Whether this render object is excluded from the semantic tree.
ScrollPosition get position => _position;
ScrollPosition _position;
set position(ScrollPosition value) {
assert(value != null);
if (value == _position)
return;
_position.removeListener(markNeedsSemanticsUpdate);
_position = value;
_position.addListener(markNeedsSemanticsUpdate);
markNeedsSemanticsUpdate();
}
@override @override
void describeSemanticsConfiguration(SemanticsConfiguration config) { void describeSemanticsConfiguration(SemanticsConfiguration config) {
super.describeSemanticsConfiguration(config); super.describeSemanticsConfiguration(config);
config.isSemanticBoundary = true; config.isSemanticBoundary = true;
if (position.haveDimensions) {
config
..scrollPosition = _position.pixels
..scrollExtentMax = _position.maxScrollExtent
..scrollExtentMin = _position.minScrollExtent;
}
} }
SemanticsNode _innerNode; SemanticsNode _innerNode;
SemanticsNode _annotatedNode;
@override @override
void assembleSemanticsNode(SemanticsNode node, SemanticsConfiguration config, Iterable<SemanticsNode> children) { void assembleSemanticsNode(SemanticsNode node, SemanticsConfiguration config, Iterable<SemanticsNode> children) {
if (children.isEmpty || !children.first.isTagged(RenderViewport.useTwoPaneSemantics)) { if (children.isEmpty || !children.first.isTagged(RenderViewport.useTwoPaneSemantics)) {
_annotatedNode = node;
super.assembleSemanticsNode(node, config, children); super.assembleSemanticsNode(node, config, children);
return; return;
} }
...@@ -587,7 +600,6 @@ class _RenderExcludableScrollSemantics extends RenderProxyBox { ...@@ -587,7 +600,6 @@ class _RenderExcludableScrollSemantics extends RenderProxyBox {
_innerNode _innerNode
..isMergedIntoParent = node.isPartOfNodeMerging ..isMergedIntoParent = node.isPartOfNodeMerging
..rect = Offset.zero & node.rect.size; ..rect = Offset.zero & node.rect.size;
_annotatedNode = _innerNode;
final List<SemanticsNode> excluded = <SemanticsNode>[_innerNode]; final List<SemanticsNode> excluded = <SemanticsNode>[_innerNode];
final List<SemanticsNode> included = <SemanticsNode>[]; final List<SemanticsNode> included = <SemanticsNode>[];
...@@ -606,12 +618,5 @@ class _RenderExcludableScrollSemantics extends RenderProxyBox { ...@@ -606,12 +618,5 @@ class _RenderExcludableScrollSemantics extends RenderProxyBox {
void clearSemantics() { void clearSemantics() {
super.clearSemantics(); super.clearSemantics();
_innerNode = null; _innerNode = null;
_annotatedNode = null;
}
/// Sends a [SemanticsEvent] in the context of the [SemanticsNode] that is
/// annotated with this object's semantics information.
void sendSemanticsEvent(SemanticsEvent event) {
_annotatedNode?.sendEvent(event);
} }
} }
// Copyright 2017 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/physics.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('nearEquals', () {
expect(nearEqual(double.infinity, double.infinity, 0.1), isTrue);
expect(nearEqual(double.negativeInfinity, double.negativeInfinity, 0.1), isTrue);
expect(nearEqual(double.infinity, double.negativeInfinity, 0.1), isFalse);
expect(nearEqual(0.1, 0.11, 0.001), isFalse);
expect(nearEqual(0.1, 0.11, 0.1), isTrue);
expect(nearEqual(0.1, 0.1, 0.0000001), isTrue);
});
}
...@@ -348,7 +348,7 @@ void main() { ...@@ -348,7 +348,7 @@ void main() {
expect( expect(
minimalProperties.toStringDeep(minLevel: DiagnosticLevel.hidden), minimalProperties.toStringDeep(minLevel: DiagnosticLevel.hidden),
'SemanticsNode#1(owner: null, isMergedIntoParent: false, mergeAllDescendantsIntoThisNode: false, Rect.fromLTRB(0.0, 0.0, 0.0, 0.0), actions: [], isInMutuallyExcusiveGroup: false, isSelected: false, isFocused: false, isButton: false, isTextField: false, invisible, label: "", value: "", increasedValue: "", decreasedValue: "", hint: "", textDirection: null, nextNodeId: null, sortOrder: null)\n' 'SemanticsNode#1(owner: null, isMergedIntoParent: false, mergeAllDescendantsIntoThisNode: false, Rect.fromLTRB(0.0, 0.0, 0.0, 0.0), actions: [], isInMutuallyExcusiveGroup: false, isSelected: false, isFocused: false, isButton: false, isTextField: false, invisible, label: "", value: "", increasedValue: "", decreasedValue: "", hint: "", textDirection: null, nextNodeId: null, sortOrder: null, scrollExtentMin: null, scrollPosition: null, scrollExtentMax: null)\n'
); );
final SemanticsConfiguration config = new SemanticsConfiguration() final SemanticsConfiguration config = new SemanticsConfiguration()
......
...@@ -5,7 +5,6 @@ ...@@ -5,7 +5,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'semantics_tester.dart'; import 'semantics_tester.dart';
...@@ -204,12 +203,7 @@ void main() { ...@@ -204,12 +203,7 @@ void main() {
expect(tester.getTopLeft(find.byWidget(children[1])).dy, kToolbarHeight); expect(tester.getTopLeft(find.byWidget(children[1])).dy, kToolbarHeight);
}); });
testWidgets('vertical scrolling sends ScrollCompletedSemanticsEvent', (WidgetTester tester) async { testWidgets('correct scrollProgress', (WidgetTester tester) async {
final List<dynamic> messages = <dynamic>[];
SystemChannels.accessibility.setMockMessageHandler((dynamic message) {
messages.add(message);
});
semantics = new SemanticsTester(tester); semantics = new SemanticsTester(tester);
final List<Widget> textWidgets = <Widget>[]; final List<Widget> textWidgets = <Widget>[];
...@@ -220,74 +214,84 @@ void main() { ...@@ -220,74 +214,84 @@ void main() {
child: new ListView(children: textWidgets), child: new ListView(children: textWidgets),
)); ));
await flingUp(tester); expect(semantics, includesNodeWith(
scrollExtentMin: 0.0,
expect(messages, isNot(hasLength(0))); scrollPosition: 0.0,
expect(messages.every((dynamic message) => message['type'] == 'scroll'), isTrue); scrollExtentMax: 520.0,
actions: <SemanticsAction>[
Map<Object, Object> message = messages.last['data']; SemanticsAction.scrollUp,
expect(message['axis'], 'v'); ],
expect(message['pixels'], isPositive); ));
expect(message['minScrollExtent'], 0.0);
expect(message['maxScrollExtent'], 520.0);
messages.clear(); await flingUp(tester);
await flingDown(tester);
expect(messages, isNot(hasLength(0))); expect(semantics, includesNodeWith(
expect(messages.every((dynamic message) => message['type'] == 'scroll'), isTrue); scrollExtentMin: 0.0,
scrollPosition: 380.2,
scrollExtentMax: 520.0,
actions: <SemanticsAction>[
SemanticsAction.scrollUp,
SemanticsAction.scrollDown,
],
));
message = messages.last['data']; await flingUp(tester);
expect(message['axis'], 'v');
expect(message['pixels'], isNonNegative);
expect(message['minScrollExtent'], 0.0);
expect(message['maxScrollExtent'], 520.0);
});
testWidgets('horizontal scrolling sends ScrollCompletedSemanticsEvent', (WidgetTester tester) async { expect(semantics, includesNodeWith(
final List<dynamic> messages = <dynamic>[]; scrollExtentMin: 0.0,
SystemChannels.accessibility.setMockMessageHandler((dynamic message) { scrollPosition: 520.0,
messages.add(message); scrollExtentMax: 520.0,
actions: <SemanticsAction>[
SemanticsAction.scrollDown,
],
));
}); });
testWidgets('correct scrollProgress for unbound', (WidgetTester tester) async {
semantics = new SemanticsTester(tester); semantics = new SemanticsTester(tester);
final List<Widget> children = <Widget>[];
for (int i = 0; i < 80; i++)
children.add(new Container(
child: new Text('$i'),
width: 100.0,
));
await tester.pumpWidget(new Directionality( await tester.pumpWidget(new Directionality(
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
child: new ListView( child: new ListView.builder(
children: children, itemExtent: 20.0,
scrollDirection: Axis.horizontal, itemBuilder: (BuildContext context, int index) {
return new Text('entry $index');
},
), ),
)); ));
await flingLeft(tester); expect(semantics, includesNodeWith(
scrollExtentMin: 0.0,
expect(messages, isNot(hasLength(0))); scrollPosition: 0.0,
expect(messages.every((dynamic message) => message['type'] == 'scroll'), isTrue); scrollExtentMax: double.infinity,
actions: <SemanticsAction>[
SemanticsAction.scrollUp,
],
));
Map<Object, Object> message = messages.last['data']; await flingUp(tester);
expect(message['axis'], 'h');
expect(message['pixels'], isPositive);
expect(message['minScrollExtent'], 0.0);
expect(message['maxScrollExtent'], 7200.0);
messages.clear(); expect(semantics, includesNodeWith(
await flingRight(tester); scrollExtentMin: 0.0,
scrollPosition: 380.2,
scrollExtentMax: double.infinity,
actions: <SemanticsAction>[
SemanticsAction.scrollUp,
SemanticsAction.scrollDown,
],
));
expect(messages, isNot(hasLength(0))); await flingUp(tester);
expect(messages.every((dynamic message) => message['type'] == 'scroll'), isTrue);
message = messages.last['data']; expect(semantics, includesNodeWith(
expect(message['axis'], 'h'); scrollExtentMin: 0.0,
expect(message['pixels'], isNonNegative); scrollPosition: 760.4,
expect(message['minScrollExtent'], 0.0); scrollExtentMax: double.infinity,
expect(message['maxScrollExtent'], 7200.0); actions: <SemanticsAction>[
SemanticsAction.scrollUp,
SemanticsAction.scrollDown,
],
));
}); });
testWidgets('Semantics tree is populated mid-scroll', (WidgetTester tester) async { testWidgets('Semantics tree is populated mid-scroll', (WidgetTester tester) async {
......
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
import 'dart:ui' show SemanticsFlag; import 'dart:ui' show SemanticsFlag;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
...@@ -369,6 +370,9 @@ class SemanticsTester { ...@@ -369,6 +370,9 @@ class SemanticsTester {
TextDirection textDirection, TextDirection textDirection,
List<SemanticsAction> actions, List<SemanticsAction> actions,
List<SemanticsFlag> flags, List<SemanticsFlag> flags,
double scrollPosition,
double scrollExtentMax,
double scrollExtentMin,
SemanticsNode ancestor, SemanticsNode ancestor,
}) { }) {
bool checkNode(SemanticsNode node) { bool checkNode(SemanticsNode node) {
...@@ -390,6 +394,12 @@ class SemanticsTester { ...@@ -390,6 +394,12 @@ class SemanticsTester {
if (expectedFlags != actualFlags) if (expectedFlags != actualFlags)
return false; return false;
} }
if (scrollPosition != null && !nearEqual(node.scrollPosition, scrollPosition, 0.1))
return false;
if (scrollExtentMax != null && !nearEqual(node.scrollExtentMax, scrollExtentMax, 0.1))
return false;
if (scrollExtentMin != null && !nearEqual(node.scrollExtentMin, scrollExtentMin, 0.1))
return false;
return true; return true;
} }
...@@ -578,13 +588,19 @@ class _IncludesNodeWith extends Matcher { ...@@ -578,13 +588,19 @@ class _IncludesNodeWith extends Matcher {
this.textDirection, this.textDirection,
this.actions, this.actions,
this.flags, this.flags,
}) : assert(label != null || value != null || actions != null || flags != null); this.scrollPosition,
this.scrollExtentMax,
this.scrollExtentMin,
}) : assert(label != null || value != null || actions != null || flags != null || scrollPosition != null || scrollExtentMax != null || scrollExtentMin != null);
final String label; final String label;
final String value; final String value;
final TextDirection textDirection; final TextDirection textDirection;
final List<SemanticsAction> actions; final List<SemanticsAction> actions;
final List<SemanticsFlag> flags; final List<SemanticsFlag> flags;
final double scrollPosition;
final double scrollExtentMax;
final double scrollExtentMin;
@override @override
bool matches(covariant SemanticsTester item, Map<dynamic, dynamic> matchState) { bool matches(covariant SemanticsTester item, Map<dynamic, dynamic> matchState) {
...@@ -594,6 +610,9 @@ class _IncludesNodeWith extends Matcher { ...@@ -594,6 +610,9 @@ class _IncludesNodeWith extends Matcher {
textDirection: textDirection, textDirection: textDirection,
actions: actions, actions: actions,
flags: flags, flags: flags,
scrollPosition: scrollPosition,
scrollExtentMax: scrollExtentMax,
scrollExtentMin: scrollExtentMin,
).isNotEmpty; ).isNotEmpty;
} }
...@@ -619,6 +638,12 @@ class _IncludesNodeWith extends Matcher { ...@@ -619,6 +638,12 @@ class _IncludesNodeWith extends Matcher {
strings.add('actions "${actions.join(', ')}"'); strings.add('actions "${actions.join(', ')}"');
if (flags != null) if (flags != null)
strings.add('flags "${flags.join(', ')}"'); strings.add('flags "${flags.join(', ')}"');
if (scrollPosition != null)
strings.add('scrollPosition "$scrollPosition"');
if (scrollExtentMax != null)
strings.add('scrollExtentMax "$scrollExtentMax"');
if (scrollExtentMin != null)
strings.add('scrollExtentMin "$scrollExtentMin"');
return strings.join(', '); return strings.join(', ');
} }
} }
...@@ -633,6 +658,9 @@ Matcher includesNodeWith({ ...@@ -633,6 +658,9 @@ Matcher includesNodeWith({
TextDirection textDirection, TextDirection textDirection,
List<SemanticsAction> actions, List<SemanticsAction> actions,
List<SemanticsFlag> flags, List<SemanticsFlag> flags,
double scrollPosition,
double scrollExtentMax,
double scrollExtentMin,
}) { }) {
return new _IncludesNodeWith( return new _IncludesNodeWith(
label: label, label: label,
...@@ -640,5 +668,8 @@ Matcher includesNodeWith({ ...@@ -640,5 +668,8 @@ Matcher includesNodeWith({
textDirection: textDirection, textDirection: textDirection,
actions: actions, actions: actions,
flags: flags, flags: flags,
scrollPosition: scrollPosition,
scrollExtentMax: scrollExtentMax,
scrollExtentMin: scrollExtentMin,
); );
} }
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