Commit 820137b7 authored by Hixie's avatar Hixie

Remove the inner SizeObserver from ScrollableWidgetList.

Adds a HomogeneousViewport class that works like MixedViewport but
handles only children that have all the same height.

Converts ScrollableWidgetList to use that, so that we don't waste a
frame looking at the size of the contents each time we change size.

This allows a number of seemingly pointless double-pumps in the tests
to be removed.

Other changes that were necessary to support the above:

 - RenderBlock now supports minExtent (think 'min-height' in CSS)
 - RenderBlock now supports itemExtent (forces the height of each
   child to be the same, so that the itemExtent passed to the fixed-
   height scrollables are all authoritative instead of a source of
   bugs when they don't match)
 - RenderBlockViewport now supports horizontal scrolling
 - improved the style of the isInfinite assert in box.dart
 - fixed the position of a comment in mixed_viewport.dart
 - added a test
 - made the logic for how many items to show be more precise
parent 916e4fab
......@@ -23,8 +23,10 @@ abstract class RenderBlockBase extends RenderBox with ContainerRenderObjectMixin
RenderBlockBase({
List<RenderBox> children,
BlockDirection direction: BlockDirection.vertical
}) : _direction = direction {
BlockDirection direction: BlockDirection.vertical,
double itemExtent,
double minExtent: 0.0
}) : _direction = direction, _itemExtent = itemExtent, _minExtent = minExtent {
addAll(children);
}
......@@ -42,22 +44,42 @@ abstract class RenderBlockBase extends RenderBox with ContainerRenderObjectMixin
}
}
double _itemExtent;
double get itemExtent => _itemExtent;
void set itemExtent(double value) {
if (value != _itemExtent) {
_itemExtent = value;
markNeedsLayout();
}
}
double _minExtent;
double get minExtent => _minExtent;
void set minExtent(double value) {
if (value != _minExtent) {
_minExtent = value;
markNeedsLayout();
}
}
bool get isVertical => _direction == BlockDirection.vertical;
BoxConstraints _getInnerConstraints(BoxConstraints constraints) {
if (isVertical)
return new BoxConstraints.tightFor(width: constraints.constrainWidth(constraints.maxWidth));
return new BoxConstraints.tightFor(height: constraints.constrainHeight(constraints.maxHeight));
return new BoxConstraints.tightFor(width: constraints.constrainWidth(constraints.maxWidth),
height: itemExtent);
return new BoxConstraints.tightFor(height: constraints.constrainHeight(constraints.maxHeight),
width: itemExtent);
}
double get _mainAxisExtent {
RenderBox child = lastChild;
if (child == null)
return 0.0;
return minExtent;
BoxParentData parentData = child.parentData;
return isVertical ?
parentData.position.y + child.size.height :
parentData.position.x + child.size.width;
math.max(minExtent, parentData.position.y + child.size.height) :
math.max(minExtent, parentData.position.x + child.size.width);
}
void performLayout() {
......@@ -84,8 +106,10 @@ class RenderBlock extends RenderBlockBase {
RenderBlock({
List<RenderBox> children,
BlockDirection direction: BlockDirection.vertical
}) : super(children: children, direction: direction);
BlockDirection direction: BlockDirection.vertical,
double itemExtent,
double minExtent: 0.0
}) : super(children: children, direction: direction, itemExtent: itemExtent, minExtent: minExtent);
double _getIntrinsicCrossAxis(BoxConstraints constraints, _ChildSizingFunction childSize) {
double extent = 0.0;
......@@ -116,7 +140,7 @@ class RenderBlock extends RenderBlockBase {
assert(child.parentData is BlockParentData);
child = child.parentData.nextSibling;
}
return extent;
return math.max(extent, minExtent);
}
double getMinIntrinsicWidth(BoxConstraints constraints) {
......@@ -185,15 +209,17 @@ class RenderBlockViewport extends RenderBlockBase {
DimensionCallback totalExtentCallback,
DimensionCallback maxCrossAxisDimensionCallback,
DimensionCallback minCrossAxisDimensionCallback,
BlockDirection direction: BlockDirection.vertical,
double itemExtent,
double minExtent: 0.0,
double startOffset: 0.0,
List<RenderBox> children,
BlockDirection direction: BlockDirection.vertical
List<RenderBox> children
}) : _callback = callback,
_totalExtentCallback = totalExtentCallback,
_maxCrossAxisDimensionCallback = maxCrossAxisDimensionCallback,
_minCrossAxisDimensionCallback = minCrossAxisDimensionCallback,
_startOffset = startOffset,
super(children: children, direction: direction);
super(children: children, direction: direction, itemExtent: itemExtent, minExtent: minExtent);
bool _inCallback = false;
......@@ -250,11 +276,10 @@ class RenderBlockViewport extends RenderBlockBase {
double _startOffset;
double get startOffset => _startOffset;
void set startOffset(double value) {
if (value == _startOffset)
return;
_startOffset = value;
if (!_inCallback)
if (value != _startOffset) {
_startOffset = value;
markNeedsPaint();
}
}
double _getIntrinsicDimension(BoxConstraints constraints, DimensionCallback intrinsicCallback, _Constrainer constrainer) {
......@@ -283,13 +308,13 @@ class RenderBlockViewport extends RenderBlockBase {
double getMinIntrinsicWidth(BoxConstraints constraints) {
if (isVertical)
return _getIntrinsicDimension(constraints, minCrossAxisDimensionCallback, constraints.constrainWidth);
return constraints.constrainWidth(0.0);
return constraints.constrainWidth(minExtent);
}
double getMaxIntrinsicWidth(BoxConstraints constraints) {
if (isVertical)
return _getIntrinsicDimension(constraints, maxCrossAxisDimensionCallback, constraints.constrainWidth);
return _getIntrinsicDimension(constraints, totalExtentCallback, constraints.constrainWidth);
return _getIntrinsicDimension(constraints, totalExtentCallback, new BoxConstraints(minWidth: minExtent).apply(constraints).constrainWidth);
}
double getMinIntrinsicHeight(BoxConstraints constraints) {
......@@ -301,7 +326,7 @@ class RenderBlockViewport extends RenderBlockBase {
double getMaxIntrinsicHeight(BoxConstraints constraints) {
if (!isVertical)
return _getIntrinsicDimension(constraints, maxCrossAxisDimensionCallback, constraints.constrainHeight);
return _getIntrinsicDimension(constraints, totalExtentCallback, constraints.constrainHeight);
return _getIntrinsicDimension(constraints, totalExtentCallback, new BoxConstraints(minHeight: minExtent).apply(constraints).constrainHeight);
}
// We don't override computeDistanceToActualBaseline(), because we
......@@ -325,7 +350,10 @@ class RenderBlockViewport extends RenderBlockBase {
void paint(PaintingContext context, Offset offset) {
context.canvas.save();
context.canvas.clipRect(offset & size);
defaultPaint(context, offset.translate(0.0, startOffset));
if (isVertical)
defaultPaint(context, offset.translate(0.0, startOffset));
else
defaultPaint(context, offset.translate(startOffset, 0.0));
context.canvas.restore();
}
......
......@@ -319,7 +319,10 @@ abstract class RenderBox extends RenderObject {
bool debugDoesMeetConstraints() {
assert(constraints != null);
assert(_size != null);
assert(!_size.isInfinite && 'See https://github.com/domokit/sky_engine/blob/master/sky/packages/sky/lib/widgets/sizing.md#user-content-unbounded-constraints' is String);
assert(() {
'See https://github.com/domokit/sky_engine/blob/master/sky/packages/sky/lib/widgets/sizing.md#user-content-unbounded-constraints';
return !_size.isInfinite;
});
bool result = constraints.contains(_size);
if (!result)
print("${this.runtimeType} does not meet its constraints. Constraints: $constraints, size: $_size");
......
......@@ -24,6 +24,7 @@ export 'package:sky/widgets/floating_action_button.dart';
export 'package:sky/widgets/focus.dart';
export 'package:sky/widgets/framework.dart';
export 'package:sky/widgets/gesture_detector.dart';
export 'package:sky/widgets/homogeneous_viewport.dart';
export 'package:sky/widgets/icon.dart';
export 'package:sky/widgets/icon_button.dart';
export 'package:sky/widgets/ink_well.dart';
......
// Copyright 2015 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 'dart:math' as math;
import 'package:sky/rendering/block.dart';
import 'package:sky/rendering/box.dart';
import 'package:sky/rendering/object.dart';
import 'package:sky/widgets/framework.dart';
import 'package:sky/widgets/basic.dart';
typedef List<Widget> ListBuilder(int startIndex, int count);
class HomogeneousViewport extends RenderObjectWrapper {
HomogeneousViewport({
Key key,
this.builder,
this.itemExtent, // required
this.itemCount, // optional, but you cannot shrink-wrap this class or otherwise use its intrinsic dimensions if you don't specify it
this.direction: ScrollDirection.vertical,
this.startOffset: 0.0
}) : super(key: key) {
assert(itemExtent != null);
}
ListBuilder builder;
double itemExtent;
int itemCount;
ScrollDirection direction;
double startOffset;
bool _layoutDirty = true;
List<Widget> _children;
RenderBlockViewport get renderObject => super.renderObject;
RenderBlockViewport createNode() {
// we don't pass constructor arguments to the RenderBlockViewport() because until
// we know our children, the constructor arguments we could give have no effect
RenderBlockViewport result = new RenderBlockViewport();
result.callback = layout;
result.totalExtentCallback = getTotalExtent;
result.minCrossAxisDimensionCallback = getMinCrossAxisDimension;
result.maxCrossAxisDimensionCallback = getMaxCrossAxisDimension;
return result;
}
void remove() {
renderObject.callback = null;
renderObject.totalExtentCallback = null;
renderObject.minCrossAxisDimensionCallback = null;
renderObject.maxCrossAxisDimensionCallback = null;
super.remove();
_children.clear();
_layoutDirty = true;
}
void walkChildren(WidgetTreeWalker walker) {
if (_children == null) return;
for (Widget child in _children)
walker(child);
}
void insertChildRenderObject(RenderObjectWrapper child, Widget slot) {
RenderObject nextSibling = slot != null ? slot.renderObject : null;
renderObject.add(child.renderObject, before: nextSibling);
}
void detachChildRenderObject(RenderObjectWrapper child) {
renderObject.remove(child.renderObject);
}
bool retainStatefulNodeIfPossible(HomogeneousViewport newNode) {
retainStatefulRenderObjectWrapper(newNode);
if (startOffset != newNode.startOffset) {
_layoutDirty = true;
startOffset = newNode.startOffset;
}
if (itemCount != newNode.itemCount) {
_layoutDirty = true;
itemCount = newNode.itemCount;
}
if (itemExtent != newNode.itemExtent) {
_layoutDirty = true;
itemExtent = newNode.itemExtent;
}
if (direction != newNode.direction) {
_layoutDirty = true;
direction = newNode.direction;
}
if (builder != newNode.builder) {
_layoutDirty = true;
builder = newNode.builder;
}
return true;
}
// This is called during the regular component build
void syncRenderObject(HomogeneousViewport old) {
super.syncRenderObject(old);
if (_layoutDirty) {
renderObject.markNeedsLayout();
} else {
assert(old != null); // if old was null, we'd be new, and therefore _layoutDirty would be true
_updateChildren();
}
}
int _layoutFirstIndex;
int _layoutItemCount;
void layout(BoxConstraints constraints) {
LayoutCallbackBuilderHandle handle = enterLayoutCallbackBuilder();
try {
double mainAxisExtent = direction == ScrollDirection.vertical ? constraints.maxHeight : constraints.maxWidth;
double offset;
if (startOffset <= 0.0) {
_layoutFirstIndex = 0;
offset = -startOffset;
} else {
_layoutFirstIndex = startOffset ~/ itemExtent;
offset = -(startOffset % itemExtent);
}
if (mainAxisExtent < double.INFINITY) {
_layoutItemCount = ((mainAxisExtent - offset) / itemExtent).ceil();
if (itemCount != null)
_layoutItemCount = math.min(_layoutItemCount, itemCount - _layoutFirstIndex);
} else {
assert(() {
'This HomogeneousViewport has no specified number of items (meaning it has infinite items), ' +
'and has been placed in an unconstrained environment where all items can be rendered. ' +
'It is most likely that you have placed your HomogeneousViewport (which is an internal ' +
'component of several scrollable widgets) inside either another scrolling box, a flexible ' +
'box (Row, Column), or a Stack, without giving it a specific size.';
return itemCount != null;
});
_layoutItemCount = itemCount - _layoutFirstIndex;
}
_layoutItemCount = math.max(0, _layoutItemCount);
_updateChildren();
// Update the renderObject configuration
renderObject.direction = direction == ScrollDirection.vertical ? BlockDirection.vertical : BlockDirection.horizontal;
renderObject.itemExtent = itemExtent;
renderObject.minExtent = getTotalExtent(null);
renderObject.startOffset = offset;
} finally {
exitLayoutCallbackBuilder(handle);
}
}
void _updateChildren() {
assert(_layoutFirstIndex != null);
assert(_layoutItemCount != null);
List<Widget> newChildren;
if (_layoutItemCount > 0)
newChildren = builder(_layoutFirstIndex, _layoutItemCount);
else
newChildren = <Widget>[];
syncChildren(newChildren, _children == null ? <Widget>[] : _children);
_children = newChildren;
}
double getTotalExtent(BoxConstraints constraints) {
// constraints is null when called by layout() above
return itemCount != null ? itemCount * itemExtent : double.INFINITY;
}
double getMinCrossAxisDimension(BoxConstraints constraints) {
return 0.0;
}
double getMaxCrossAxisDimension(BoxConstraints constraints) {
if (direction == ScrollDirection.vertical)
return constraints.maxWidth;
return constraints.maxHeight;
}
}
......@@ -91,11 +91,11 @@ class MixedViewport extends RenderObjectWrapper {
Map<_Key, Widget> _childrenByKey = new Map<_Key, Widget>();
// we don't pass the direction or offset to the render object when we create it, because
// the render object is empty so it will not matter
RenderBlockViewport get renderObject => super.renderObject;
RenderBlockViewport createNode() {
// we don't pass the direction or offset to the render object when we
// create it, because the render object is empty so it will not matter
RenderBlockViewport result = new RenderBlockViewport();
result.callback = layout;
result.totalExtentCallback = _noIntrinsicDimensions;
......
......@@ -18,6 +18,7 @@ import 'package:sky/rendering/viewport.dart';
import 'package:sky/widgets/basic.dart';
import 'package:sky/widgets/framework.dart';
import 'package:sky/widgets/gesture_detector.dart';
import 'package:sky/widgets/homogeneous_viewport.dart';
import 'package:sky/widgets/mixed_viewport.dart';
export 'package:sky/widgets/mixed_viewport.dart' show MixedViewportLayoutState;
......@@ -421,17 +422,11 @@ abstract class ScrollableWidgetList extends Scrollable {
double contentExtent = itemExtent * itemCount;
if (padding != null)
contentExtent += _leadingPadding + _trailingPadding;
scrollTo(scrollBehavior.updateExtents(
contentExtent: contentExtent,
containerExtent: _containerExtent,
scrollOffset: scrollOffset));
}
Offset _toOffset(double value) {
return scrollDirection == ScrollDirection.vertical
? new Offset(0.0, value)
: new Offset(value, 0.0);
scrollOffset: scrollOffset
));
}
Widget buildContent() {
......@@ -440,48 +435,27 @@ abstract class ScrollableWidgetList extends Scrollable {
_updateScrollBehavior();
}
double paddedScrollOffset = scrollOffset - _leadingPadding;
int itemShowIndex = 0;
int itemShowCount = 0;
Offset viewportOffset = Offset.zero;
if (_containerExtent != null && _containerExtent > 0.0 && itemCount > 0) {
if (paddedScrollOffset < scrollBehavior.minScrollOffset) {
// Underscroll
double visibleExtent = _containerExtent + paddedScrollOffset;
itemShowCount = (visibleExtent / itemExtent).ceil();
viewportOffset = _toOffset(paddedScrollOffset);
} else {
itemShowCount = (_containerExtent / itemExtent).ceil() + 1;
itemShowIndex = (paddedScrollOffset / itemExtent).floor();
viewportOffset = _toOffset(paddedScrollOffset - itemShowIndex * itemExtent);
itemShowIndex %= itemCount; // Wrap index for when itemWrap is true.
}
}
List<Widget> items = buildItems(itemShowIndex, itemShowCount);
assert(items.every((item) => item.key != null));
BlockDirection blockDirection = scrollDirection == ScrollDirection.vertical
? BlockDirection.vertical
: BlockDirection.horizontal;
// TODO(ianh): Refactor this so that it does the building in the
// same frame as the size observing, similar to MixedViewport, but
// keeping the fixed-height optimisations.
return new SizeObserver(
callback: _handleSizeChanged,
child: new Viewport(
scrollDirection: scrollDirection,
scrollOffset: viewportOffset,
child: new Container(
padding: _crossAxisPadding,
child: new BlockBody(items, direction: blockDirection)
child: new Container(
padding: _crossAxisPadding,
child: new HomogeneousViewport(
builder: _buildItems,
itemExtent: itemExtent,
itemCount: itemCount,
direction: scrollDirection,
startOffset: scrollOffset - _leadingPadding
)
)
);
}
List<Widget> _buildItems(int start, int count) {
List<Widget> result = buildItems(start, count);
assert(result.every((item) => item.key != null));
return result;
}
List<Widget> buildItems(int start, int count);
}
......
......@@ -22,19 +22,13 @@ void main() {
]);
}
tester.pumpFrame(builder);
// TODO(abarth): We shouldn't need to pump a second frame here.
tester.pumpFrame(builder);
expect(currentValue, isNull);
tester.tap(tester.findText('2015'));
tester.pumpFrame(builder);
// TODO(jackson): We shouldn't need to pump a second frame here.
tester.pumpFrame(builder);
tester.tap(tester.findText('2014'));
tester.pumpFrame(builder);
// TODO(jackson): We shouldn't need to pump a second frame here.
tester.pumpFrame(builder);
expect(currentValue, equals(new DateTime(2014, 6, 9)));
tester.tap(tester.findText('30'));
expect(currentValue, equals(new DateTime(2013, 1, 30)));
......
import 'package:sky/widgets.dart';
import 'package:test/test.dart';
import 'widget_tester.dart';
class TestComponent extends StatefulComponent {
TestComponent(this.viewport);
HomogeneousViewport viewport;
void syncConstructorArguments(TestComponent source) {
viewport = source.viewport;
}
bool _flag = true;
void go(bool flag) {
setState(() {
_flag = flag;
});
}
Widget build() {
return _flag ? viewport : new Text('Not Today');
}
}
void main() {
test('HomogeneousViewport mount/dismount smoke test', () {
WidgetTester tester = new WidgetTester();
List<int> callbackTracker = <int>[];
// the root view is 800x600 in the test environment
// so if our widget is 100 pixels tall, it should fit exactly 6 times.
TestComponent testComponent;
Widget builder() {
testComponent = new TestComponent(new HomogeneousViewport(
builder: (int start, int count) {
List<Widget> result = <Widget>[];
for (int index = start; index < start + count; index += 1) {
callbackTracker.add(index);
result.add(new Container(
key: new ValueKey<int>(index),
height: 100.0,
child: new Text("$index")
));
}
return result;
},
startOffset: 0.0,
itemExtent: 100.0
));
return testComponent;
}
tester.pumpFrame(builder);
expect(callbackTracker, equals([0, 1, 2, 3, 4, 5]));
callbackTracker.clear();
testComponent.go(false);
tester.pumpFrameWithoutChange();
expect(callbackTracker, equals([]));
callbackTracker.clear();
testComponent.go(true);
tester.pumpFrameWithoutChange();
expect(callbackTracker, equals([0, 1, 2, 3, 4, 5]));
});
test('HomogeneousViewport vertical', () {
WidgetTester tester = new WidgetTester();
List<int> callbackTracker = <int>[];
// the root view is 800x600 in the test environment
// so if our widget is 200 pixels tall, it should fit exactly 3 times.
// but if we are offset by 300 pixels, there will be 4, numbered 1-4.
double offset = 300.0;
ListBuilder itemBuilder = (int start, int count) {
List<Widget> result = <Widget>[];
for (int index = start; index < start + count; index += 1) {
callbackTracker.add(index);
result.add(new Container(
key: new ValueKey<int>(index),
width: 500.0, // this should be ignored
height: 400.0, // should be overridden by itemExtent
child: new Text("$index")
));
}
return result;
};
TestComponent testComponent;
Widget builder() {
testComponent = new TestComponent(new HomogeneousViewport(
builder: itemBuilder,
startOffset: offset,
itemExtent: 200.0
));
return testComponent;
}
tester.pumpFrame(builder);
expect(callbackTracker, equals([1, 2, 3, 4]));
callbackTracker.clear();
offset = 400.0; // now only 3 should fit, numbered 2-4.
tester.pumpFrame(builder);
expect(callbackTracker, equals([2, 3, 4]));
callbackTracker.clear();
});
test('HomogeneousViewport horizontal', () {
WidgetTester tester = new WidgetTester();
List<int> callbackTracker = <int>[];
// the root view is 800x600 in the test environment
// so if our widget is 200 pixels wide, it should fit exactly 4 times.
// but if we are offset by 300 pixels, there will be 5, numbered 1-5.
double offset = 300.0;
ListBuilder itemBuilder = (int start, int count) {
List<Widget> result = <Widget>[];
for (int index = start; index < start + count; index += 1) {
callbackTracker.add(index);
result.add(new Container(
key: new ValueKey<int>(index),
width: 400.0, // this should be overridden by itemExtent
height: 500.0, // this should be ignored
child: new Text("$index")
));
}
return result;
};
TestComponent testComponent;
Widget builder() {
testComponent = new TestComponent(new HomogeneousViewport(
builder: itemBuilder,
startOffset: offset,
itemExtent: 200.0,
direction: ScrollDirection.horizontal
));
return testComponent;
}
tester.pumpFrame(builder);
expect(callbackTracker, equals([1, 2, 3, 4, 5]));
callbackTracker.clear();
offset = 400.0; // now only 4 should fit, numbered 2-5.
tester.pumpFrame(builder);
expect(callbackTracker, equals([2, 3, 4, 5]));
callbackTracker.clear();
});
}
......@@ -37,8 +37,6 @@ void main() {
);
}
tester.pumpFrame(builder);
// TODO(abarth): We shouldn't need to pump a second frame here.
tester.pumpFrame(builder);
expect(currentPage, isNull);
......
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