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) {
assert(epsilon >= 0.0);
if (a == null || b == null)
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.
......
......@@ -2,7 +2,6 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
/// An event sent by the application to notify interested listeners that
......@@ -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.
///
/// This should be used for announcement that are not seamlessly announced by
......
......@@ -59,11 +59,15 @@ abstract class ScrollMetrics {
/// The minimum in-range value for [pixels].
///
/// The actual [pixels] value might be [outOfRange].
///
/// This value can be negative infinity, if the scroll is unbounded.
double get minScrollExtent;
/// The maximum in-range value for [pixels].
///
/// The actual [pixels] value might be [outOfRange].
///
/// This value can be infinity, if the scroll is unbounded.
double get maxScrollExtent;
/// The current scroll position, in logical pixels along the [axisDirection].
......
......@@ -275,7 +275,6 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin
final ScrollPosition oldPosition = position;
if (oldPosition != null) {
controller?.detach(oldPosition);
oldPosition.removeListener(_sendSemanticsScrollEvent);
// 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
// position. So, schedule a microtask to do it.
......@@ -284,30 +283,10 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin
_position = controller?.createScrollPosition(_physics, this, oldPosition)
?? new ScrollPositionWithSingleContext(physics: _physics, context: this, oldPosition: oldPosition);
_position.addListener(_sendSemanticsScrollEvent);
assert(position != null);
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
void didChangeDependencies() {
super.didChangeDependencies();
......@@ -529,6 +508,7 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin
result = new _ExcludableScrollSemantics(
key: _excludableScrollSemanticsKey,
child: result,
position: position,
);
}
......@@ -557,28 +537,61 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin
/// node, which is annotated with the scrolling actions, will house the
/// scrollable children.
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
_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 {
_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
void describeSemanticsConfiguration(SemanticsConfiguration config) {
super.describeSemanticsConfiguration(config);
config.isSemanticBoundary = true;
if (position.haveDimensions) {
config
..scrollPosition = _position.pixels
..scrollExtentMax = _position.maxScrollExtent
..scrollExtentMin = _position.minScrollExtent;
}
}
SemanticsNode _innerNode;
SemanticsNode _annotatedNode;
@override
void assembleSemanticsNode(SemanticsNode node, SemanticsConfiguration config, Iterable<SemanticsNode> children) {
if (children.isEmpty || !children.first.isTagged(RenderViewport.useTwoPaneSemantics)) {
_annotatedNode = node;
super.assembleSemanticsNode(node, config, children);
return;
}
......@@ -587,7 +600,6 @@ class _RenderExcludableScrollSemantics extends RenderProxyBox {
_innerNode
..isMergedIntoParent = node.isPartOfNodeMerging
..rect = Offset.zero & node.rect.size;
_annotatedNode = _innerNode;
final List<SemanticsNode> excluded = <SemanticsNode>[_innerNode];
final List<SemanticsNode> included = <SemanticsNode>[];
......@@ -606,12 +618,5 @@ class _RenderExcludableScrollSemantics extends RenderProxyBox {
void clearSemantics() {
super.clearSemantics();
_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() {
expect(
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()
......
......@@ -5,7 +5,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'semantics_tester.dart';
......@@ -204,12 +203,7 @@ void main() {
expect(tester.getTopLeft(find.byWidget(children[1])).dy, kToolbarHeight);
});
testWidgets('vertical scrolling sends ScrollCompletedSemanticsEvent', (WidgetTester tester) async {
final List<dynamic> messages = <dynamic>[];
SystemChannels.accessibility.setMockMessageHandler((dynamic message) {
messages.add(message);
});
testWidgets('correct scrollProgress', (WidgetTester tester) async {
semantics = new SemanticsTester(tester);
final List<Widget> textWidgets = <Widget>[];
......@@ -220,74 +214,84 @@ void main() {
child: new ListView(children: textWidgets),
));
await flingUp(tester);
expect(messages, isNot(hasLength(0)));
expect(messages.every((dynamic message) => message['type'] == 'scroll'), isTrue);
Map<Object, Object> message = messages.last['data'];
expect(message['axis'], 'v');
expect(message['pixels'], isPositive);
expect(message['minScrollExtent'], 0.0);
expect(message['maxScrollExtent'], 520.0);
expect(semantics, includesNodeWith(
scrollExtentMin: 0.0,
scrollPosition: 0.0,
scrollExtentMax: 520.0,
actions: <SemanticsAction>[
SemanticsAction.scrollUp,
],
));
messages.clear();
await flingDown(tester);
await flingUp(tester);
expect(messages, isNot(hasLength(0)));
expect(messages.every((dynamic message) => message['type'] == 'scroll'), isTrue);
expect(semantics, includesNodeWith(
scrollExtentMin: 0.0,
scrollPosition: 380.2,
scrollExtentMax: 520.0,
actions: <SemanticsAction>[
SemanticsAction.scrollUp,
SemanticsAction.scrollDown,
],
));
message = messages.last['data'];
expect(message['axis'], 'v');
expect(message['pixels'], isNonNegative);
expect(message['minScrollExtent'], 0.0);
expect(message['maxScrollExtent'], 520.0);
});
await flingUp(tester);
testWidgets('horizontal scrolling sends ScrollCompletedSemanticsEvent', (WidgetTester tester) async {
final List<dynamic> messages = <dynamic>[];
SystemChannels.accessibility.setMockMessageHandler((dynamic message) {
messages.add(message);
expect(semantics, includesNodeWith(
scrollExtentMin: 0.0,
scrollPosition: 520.0,
scrollExtentMax: 520.0,
actions: <SemanticsAction>[
SemanticsAction.scrollDown,
],
));
});
testWidgets('correct scrollProgress for unbound', (WidgetTester tester) async {
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(
textDirection: TextDirection.ltr,
child: new ListView(
children: children,
scrollDirection: Axis.horizontal,
child: new ListView.builder(
itemExtent: 20.0,
itemBuilder: (BuildContext context, int index) {
return new Text('entry $index');
},
),
));
await flingLeft(tester);
expect(messages, isNot(hasLength(0)));
expect(messages.every((dynamic message) => message['type'] == 'scroll'), isTrue);
expect(semantics, includesNodeWith(
scrollExtentMin: 0.0,
scrollPosition: 0.0,
scrollExtentMax: double.infinity,
actions: <SemanticsAction>[
SemanticsAction.scrollUp,
],
));
Map<Object, Object> message = messages.last['data'];
expect(message['axis'], 'h');
expect(message['pixels'], isPositive);
expect(message['minScrollExtent'], 0.0);
expect(message['maxScrollExtent'], 7200.0);
await flingUp(tester);
messages.clear();
await flingRight(tester);
expect(semantics, includesNodeWith(
scrollExtentMin: 0.0,
scrollPosition: 380.2,
scrollExtentMax: double.infinity,
actions: <SemanticsAction>[
SemanticsAction.scrollUp,
SemanticsAction.scrollDown,
],
));
expect(messages, isNot(hasLength(0)));
expect(messages.every((dynamic message) => message['type'] == 'scroll'), isTrue);
await flingUp(tester);
message = messages.last['data'];
expect(message['axis'], 'h');
expect(message['pixels'], isNonNegative);
expect(message['minScrollExtent'], 0.0);
expect(message['maxScrollExtent'], 7200.0);
expect(semantics, includesNodeWith(
scrollExtentMin: 0.0,
scrollPosition: 760.4,
scrollExtentMax: double.infinity,
actions: <SemanticsAction>[
SemanticsAction.scrollUp,
SemanticsAction.scrollDown,
],
));
});
testWidgets('Semantics tree is populated mid-scroll', (WidgetTester tester) async {
......
......@@ -5,6 +5,7 @@
import 'dart:ui' show SemanticsFlag;
import 'package:flutter/foundation.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meta/meta.dart';
......@@ -369,6 +370,9 @@ class SemanticsTester {
TextDirection textDirection,
List<SemanticsAction> actions,
List<SemanticsFlag> flags,
double scrollPosition,
double scrollExtentMax,
double scrollExtentMin,
SemanticsNode ancestor,
}) {
bool checkNode(SemanticsNode node) {
......@@ -390,6 +394,12 @@ class SemanticsTester {
if (expectedFlags != actualFlags)
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;
}
......@@ -578,13 +588,19 @@ class _IncludesNodeWith extends Matcher {
this.textDirection,
this.actions,
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 value;
final TextDirection textDirection;
final List<SemanticsAction> actions;
final List<SemanticsFlag> flags;
final double scrollPosition;
final double scrollExtentMax;
final double scrollExtentMin;
@override
bool matches(covariant SemanticsTester item, Map<dynamic, dynamic> matchState) {
......@@ -594,6 +610,9 @@ class _IncludesNodeWith extends Matcher {
textDirection: textDirection,
actions: actions,
flags: flags,
scrollPosition: scrollPosition,
scrollExtentMax: scrollExtentMax,
scrollExtentMin: scrollExtentMin,
).isNotEmpty;
}
......@@ -619,6 +638,12 @@ class _IncludesNodeWith extends Matcher {
strings.add('actions "${actions.join(', ')}"');
if (flags != null)
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(', ');
}
}
......@@ -633,6 +658,9 @@ Matcher includesNodeWith({
TextDirection textDirection,
List<SemanticsAction> actions,
List<SemanticsFlag> flags,
double scrollPosition,
double scrollExtentMax,
double scrollExtentMin,
}) {
return new _IncludesNodeWith(
label: label,
......@@ -640,5 +668,8 @@ Matcher includesNodeWith({
textDirection: textDirection,
actions: actions,
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