Commit 135a38d6 authored by Ian Hickson's avatar Ian Hickson

Merge pull request #2165 from Hixie/size-obs-3

SizeObserver crusade: ScrollableViewport and tabs
parents 1ce3146d f8080557
...@@ -699,10 +699,10 @@ class _TabBarState<T> extends ScrollableState<TabBar<T>> implements TabBarSelect ...@@ -699,10 +699,10 @@ class _TabBarState<T> extends ScrollableState<TabBar<T>> implements TabBarSelect
} }
void _updateScrollBehavior() { void _updateScrollBehavior() {
scrollBehavior.updateExtents( scrollTo(scrollBehavior.updateExtents(
containerExtent: config.scrollDirection == Axis.vertical ? _viewportSize.height : _viewportSize.width, containerExtent: config.scrollDirection == Axis.vertical ? _viewportSize.height : _viewportSize.width,
contentExtent: _tabWidths.reduce((double sum, double width) => sum + width) contentExtent: _tabWidths.reduce((double sum, double width) => sum + width)
); ));
} }
void _layoutChanged(Size tabBarSize, List<double> tabWidths) { void _layoutChanged(Size tabBarSize, List<double> tabWidths) {
...@@ -713,11 +713,16 @@ class _TabBarState<T> extends ScrollableState<TabBar<T>> implements TabBarSelect ...@@ -713,11 +713,16 @@ class _TabBarState<T> extends ScrollableState<TabBar<T>> implements TabBarSelect
}); });
} }
void _handleViewportSizeChanged(Size newSize) { Offset _handlePaintOffsetUpdateNeeded(ViewportDimensions dimensions) {
_viewportSize = newSize; // We make various state changes here but don't have to do so in a
// setState() callback because we are called during layout and all
// we're updating is the new offset, which we are providing to the
// render object via our return value.
_viewportSize = dimensions.containerSize;
_updateScrollBehavior(); _updateScrollBehavior();
if (config.isScrollable) if (config.isScrollable)
scrollTo(_centeredTabScrollOffset(_selection.index), duration: _kTabBarScroll); scrollTo(_centeredTabScrollOffset(_selection.index), duration: _kTabBarScroll);
return scrollOffsetToPixelDelta(scrollOffset);
} }
Widget buildContent(BuildContext context) { Widget buildContent(BuildContext context) {
...@@ -772,13 +777,11 @@ class _TabBarState<T> extends ScrollableState<TabBar<T>> implements TabBarSelect ...@@ -772,13 +777,11 @@ class _TabBarState<T> extends ScrollableState<TabBar<T>> implements TabBarSelect
); );
if (config.isScrollable) { if (config.isScrollable) {
contents = new SizeObserver( child: new Viewport(
onSizeChanged: _handleViewportSizeChanged, scrollDirection: Axis.horizontal,
child: new Viewport( paintOffset: scrollOffsetToPixelDelta(scrollOffset),
scrollDirection: Axis.horizontal, onPaintOffsetUpdateNeeded: _handlePaintOffsetUpdateNeeded,
paintOffset: scrollOffsetToPixelDelta(scrollOffset), child: contents
child: contents
)
); );
} }
......
...@@ -39,6 +39,20 @@ class ViewportDimensions { ...@@ -39,6 +39,20 @@ class ViewportDimensions {
return paintOffset + (containerSize - contentSize); return paintOffset + (containerSize - contentSize);
} }
} }
bool operator ==(dynamic other) {
if (identical(this, other))
return true;
if (other is! ViewportDimensions)
return false;
final ViewportDimensions typedOther = other;
return contentSize == typedOther.contentSize &&
containerSize == typedOther.containerSize;
}
int get hashCode => hashValues(contentSize, containerSize);
String toString() => 'ViewportDimensions(container: $containerSize, content: $contentSize)';
} }
abstract class HasScrollDirection { abstract class HasScrollDirection {
...@@ -163,6 +177,8 @@ class RenderViewportBase extends RenderBox implements HasScrollDirection { ...@@ -163,6 +177,8 @@ class RenderViewportBase extends RenderBox implements HasScrollDirection {
} }
typedef Offset ViewportDimensionsChangeCallback(ViewportDimensions dimensions);
/// A render object that's bigger on the inside. /// A render object that's bigger on the inside.
/// ///
/// The child of a viewport can layout to a larger size than the viewport /// The child of a viewport can layout to a larger size than the viewport
...@@ -176,11 +192,16 @@ class RenderViewport extends RenderViewportBase with RenderObjectWithChildMixin< ...@@ -176,11 +192,16 @@ class RenderViewport extends RenderViewportBase with RenderObjectWithChildMixin<
Offset paintOffset: Offset.zero, Offset paintOffset: Offset.zero,
Axis scrollDirection: Axis.vertical, Axis scrollDirection: Axis.vertical,
ViewportAnchor scrollAnchor: ViewportAnchor.start, ViewportAnchor scrollAnchor: ViewportAnchor.start,
Painter overlayPainter Painter overlayPainter,
this.onPaintOffsetUpdateNeeded
}) : super(paintOffset, scrollDirection, scrollAnchor, overlayPainter) { }) : super(paintOffset, scrollDirection, scrollAnchor, overlayPainter) {
this.child = child; this.child = child;
} }
/// Called during [layout] to report the dimensions of the viewport
/// and its child.
ViewportDimensionsChangeCallback onPaintOffsetUpdateNeeded;
BoxConstraints _getInnerConstraints(BoxConstraints constraints) { BoxConstraints _getInnerConstraints(BoxConstraints constraints) {
BoxConstraints innerConstraints; BoxConstraints innerConstraints;
switch (scrollDirection) { switch (scrollDirection) {
...@@ -228,6 +249,7 @@ class RenderViewport extends RenderViewportBase with RenderObjectWithChildMixin< ...@@ -228,6 +249,7 @@ class RenderViewport extends RenderViewportBase with RenderObjectWithChildMixin<
// parent was baseline-aligned, which makes no sense. // parent was baseline-aligned, which makes no sense.
void performLayout() { void performLayout() {
ViewportDimensions oldDimensions = dimensions;
if (child != null) { if (child != null) {
child.layout(_getInnerConstraints(constraints), parentUsesSize: true); child.layout(_getInnerConstraints(constraints), parentUsesSize: true);
size = constraints.constrain(child.size); size = constraints.constrain(child.size);
...@@ -238,6 +260,9 @@ class RenderViewport extends RenderViewportBase with RenderObjectWithChildMixin< ...@@ -238,6 +260,9 @@ class RenderViewport extends RenderViewportBase with RenderObjectWithChildMixin<
performResize(); performResize();
dimensions = new ViewportDimensions(containerSize: size); dimensions = new ViewportDimensions(containerSize: size);
} }
if (onPaintOffsetUpdateNeeded != null && dimensions != oldDimensions)
paintOffset = onPaintOffsetUpdateNeeded(dimensions);
assert(paintOffset != null);
} }
bool _shouldClipAtPaintOffset(Offset paintOffset) { bool _shouldClipAtPaintOffset(Offset paintOffset) {
......
...@@ -37,6 +37,8 @@ abstract class BindingBase { ...@@ -37,6 +37,8 @@ abstract class BindingBase {
void initInstances() { void initInstances() {
assert(() { _debugInitialized = true; return true; }); assert(() { _debugInitialized = true; return true; });
} }
String toString() => '<$runtimeType>';
} }
// A replacement for shell.connectToService. Implementations should return true // A replacement for shell.connectToService. Implementations should return true
......
...@@ -43,7 +43,9 @@ export 'package:flutter/rendering.dart' show ...@@ -43,7 +43,9 @@ export 'package:flutter/rendering.dart' show
RelativeRect, RelativeRect,
ShaderCallback, ShaderCallback,
ValueChanged, ValueChanged,
ViewportAnchor; ViewportAnchor,
ViewportDimensions,
ViewportDimensionsChangeCallback;
// PAINTING NODES // PAINTING NODES
...@@ -777,6 +779,7 @@ class Viewport extends OneChildRenderObjectWidget { ...@@ -777,6 +779,7 @@ class Viewport extends OneChildRenderObjectWidget {
this.scrollDirection: Axis.vertical, this.scrollDirection: Axis.vertical,
this.scrollAnchor: ViewportAnchor.start, this.scrollAnchor: ViewportAnchor.start,
this.overlayPainter, this.overlayPainter,
this.onPaintOffsetUpdateNeeded,
Widget child Widget child
}) : super(key: key, child: child) { }) : super(key: key, child: child) {
assert(scrollDirection != null); assert(scrollDirection != null);
...@@ -802,11 +805,14 @@ class Viewport extends OneChildRenderObjectWidget { ...@@ -802,11 +805,14 @@ class Viewport extends OneChildRenderObjectWidget {
/// Often used to paint scroll bars. /// Often used to paint scroll bars.
final Painter overlayPainter; final Painter overlayPainter;
final ViewportDimensionsChangeCallback onPaintOffsetUpdateNeeded;
RenderViewport createRenderObject() { RenderViewport createRenderObject() {
return new RenderViewport( return new RenderViewport(
paintOffset: paintOffset, paintOffset: paintOffset,
scrollDirection: scrollDirection, scrollDirection: scrollDirection,
scrollAnchor: scrollAnchor, scrollAnchor: scrollAnchor,
onPaintOffsetUpdateNeeded: onPaintOffsetUpdateNeeded,
overlayPainter: overlayPainter overlayPainter: overlayPainter
); );
} }
...@@ -817,6 +823,7 @@ class Viewport extends OneChildRenderObjectWidget { ...@@ -817,6 +823,7 @@ class Viewport extends OneChildRenderObjectWidget {
..scrollDirection = scrollDirection ..scrollDirection = scrollDirection
..scrollAnchor = scrollAnchor ..scrollAnchor = scrollAnchor
..paintOffset = paintOffset ..paintOffset = paintOffset
..onPaintOffsetUpdateNeeded = onPaintOffsetUpdateNeeded
..overlayPainter = overlayPainter; ..overlayPainter = overlayPainter;
} }
} }
......
...@@ -280,29 +280,29 @@ class RawGestureDetectorState extends State<RawGestureDetector> { ...@@ -280,29 +280,29 @@ class RawGestureDetectorState extends State<RawGestureDetector> {
void replaceGestureRecognizers(Map<Type, GestureRecognizerFactory> gestures) { void replaceGestureRecognizers(Map<Type, GestureRecognizerFactory> gestures) {
assert(() { assert(() {
RenderObject renderObject = context.findRenderObject(); RenderObject renderObject = context.findRenderObject();
assert(renderObject is RenderPointerListener);
RenderPointerListener listener = renderObject;
RenderBox descendant = listener.child;
if (!config.excludeFromSemantics) { if (!config.excludeFromSemantics) {
assert(descendant is RenderSemanticsGestureHandler); assert(renderObject is RenderSemanticsGestureHandler);
RenderSemanticsGestureHandler semanticsGestureHandler = descendant; RenderSemanticsGestureHandler semanticsGestureHandler = renderObject;
descendant = semanticsGestureHandler.child; renderObject = semanticsGestureHandler.child;
} }
assert(descendant != null); assert(renderObject is RenderPointerListener);
if (!descendant.debugDoingThisLayout) { RenderPointerListener pointerListener = renderObject;
renderObject = pointerListener.child;
if (!renderObject.debugDoingThisLayout) {
throw new WidgetError( throw new WidgetError(
'replaceGestureRecognizers() can only be called during the layout phase of the GestureDetector\'s nearest descendant RenderObjectWidget.\n' 'replaceGestureRecognizers() can only be called during the layout phase of the GestureDetector\'s nearest descendant RenderObjectWidget.\n'
'In this particular case, that is:\n' 'In this particular case, that is:\n'
' $descendant' ' $renderObject'
); );
} }
return true; return true;
}); });
_syncAll(gestures); _syncAll(gestures);
if (!config.excludeFromSemantics) { if (!config.excludeFromSemantics) {
RenderPointerListener listener = context.findRenderObject(); RenderSemanticsGestureHandler semanticsGestureHandler = context.findRenderObject();
RenderSemanticsGestureHandler semanticsGestureHandler = listener.child; context.visitChildElements((RenderObjectElement element) {
context.visitChildElements((RenderObjectElement element) => element.widget.updateRenderObject(semanticsGestureHandler, null)); element.widget.updateRenderObject(semanticsGestureHandler, null);
});
} }
} }
......
...@@ -10,7 +10,6 @@ import 'framework.dart'; ...@@ -10,7 +10,6 @@ import 'framework.dart';
import 'basic.dart'; import 'basic.dart';
typedef Widget IndexedBuilder(BuildContext context, int index); // return null if index is greater than index of last entry typedef Widget IndexedBuilder(BuildContext context, int index); // return null if index is greater than index of last entry
typedef void ExtentsUpdateCallback(double newExtents);
typedef void InvalidatorCallback(Iterable<int> indices); typedef void InvalidatorCallback(Iterable<int> indices);
typedef void InvalidatorAvailableCallback(InvalidatorCallback invalidator); typedef void InvalidatorAvailableCallback(InvalidatorCallback invalidator);
...@@ -23,7 +22,7 @@ class MixedViewport extends RenderObjectWidget { ...@@ -23,7 +22,7 @@ class MixedViewport extends RenderObjectWidget {
this.direction: Axis.vertical, this.direction: Axis.vertical,
this.builder, this.builder,
this.token, this.token,
this.onExtentsUpdate, this.onExtentChanged,
this.onInvalidatorAvailable this.onInvalidatorAvailable
}) : super(key: key); }) : super(key: key);
...@@ -31,7 +30,7 @@ class MixedViewport extends RenderObjectWidget { ...@@ -31,7 +30,7 @@ class MixedViewport extends RenderObjectWidget {
final Axis direction; final Axis direction;
final IndexedBuilder builder; final IndexedBuilder builder;
final Object token; // change this if the list changed (i.e. there are added, removed, or resorted items) final Object token; // change this if the list changed (i.e. there are added, removed, or resorted items)
final ExtentsUpdateCallback onExtentsUpdate; final ValueChanged<double> onExtentChanged;
final InvalidatorAvailableCallback onInvalidatorAvailable; // call the callback this gives to invalidate sizes final InvalidatorAvailableCallback onInvalidatorAvailable; // call the callback this gives to invalidate sizes
_MixedViewportElement createElement() => new _MixedViewportElement(this); _MixedViewportElement createElement() => new _MixedViewportElement(this);
...@@ -108,8 +107,8 @@ class _MixedViewportElement extends RenderObjectElement<MixedViewport> { ...@@ -108,8 +107,8 @@ class _MixedViewportElement extends RenderObjectElement<MixedViewport> {
/// The constraints for which the current offsets are valid. /// The constraints for which the current offsets are valid.
BoxConstraints _lastLayoutConstraints; BoxConstraints _lastLayoutConstraints;
/// The last value that was sent to onExtentsUpdate. /// The last value that was sent to onExtentChanged.
double _lastReportedExtents; double _lastReportedExtent;
RenderBlockViewport get renderObject => super.renderObject; RenderBlockViewport get renderObject => super.renderObject;
...@@ -227,11 +226,11 @@ class _MixedViewportElement extends RenderObjectElement<MixedViewport> { ...@@ -227,11 +226,11 @@ class _MixedViewportElement extends RenderObjectElement<MixedViewport> {
BuildableElement.lockState(() { BuildableElement.lockState(() {
_doLayout(constraints); _doLayout(constraints);
}, building: true); }, building: true);
if (widget.onExtentsUpdate != null) { if (widget.onExtentChanged != null) {
final double newExtents = _didReachLastChild ? _childOffsets.last : null; final double newExtent = _didReachLastChild ? _childOffsets.last : null;
if (newExtents != _lastReportedExtents) { if (newExtent != _lastReportedExtent) {
_lastReportedExtents = newExtents; _lastReportedExtent = newExtent;
widget.onExtentsUpdate(_lastReportedExtents); widget.onExtentChanged(_lastReportedExtent);
} }
} }
} }
......
...@@ -35,6 +35,15 @@ abstract class ScrollBehavior<T, U> { ...@@ -35,6 +35,15 @@ abstract class ScrollBehavior<T, U> {
/// Whether this scroll behavior currently permits scrolling /// Whether this scroll behavior currently permits scrolling
bool get isScrollable => true; bool get isScrollable => true;
String toString() {
List<String> description = <String>[];
debugFillDescription(description);
return '$runtimeType(${description.join("; ")})';
}
void debugFillDescription(List<String> description) {
description.add(isScrollable ? 'scrollable' : 'not scrollable');
}
} }
/// A scroll behavior for a scrollable widget with linear extent (i.e. /// A scroll behavior for a scrollable widget with linear extent (i.e.
...@@ -74,6 +83,13 @@ abstract class ExtentScrollBehavior extends ScrollBehavior<double, double> { ...@@ -74,6 +83,13 @@ abstract class ExtentScrollBehavior extends ScrollBehavior<double, double> {
/// The maximum value the scroll offset can obtain. /// The maximum value the scroll offset can obtain.
double get maxScrollOffset; double get maxScrollOffset;
void debugFillDescription(List<String> description) {
super.debugFillDescription(description);
description.add('content: ${contentExtent.toStringAsFixed(1)}');
description.add('container: ${contentExtent.toStringAsFixed(1)}');
description.add('range: ${minScrollOffset?.toStringAsFixed(1)} .. ${maxScrollOffset?.toStringAsFixed(1)}');
}
} }
/// A scroll behavior that prevents the user from exceeding scroll bounds. /// A scroll behavior that prevents the user from exceeding scroll bounds.
......
...@@ -237,32 +237,42 @@ abstract class ScrollableState<T extends Scrollable> extends State<T> { ...@@ -237,32 +237,42 @@ abstract class ScrollableState<T extends Scrollable> extends State<T> {
return _scrollBehavior; return _scrollBehavior;
} }
GestureDragStartCallback _getDragStartHandler(Axis direction) { Map<Type, GestureRecognizerFactory> buildGestureDetectors() {
if (config.scrollDirection != direction || !scrollBehavior.isScrollable) if (scrollBehavior.isScrollable) {
return null; switch (config.scrollDirection) {
return _handleDragStart; case Axis.vertical:
return <Type, GestureRecognizerFactory>{
VerticalDragGestureRecognizer: (VerticalDragGestureRecognizer recognizer) {
return (recognizer ??= new VerticalDragGestureRecognizer())
..onStart = _handleDragStart
..onUpdate = _handleDragUpdate
..onEnd = _handleDragEnd;
}
};
case Axis.horizontal:
return <Type, GestureRecognizerFactory>{
HorizontalDragGestureRecognizer: (HorizontalDragGestureRecognizer recognizer) {
return (recognizer ??= new HorizontalDragGestureRecognizer())
..onStart = _handleDragStart
..onUpdate = _handleDragUpdate
..onEnd = _handleDragEnd;
}
};
}
}
return const <Type, GestureRecognizerFactory>{};
} }
GestureDragUpdateCallback _getDragUpdateHandler(Axis direction) { final GlobalKey _gestureDetectorKey = new GlobalKey();
if (config.scrollDirection != direction || !scrollBehavior.isScrollable)
return null;
return _handleDragUpdate;
}
GestureDragEndCallback _getDragEndHandler(Axis direction) { void updateGestureDetector() {
if (config.scrollDirection != direction || !scrollBehavior.isScrollable) _gestureDetectorKey.currentState.replaceGestureRecognizers(buildGestureDetectors());
return null;
return _handleDragEnd;
} }
Widget build(BuildContext context) { Widget build(BuildContext context) {
return new GestureDetector( return new RawGestureDetector(
onVerticalDragStart: _getDragStartHandler(Axis.vertical), key: _gestureDetectorKey,
onVerticalDragUpdate: _getDragUpdateHandler(Axis.vertical), gestures: buildGestureDetectors(),
onVerticalDragEnd: _getDragEndHandler(Axis.vertical),
onHorizontalDragStart: _getDragStartHandler(Axis.horizontal),
onHorizontalDragUpdate: _getDragUpdateHandler(Axis.horizontal),
onHorizontalDragEnd: _getDragEndHandler(Axis.horizontal),
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
child: new Listener( child: new Listener(
child: buildContent(context), child: buildContent(context),
...@@ -321,7 +331,7 @@ abstract class ScrollableState<T extends Scrollable> extends State<T> { ...@@ -321,7 +331,7 @@ abstract class ScrollableState<T extends Scrollable> extends State<T> {
if (endScrollOffset.isNaN) if (endScrollOffset.isNaN)
return null; return null;
final double snappedScrollOffset = snapScrollOffset(endScrollOffset); final double snappedScrollOffset = snapScrollOffset(endScrollOffset); // invokes the config.snapOffsetCallback callback
if (!_scrollOffsetIsInBounds(snappedScrollOffset)) if (!_scrollOffsetIsInBounds(snappedScrollOffset))
return null; return null;
...@@ -443,7 +453,7 @@ abstract class ScrollableState<T extends Scrollable> extends State<T> { ...@@ -443,7 +453,7 @@ abstract class ScrollableState<T extends Scrollable> extends State<T> {
} }
void _handleDragStart(_) { void _handleDragStart(_) {
scheduleMicrotask(dispatchOnScrollStart); dispatchOnScrollStart();
} }
void _handleDragUpdate(double delta) { void _handleDragUpdate(double delta) {
...@@ -503,18 +513,19 @@ class _ScrollableViewportState extends ScrollableState<ScrollableViewport> { ...@@ -503,18 +513,19 @@ class _ScrollableViewportState extends ScrollableState<ScrollableViewport> {
double _viewportSize = 0.0; double _viewportSize = 0.0;
double _childSize = 0.0; double _childSize = 0.0;
void _handleViewportSizeChanged(Size newSize) {
_viewportSize = config.scrollDirection == Axis.vertical ? newSize.height : newSize.width; Offset _handlePaintOffsetUpdateNeeded(ViewportDimensions dimensions) {
setState(() { // We make various state changes here but don't have to do so in a
_updateScrollBehavior(); // setState() callback because we are called during layout and all
}); // we're updating is the new offset, which we are providing to the
} // render object via our return value.
void _handleChildSizeChanged(Size newSize) { _viewportSize = config.scrollDirection == Axis.vertical ? dimensions.containerSize.height : dimensions.containerSize.width;
_childSize = config.scrollDirection == Axis.vertical ? newSize.height : newSize.width; _childSize = config.scrollDirection == Axis.vertical ? dimensions.contentSize.height : dimensions.contentSize.width;
setState(() { _updateScrollBehavior();
_updateScrollBehavior(); updateGestureDetector();
}); return scrollOffsetToPixelDelta(scrollOffset);
} }
void _updateScrollBehavior() { void _updateScrollBehavior() {
// if you don't call this from build(), you must call it from setState(). // if you don't call this from build(), you must call it from setState().
scrollTo(scrollBehavior.updateExtents( scrollTo(scrollBehavior.updateExtents(
...@@ -525,17 +536,12 @@ class _ScrollableViewportState extends ScrollableState<ScrollableViewport> { ...@@ -525,17 +536,12 @@ class _ScrollableViewportState extends ScrollableState<ScrollableViewport> {
} }
Widget buildContent(BuildContext context) { Widget buildContent(BuildContext context) {
return new SizeObserver( return new Viewport(
onSizeChanged: _handleViewportSizeChanged, paintOffset: scrollOffsetToPixelDelta(scrollOffset),
child: new Viewport( scrollDirection: config.scrollDirection,
paintOffset: scrollOffsetToPixelDelta(scrollOffset), scrollAnchor: config.scrollAnchor,
scrollDirection: config.scrollDirection, onPaintOffsetUpdateNeeded: _handlePaintOffsetUpdateNeeded,
scrollAnchor: config.scrollAnchor, child: config.child
child: new SizeObserver(
onSizeChanged: _handleChildSizeChanged,
child: config.child
)
)
); );
} }
} }
...@@ -690,11 +696,11 @@ class ScrollableMixedWidgetListState extends ScrollableState<ScrollableMixedWidg ...@@ -690,11 +696,11 @@ class ScrollableMixedWidgetListState extends ScrollableState<ScrollableMixedWidg
} }
} }
void _handleExtentsUpdate(double newExtents) { void _handleExtentChanged(double newExtent) {
double newScrollOffset; double newScrollOffset;
setState(() { setState(() {
newScrollOffset = scrollBehavior.updateExtents( newScrollOffset = scrollBehavior.updateExtents(
contentExtent: newExtents ?? double.INFINITY, contentExtent: newExtent ?? double.INFINITY,
scrollOffset: scrollOffset scrollOffset: scrollOffset
); );
}); });
...@@ -712,7 +718,7 @@ class ScrollableMixedWidgetListState extends ScrollableState<ScrollableMixedWidg ...@@ -712,7 +718,7 @@ class ScrollableMixedWidgetListState extends ScrollableState<ScrollableMixedWidg
builder: config.builder, builder: config.builder,
token: config.token, token: config.token,
onInvalidatorAvailable: config.onInvalidatorAvailable, onInvalidatorAvailable: config.onInvalidatorAvailable,
onExtentsUpdate: _handleExtentsUpdate onExtentChanged: _handleExtentChanged
) )
); );
} }
......
...@@ -50,16 +50,18 @@ void main() { ...@@ -50,16 +50,18 @@ void main() {
] ]
) )
); );
tester.pump(); // for SizeObservers
Point middleOfContainer = tester.getCenter(tester.findText('Hello')); Point middleOfContainer = tester.getCenter(tester.findText('Hello'));
expect(middleOfContainer.x, equals(400.0));
expect(middleOfContainer.y, equals(1000.0));
Point target = tester.getCenter(tester.findElementByKey(blockKey)); Point target = tester.getCenter(tester.findElementByKey(blockKey));
TestGesture gesture = tester.startGesture(target); TestGesture gesture = tester.startGesture(target);
gesture.moveBy(const Offset(0.0, -10.0)); gesture.moveBy(const Offset(0.0, -10.0));
tester.pump(const Duration(milliseconds: 1)); tester.pump(); // redo layout
expect(tester.getCenter(tester.findText('Hello')) == middleOfContainer, isFalse); expect(tester.getCenter(tester.findText('Hello')), isNot(equals(middleOfContainer)));
gesture.up(); gesture.up();
}); });
......
...@@ -19,6 +19,8 @@ class Instrumentation { ...@@ -19,6 +19,8 @@ class Instrumentation {
final WidgetFlutterBinding binding; final WidgetFlutterBinding binding;
/// Returns a list of all the [Layer] objects in the rendering.
List<Layer> get layers => _layers(binding.renderView.layer);
// TODO(ianh): This should not be O(N) hidden behind a getter! // TODO(ianh): This should not be O(N) hidden behind a getter!
List<Layer> _layers(Layer layer) { List<Layer> _layers(Layer layer) {
List<Layer> result = <Layer>[layer]; List<Layer> result = <Layer>[layer];
...@@ -32,9 +34,9 @@ class Instrumentation { ...@@ -32,9 +34,9 @@ class Instrumentation {
} }
return result; return result;
} }
List<Layer> get layers => _layers(binding.renderView.layer);
/// Walks all the elements in the tree, in depth-first pre-order,
/// calling the given function for each one.
void walkElements(ElementVisitor visitor) { void walkElements(ElementVisitor visitor) {
void walk(Element element) { void walk(Element element) {
visitor(element); visitor(element);
...@@ -43,6 +45,9 @@ class Instrumentation { ...@@ -43,6 +45,9 @@ class Instrumentation {
binding.renderViewElement.visitChildren(walk); binding.renderViewElement.visitChildren(walk);
} }
/// Returns the first element that for which the given predicate
/// function returns true, if any, or null if the predicate function
/// never returns true.
Element findElement(bool predicate(Element element)) { Element findElement(bool predicate(Element element)) {
try { try {
walkElements((Element element) { walkElements((Element element) {
...@@ -55,16 +60,24 @@ class Instrumentation { ...@@ -55,16 +60,24 @@ class Instrumentation {
return null; return null;
} }
/// Returns the first element that corresponds to a widget with the
/// given [Key], or null if there is no such element.
Element findElementByKey(Key key) { Element findElementByKey(Key key) {
return findElement((Element element) => element.widget.key == key); return findElement((Element element) => element.widget.key == key);
} }
/// Returns the first element that corresponds to a [Text] widget
/// whose data is the given string, or null if there is no such
/// element.
Element findText(String text) { Element findText(String text) {
return findElement((Element element) { return findElement((Element element) {
return element.widget is Text && element.widget.data == text; return element.widget is Text && element.widget.data == text;
}); });
} }
/// Returns the [State] object of the first element whose state has
/// the given [runtimeType], if any. Returns null if there is no
/// matching element.
State findStateOfType(Type type) { State findStateOfType(Type type) {
StatefulComponentElement element = findElement((Element element) { StatefulComponentElement element = findElement((Element element) {
return element is StatefulComponentElement && element.state.runtimeType == type; return element is StatefulComponentElement && element.state.runtimeType == type;
...@@ -72,6 +85,10 @@ class Instrumentation { ...@@ -72,6 +85,10 @@ class Instrumentation {
return element?.state; return element?.state;
} }
/// Returns the [State] object of the first element whose
/// configuration is the given widget, if any. Returns null if the
/// given configuration is not that of a stateful widget or if there
/// is no matching element.
State findStateByConfig(Widget config) { State findStateByConfig(Widget config) {
StatefulComponentElement element = findElement((Element element) { StatefulComponentElement element = findElement((Element element) {
return element is StatefulComponentElement && element.state.config == config; return element is StatefulComponentElement && element.state.config == config;
...@@ -79,26 +96,36 @@ class Instrumentation { ...@@ -79,26 +96,36 @@ class Instrumentation {
return element?.state; return element?.state;
} }
/// Returns the point at the center of the given element.
Point getCenter(Element element) { Point getCenter(Element element) {
return _getElementPoint(element, (Size size) => size.center(Point.origin)); return _getElementPoint(element, (Size size) => size.center(Point.origin));
} }
/// Returns the point at the top left of the given element.
Point getTopLeft(Element element) { Point getTopLeft(Element element) {
return _getElementPoint(element, (_) => Point.origin); return _getElementPoint(element, (_) => Point.origin);
} }
/// Returns the point at the top right of the given element. This
/// point is not inside the object's hit test area.
Point getTopRight(Element element) { Point getTopRight(Element element) {
return _getElementPoint(element, (Size size) => size.topRight(Point.origin)); return _getElementPoint(element, (Size size) => size.topRight(Point.origin));
} }
/// Returns the point at the bottom left of the given element. This
/// point is not inside the object's hit test area.
Point getBottomLeft(Element element) { Point getBottomLeft(Element element) {
return _getElementPoint(element, (Size size) => size.bottomLeft(Point.origin)); return _getElementPoint(element, (Size size) => size.bottomLeft(Point.origin));
} }
/// Returns the point at the bottom right of the given element. This
/// point is not inside the object's hit test area.
Point getBottomRight(Element element) { Point getBottomRight(Element element) {
return _getElementPoint(element, (Size size) => size.bottomRight(Point.origin)); return _getElementPoint(element, (Size size) => size.bottomRight(Point.origin));
} }
/// Returns the size of the given element. This is only valid once
/// the element's render object has been laid out at least once.
Size getSize(Element element) { Size getSize(Element element) {
assert(element != null); assert(element != null);
RenderBox box = element.renderObject as RenderBox; RenderBox box = element.renderObject as RenderBox;
...@@ -113,22 +140,34 @@ class Instrumentation { ...@@ -113,22 +140,34 @@ class Instrumentation {
return box.localToGlobal(sizeToPoint(box.size)); return box.localToGlobal(sizeToPoint(box.size));
} }
/// Dispatch a pointer down / pointer up sequence at the center of
/// the given element, assuming it is exposed. If the center of the
/// element is not exposed, this might send events to another
/// object.
void tap(Element element, { int pointer: 1 }) { void tap(Element element, { int pointer: 1 }) {
tapAt(getCenter(element), pointer: pointer); tapAt(getCenter(element), pointer: pointer);
} }
/// Dispatch a pointer down / pointer up sequence at the given
/// location.
void tapAt(Point location, { int pointer: 1 }) { void tapAt(Point location, { int pointer: 1 }) {
HitTestResult result = _hitTest(location); HitTestResult result = _hitTest(location);
TestPointer p = new TestPointer(pointer); TestPointer p = new TestPointer(pointer);
_dispatchEvent(p.down(location), result); dispatchEvent(p.down(location), result);
_dispatchEvent(p.up(), result); dispatchEvent(p.up(), result);
} }
/// Attempts a fling gesture starting from the center of the given
/// element, moving the given distance, reaching the given velocity.
///
/// If the middle of the element is not exposed, this might send
/// events to another object.
void fling(Element element, Offset offset, double velocity, { int pointer: 1 }) { void fling(Element element, Offset offset, double velocity, { int pointer: 1 }) {
flingFrom(getCenter(element), offset, velocity, pointer: pointer); flingFrom(getCenter(element), offset, velocity, pointer: pointer);
} }
/// Attempts a fling gesture starting from the given location,
/// moving the given distance, reaching the given velocity.
void flingFrom(Point startLocation, Offset offset, double velocity, { int pointer: 1 }) { void flingFrom(Point startLocation, Offset offset, double velocity, { int pointer: 1 }) {
assert(offset.distance > 0.0); assert(offset.distance > 0.0);
assert(velocity != 0.0); // velocity is pixels/second assert(velocity != 0.0); // velocity is pixels/second
...@@ -137,53 +176,65 @@ class Instrumentation { ...@@ -137,53 +176,65 @@ class Instrumentation {
const int kMoveCount = 50; // Needs to be >= kHistorySize, see _LeastSquaresVelocityTrackerStrategy const int kMoveCount = 50; // Needs to be >= kHistorySize, see _LeastSquaresVelocityTrackerStrategy
final double timeStampDelta = 1000.0 * offset.distance / (kMoveCount * velocity); final double timeStampDelta = 1000.0 * offset.distance / (kMoveCount * velocity);
double timeStamp = 0.0; double timeStamp = 0.0;
_dispatchEvent(p.down(startLocation, timeStamp: new Duration(milliseconds: timeStamp.round())), result); dispatchEvent(p.down(startLocation, timeStamp: new Duration(milliseconds: timeStamp.round())), result);
for(int i = 0; i < kMoveCount; i++) { for(int i = 0; i < kMoveCount; i++) {
final Point location = startLocation + Offset.lerp(Offset.zero, offset, i / kMoveCount); final Point location = startLocation + Offset.lerp(Offset.zero, offset, i / kMoveCount);
_dispatchEvent(p.move(location, timeStamp: new Duration(milliseconds: timeStamp.round())), result); dispatchEvent(p.move(location, timeStamp: new Duration(milliseconds: timeStamp.round())), result);
timeStamp += timeStampDelta; timeStamp += timeStampDelta;
} }
_dispatchEvent(p.up(timeStamp: new Duration(milliseconds: timeStamp.round())), result); dispatchEvent(p.up(timeStamp: new Duration(milliseconds: timeStamp.round())), result);
} }
/// Attempts to drag the given element by the given offset, by
/// starting a drag in the middle of the element.
///
/// If the middle of the element is not exposed, this might send
/// events to another object.
void scroll(Element element, Offset offset, { int pointer: 1 }) { void scroll(Element element, Offset offset, { int pointer: 1 }) {
scrollAt(getCenter(element), offset, pointer: pointer); scrollAt(getCenter(element), offset, pointer: pointer);
} }
/// Attempts a drag gesture consisting of a pointer down, a move by
/// the given offset, and a pointer up.
void scrollAt(Point startLocation, Offset offset, { int pointer: 1 }) { void scrollAt(Point startLocation, Offset offset, { int pointer: 1 }) {
Point endLocation = startLocation + offset; Point endLocation = startLocation + offset;
TestPointer p = new TestPointer(pointer); TestPointer p = new TestPointer(pointer);
// Events for the entire press-drag-release gesture are dispatched // Events for the entire press-drag-release gesture are dispatched
// to the widgets "hit" by the pointer down event. // to the widgets "hit" by the pointer down event.
HitTestResult result = _hitTest(startLocation); HitTestResult result = _hitTest(startLocation);
_dispatchEvent(p.down(startLocation), result); dispatchEvent(p.down(startLocation), result);
_dispatchEvent(p.move(endLocation), result); dispatchEvent(p.move(endLocation), result);
_dispatchEvent(p.up(), result); dispatchEvent(p.up(), result);
} }
/// Begins a gesture at a particular point, and returns the
/// [TestGesture] object which you can use to continue the gesture.
TestGesture startGesture(Point downLocation, { int pointer: 1 }) { TestGesture startGesture(Point downLocation, { int pointer: 1 }) {
TestPointer p = new TestPointer(pointer); TestPointer p = new TestPointer(pointer);
HitTestResult result = _hitTest(downLocation); HitTestResult result = _hitTest(downLocation);
_dispatchEvent(p.down(downLocation), result); dispatchEvent(p.down(downLocation), result);
return new TestGesture._(this, result, p); return new TestGesture._(this, result, p);
} }
@Deprecated('soon. Use startGesture instead.')
void dispatchEvent(PointerEvent event, Point location) {
_dispatchEvent(event, _hitTest(location));
}
HitTestResult _hitTest(Point location) { HitTestResult _hitTest(Point location) {
HitTestResult result = new HitTestResult(); HitTestResult result = new HitTestResult();
binding.hitTest(result, location); binding.hitTest(result, location);
return result; return result;
} }
void _dispatchEvent(PointerEvent event, HitTestResult result) { /// Sends a [PointerEvent] at a particular [HitTestResult].
///
/// Generally speaking, it is preferred to use one of the more
/// semantically meaningful ways to dispatch events in tests, in
/// particular: [tap], [tapAt], [fling], [flingFrom], [scroll],
/// [scrollAt], or [startGesture].
void dispatchEvent(PointerEvent event, HitTestResult result) {
binding.dispatchEvent(event, result); binding.dispatchEvent(event, result);
} }
} }
/// A class for performing gestures in tests. To create a
/// [TestGesture], call [WidgetTester.startGesture].
class TestGesture { class TestGesture {
TestGesture._(this._target, this._result, this.pointer); TestGesture._(this._target, this._result, this.pointer);
...@@ -192,25 +243,31 @@ class TestGesture { ...@@ -192,25 +243,31 @@ class TestGesture {
final TestPointer pointer; final TestPointer pointer;
bool _isDown = true; bool _isDown = true;
/// Send a move event moving the pointer to the given location.
void moveTo(Point location) { void moveTo(Point location) {
assert(_isDown); assert(_isDown);
_target._dispatchEvent(pointer.move(location), _result); _target.dispatchEvent(pointer.move(location), _result);
} }
/// Send a move event moving the pointer by the given offset.
void moveBy(Offset offset) { void moveBy(Offset offset) {
assert(_isDown); assert(_isDown);
moveTo(pointer.location + offset); moveTo(pointer.location + offset);
} }
/// End the gesture by releasing the pointer.
void up() { void up() {
assert(_isDown); assert(_isDown);
_isDown = false; _isDown = false;
_target._dispatchEvent(pointer.up(), _result); _target.dispatchEvent(pointer.up(), _result);
} }
/// End the gesture by canceling the pointer (as would happen if the
/// system showed a modal dialog on top of the Flutter application,
/// for instance).
void cancel() { void cancel() {
assert(_isDown); assert(_isDown);
_isDown = false; _isDown = false;
_target._dispatchEvent(pointer.cancel(), _result); _target.dispatchEvent(pointer.cancel(), _result);
} }
} }
...@@ -6,6 +6,7 @@ import 'dart:ui' as ui show window; ...@@ -6,6 +6,7 @@ import 'dart:ui' as ui show window;
import 'package:quiver/testing/async.dart'; import 'package:quiver/testing/async.dart';
import 'package:quiver/time.dart'; import 'package:quiver/time.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
...@@ -57,6 +58,11 @@ class WidgetTester extends Instrumentation { ...@@ -57,6 +58,11 @@ class WidgetTester extends Instrumentation {
); );
async.flushMicrotasks(); async.flushMicrotasks();
} }
void dispatchEvent(PointerEvent event, HitTestResult result) {
super.dispatchEvent(event, result);
async.flushMicrotasks();
}
} }
void testWidgets(callback(WidgetTester tester)) { void testWidgets(callback(WidgetTester tester)) {
......
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