diff --git a/packages/flutter/lib/src/fn3.dart b/packages/flutter/lib/src/fn3.dart index 1b61595246..86313cce6f 100644 --- a/packages/flutter/lib/src/fn3.dart +++ b/packages/flutter/lib/src/fn3.dart @@ -7,3 +7,4 @@ library fn3; export 'fn3/basic.dart'; export 'fn3/framework.dart'; export 'fn3/binding.dart'; +export 'fn3/homogeneous_viewport.dart'; diff --git a/packages/flutter/lib/src/fn3/framework.dart b/packages/flutter/lib/src/fn3/framework.dart index 4257163af1..affa9914ce 100644 --- a/packages/flutter/lib/src/fn3/framework.dart +++ b/packages/flutter/lib/src/fn3/framework.dart @@ -504,7 +504,9 @@ abstract class Element implements BuildContext { } /// Called when an Element is given a new parent shortly after having been - /// created. + /// created. Use this to initialize state that depends on having a parent. For + /// state that is independent of the position in the tree, it's better to just + /// initialize the Element in the constructor. void mount(Element parent, dynamic newSlot) { assert(_debugLifecycleState == _ElementLifecycle.initial); assert(widget != null); @@ -599,13 +601,13 @@ abstract class Element implements BuildContext { } } -typedef Widget WidgetBuilder(BuildContext context); -typedef void BuildScheduler(BuildableElement element); - class ErrorWidget extends LeafRenderObjectWidget { RenderBox createRenderObject() => new RenderErrorBox(); } +typedef Widget WidgetBuilder(BuildContext context); +typedef void BuildScheduler(BuildableElement element); + /// Base class for the instantiation of StatelessComponent and StatefulComponent /// widgets. abstract class BuildableElement extends Element { @@ -629,7 +631,7 @@ abstract class BuildableElement extends Element { /// stateless components) or the ComponentState object (for stateful /// components) and then updates the widget tree. /// - /// Called automatically during didMount() to generate the first build, by the + /// Called automatically during mount() to generate the first build, by the /// binding when scheduleBuild() has been called to mark this element dirty, /// and by update() when the Widget has changed. void rebuild() { @@ -658,6 +660,24 @@ abstract class BuildableElement extends Element { static BuildScheduler scheduleBuildFor; + static int _debugStateLockLevel = 0; + static bool get _debugStateLocked => _debugStateLockLevel > 0; + + /// Calls the callback argument synchronously, but in a context where calls to + /// ComponentState.setState() will fail. Use this when it is possible that you + /// will trigger code in components but want to make sure that there is no + /// possibility that any components will be marked dirty, for example because + /// you are in the middle of layout and you are not going to be flushing the + /// build queue (since that could mutate the layout tree). + static void lockState(void callback()) { + _debugStateLockLevel += 1; + try { + callback(); + } finally { + _debugStateLockLevel -= 1; + } + } + /// Marks the element as dirty and adds it to the global list of widgets to /// rebuild in the next frame. /// @@ -666,6 +686,7 @@ abstract class BuildableElement extends Element { /// components dirty during event handlers before the frame begins, not during /// the build itself. void markNeedsBuild() { + assert(!_debugStateLocked); assert(_debugLifecycleState == _ElementLifecycle.mounted); if (_dirty) return; @@ -804,6 +825,7 @@ abstract class RenderObjectElement extends Element /// The underlying [RenderObject] for this element RenderObject get renderObject => _renderObject; final RenderObject _renderObject; + RenderObjectElement _ancestorRenderObjectElement; RenderObjectElement _findAncestorRenderObjectElement() { @@ -840,6 +862,153 @@ abstract class RenderObjectElement extends Element widget.updateRenderObject(renderObject, oldWidget); } + /// Utility function for subclasses that have one or more lists of children. + /// Attempts to update the given old children list using the given new + /// widgets, removing obsolete elements and introducing new ones as necessary, + /// and then returns the new child list. + List updateChildren(List oldChildren, List newWidgets) { + assert(oldChildren != null); + assert(newWidgets != null); + + // This attempts to diff the new child list (this.children) with + // the old child list (old.children), and update our renderObject + // accordingly. + + // The cases it tries to optimise for are: + // - the old list is empty + // - the lists are identical + // - there is an insertion or removal of one or more widgets in + // only one place in the list + // If a widget with a key is in both lists, it will be synced. + // Widgets without keys might be synced but there is no guarantee. + + // The general approach is to sync the entire new list backwards, as follows: + // 1. Walk the lists from the top until you no longer have + // matching nodes. We don't sync these yet, but we now know to + // skip them below. We do this because at each sync we need to + // pass the pointer to the new next widget as the slot, which + // we can't do until we've synced the next child. + // 2. Walk the lists from the bottom, syncing nodes, until you no + // longer have matching nodes. + // At this point we narrowed the old and new lists to the point + // where the nodes no longer match. + // 3. Walk the narrowed part of the old list to get the list of + // keys and sync null with non-keyed items. + // 4. Walk the narrowed part of the new list backwards: + // * Sync unkeyed items with null + // * Sync keyed items with the source if it exists, else with null. + // 5. Walk the top list again but backwards, syncing the nodes. + // 6. Sync null with any items in the list of keys that are still + // mounted. + + final ContainerRenderObjectMixin renderObject = this.renderObject; // TODO(ianh): Remove this once the analyzer is cleverer + assert(renderObject is ContainerRenderObjectMixin); + + int childrenTop = 0; + int newChildrenBottom = newWidgets.length - 1; + int oldChildrenBottom = oldChildren.length - 1; + + // top of the lists + while ((childrenTop <= oldChildrenBottom) && (childrenTop <= newChildrenBottom)) { + Element oldChild = oldChildren[childrenTop]; + Widget newWidget = newWidgets[childrenTop]; + assert(oldChild._debugLifecycleState == _ElementLifecycle.mounted); + if (!_canUpdate(oldChild.widget, newWidget)) + break; + childrenTop += 1; + } + + List newChildren = oldChildren.length == newWidgets.length ? + oldChildren : new List(newWidgets.length); + + Element nextSibling; + + // bottom of the lists + while ((childrenTop <= oldChildrenBottom) && (childrenTop <= newChildrenBottom)) { + Element oldChild = oldChildren[oldChildrenBottom]; + Widget newWidget = newWidgets[newChildrenBottom]; + assert(oldChild._debugLifecycleState == _ElementLifecycle.mounted); + if (!_canUpdate(oldChild.widget, newWidget)) + break; + Element newChild = updateChild(oldChild, newWidget, nextSibling); + assert(newChild._debugLifecycleState == _ElementLifecycle.mounted); + newChildren[newChildrenBottom] = newChild; + nextSibling = newChild; + oldChildrenBottom -= 1; + newChildrenBottom -= 1; + } + + // middle of the lists - old list + bool haveOldNodes = childrenTop <= oldChildrenBottom; + Map oldKeyedChildren; + if (haveOldNodes) { + oldKeyedChildren = new Map(); + while (childrenTop <= oldChildrenBottom) { + Element oldChild = oldChildren[oldChildrenBottom]; + assert(oldChild._debugLifecycleState == _ElementLifecycle.mounted); + if (oldChild.widget.key != null) + oldKeyedChildren[oldChild.widget.key] = oldChild; + else + _detachChild(oldChild); + oldChildrenBottom -= 1; + } + } + + // middle of the lists - new list + while (childrenTop <= newChildrenBottom) { + Element oldChild; + Widget newWidget = newWidgets[newChildrenBottom]; + if (haveOldNodes) { + Key key = newWidget.key; + if (key != null) { + oldChild = oldKeyedChildren[newWidget.key]; + if (oldChild != null) { + if (_canUpdate(oldChild.widget, newWidget)) { + // we found a match! + // remove it from oldKeyedChildren so we don't unsync it later + oldKeyedChildren.remove(key); + } else { + // Not a match, let's pretend we didn't see it for now. + oldChild = null; + } + } + } + } + assert(oldChild == null || _canUpdate(oldChild.widget, newWidget)); + Element newChild = updateChild(oldChild, newWidget, nextSibling); + assert(newChild._debugLifecycleState == _ElementLifecycle.mounted); + assert(oldChild == newChild || oldChild == null || oldChild._debugLifecycleState != _ElementLifecycle.mounted); + newChildren[newChildrenBottom] = newChild; + nextSibling = newChild; + newChildrenBottom -= 1; + } + assert(oldChildrenBottom == newChildrenBottom); + assert(childrenTop == newChildrenBottom + 1); + + // now sync the top of the list + while (childrenTop > 0) { + childrenTop -= 1; + Element oldChild = oldChildren[childrenTop]; + assert(oldChild._debugLifecycleState == _ElementLifecycle.mounted); + Widget newWidget = newWidgets[childrenTop]; + assert(_canUpdate(oldChild.widget, newWidget)); + Element newChild = updateChild(oldChild, newWidget, nextSibling); + assert(newChild._debugLifecycleState == _ElementLifecycle.mounted); + assert(oldChild == newChild || oldChild == null || oldChild._debugLifecycleState != _ElementLifecycle.mounted); + newChildren[childrenTop] = newChild; + nextSibling = newChild; + } + + // clean up any of the remaining middle nodes from the old list + if (haveOldNodes && !oldKeyedChildren.isEmpty) { + for (Element oldChild in oldKeyedChildren.values) + _detachChild(oldChild); + } + + assert(renderObject == this.renderObject); // TODO(ianh): Remove this once the analyzer is cleverer + return newChildren; + } + void unmount() { super.unmount(); widget.didUnmountRenderObject(renderObject); @@ -995,152 +1164,8 @@ class MultiChildRenderObjectElement exte void update(T newWidget) { super.update(newWidget); assert(widget == newWidget); - _children = _updateChildren(_children, widget.children); + _children = updateChildren(_children, widget.children); } - - List _updateChildren(List oldChildren, List newWidgets) { - assert(oldChildren != null); - assert(newWidgets != null); - - // This attempts to diff the new child list (this.children) with - // the old child list (old.children), and update our renderObject - // accordingly. - - // The cases it tries to optimise for are: - // - the old list is empty - // - the lists are identical - // - there is an insertion or removal of one or more widgets in - // only one place in the list - // If a widget with a key is in both lists, it will be synced. - // Widgets without keys might be synced but there is no guarantee. - - // The general approach is to sync the entire new list backwards, as follows: - // 1. Walk the lists from the top until you no longer have - // matching nodes. We don't sync these yet, but we now know to - // skip them below. We do this because at each sync we need to - // pass the pointer to the new next widget as the slot, which - // we can't do until we've synced the next child. - // 2. Walk the lists from the bottom, syncing nodes, until you no - // longer have matching nodes. - // At this point we narrowed the old and new lists to the point - // where the nodes no longer match. - // 3. Walk the narrowed part of the old list to get the list of - // keys and sync null with non-keyed items. - // 4. Walk the narrowed part of the new list backwards: - // * Sync unkeyed items with null - // * Sync keyed items with the source if it exists, else with null. - // 5. Walk the top list again but backwards, syncing the nodes. - // 6. Sync null with any items in the list of keys that are still - // mounted. - - final ContainerRenderObjectMixin renderObject = this.renderObject; // TODO(ianh): Remove this once the analyzer is cleverer - assert(renderObject is ContainerRenderObjectMixin); - - int childrenTop = 0; - int newChildrenBottom = newWidgets.length - 1; - int oldChildrenBottom = oldChildren.length - 1; - - // top of the lists - while ((childrenTop <= oldChildrenBottom) && (childrenTop <= newChildrenBottom)) { - Element oldChild = oldChildren[childrenTop]; - Widget newWidget = newWidgets[childrenTop]; - assert(oldChild._debugLifecycleState == _ElementLifecycle.mounted); - if (!_canUpdate(oldChild.widget, newWidget)) - break; - childrenTop += 1; - } - - List newChildren = oldChildren.length == newWidgets.length ? - oldChildren : new List(newWidgets.length); - - Element nextSibling; - - // bottom of the lists - while ((childrenTop <= oldChildrenBottom) && (childrenTop <= newChildrenBottom)) { - Element oldChild = oldChildren[oldChildrenBottom]; - Widget newWidget = newWidgets[newChildrenBottom]; - assert(oldChild._debugLifecycleState == _ElementLifecycle.mounted); - if (!_canUpdate(oldChild.widget, newWidget)) - break; - Element newChild = updateChild(oldChild, newWidget, nextSibling); - assert(newChild._debugLifecycleState == _ElementLifecycle.mounted); - newChildren[newChildrenBottom] = newChild; - nextSibling = newChild; - oldChildrenBottom -= 1; - newChildrenBottom -= 1; - } - - // middle of the lists - old list - bool haveOldNodes = childrenTop <= oldChildrenBottom; - Map oldKeyedChildren; - if (haveOldNodes) { - oldKeyedChildren = new Map(); - while (childrenTop <= oldChildrenBottom) { - Element oldChild = oldChildren[oldChildrenBottom]; - assert(oldChild._debugLifecycleState == _ElementLifecycle.mounted); - if (oldChild.widget.key != null) - oldKeyedChildren[oldChild.widget.key] = oldChild; - else - _detachChild(oldChild); - oldChildrenBottom -= 1; - } - } - - // middle of the lists - new list - while (childrenTop <= newChildrenBottom) { - Element oldChild; - Widget newWidget = newWidgets[newChildrenBottom]; - if (haveOldNodes) { - Key key = newWidget.key; - if (key != null) { - oldChild = oldKeyedChildren[newWidget.key]; - if (oldChild != null) { - if (_canUpdate(oldChild.widget, newWidget)) { - // we found a match! - // remove it from oldKeyedChildren so we don't unsync it later - oldKeyedChildren.remove(key); - } else { - // Not a match, let's pretend we didn't see it for now. - oldChild = null; - } - } - } - } - assert(oldChild == null || _canUpdate(oldChild.widget, newWidget)); - Element newChild = updateChild(oldChild, newWidget, nextSibling); - assert(newChild._debugLifecycleState == _ElementLifecycle.mounted); - assert(oldChild == newChild || oldChild == null || oldChild._debugLifecycleState != _ElementLifecycle.mounted); - newChildren[newChildrenBottom] = newChild; - nextSibling = newChild; - newChildrenBottom -= 1; - } - assert(oldChildrenBottom == newChildrenBottom); - assert(childrenTop == newChildrenBottom + 1); - - // now sync the top of the list - while (childrenTop > 0) { - childrenTop -= 1; - Element oldChild = oldChildren[childrenTop]; - assert(oldChild._debugLifecycleState == _ElementLifecycle.mounted); - Widget newWidget = newWidgets[childrenTop]; - assert(_canUpdate(oldChild.widget, newWidget)); - Element newChild = updateChild(oldChild, newWidget, nextSibling); - assert(newChild._debugLifecycleState == _ElementLifecycle.mounted); - assert(oldChild == newChild || oldChild == null || oldChild._debugLifecycleState != _ElementLifecycle.mounted); - newChildren[childrenTop] = newChild; - nextSibling = newChild; - } - - // clean up any of the remaining middle nodes from the old list - if (haveOldNodes && !oldKeyedChildren.isEmpty) { - for (Element oldChild in oldKeyedChildren.values) - _detachChild(oldChild); - } - - assert(renderObject == this.renderObject); // TODO(ianh): Remove this once the analyzer is cleverer - return newChildren; - } - } typedef void WidgetsExceptionHandler(String context, dynamic exception, StackTrace stack); diff --git a/packages/flutter/lib/src/fn3/homogeneous_viewport.dart b/packages/flutter/lib/src/fn3/homogeneous_viewport.dart new file mode 100644 index 0000000000..9c770f1ecb --- /dev/null +++ b/packages/flutter/lib/src/fn3/homogeneous_viewport.dart @@ -0,0 +1,176 @@ +// 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.dart'; +import 'package:sky/src/fn3/framework.dart'; +import 'package:sky/src/fn3/basic.dart'; + +typedef List ListBuilder(int startIndex, int count, BuildContext context); + +class HomogeneousViewport extends RenderObjectWidget { + HomogeneousViewport({ + Key key, + this.builder, + this.itemsWrap: false, + 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); + } + + final ListBuilder builder; + final bool itemsWrap; + final double itemExtent; + final int itemCount; + final ScrollDirection direction; + final double startOffset; + + RenderObjectElement createElement() => new HomogeneousViewportElement(this); + + // we don't pass constructor arguments to the RenderBlockViewport() because until + // we know our children, the constructor arguments we could give have no effect + RenderObject createRenderObject() => new RenderBlockViewport(); + + bool isLayoutDifferentThan(HomogeneousViewport oldWidget) { + return itemsWrap != oldWidget.itemsWrap || + itemsWrap != oldWidget.itemsWrap || + itemExtent != oldWidget.itemExtent || + itemCount != oldWidget.itemCount || + direction != oldWidget.direction || + startOffset != oldWidget.startOffset; + } + + // all the actual work is done in the element +} + +class HomogeneousViewportElement extends RenderObjectElement { + HomogeneousViewportElement(HomogeneousViewport widget) : super(widget); + + List _children = const []; + bool _layoutDirty = true; + int _layoutFirstIndex; + int _layoutItemCount; + + RenderBlockViewport get renderObject => super.renderObject; + + void visitChildren(ElementVisitor visitor) { + if (_children == null) + return; + for (Element child in _children) + visitor(child); + } + + void mount(Element parent, dynamic newSlot) { + super.mount(parent, newSlot); + renderObject.callback = layout; + renderObject.totalExtentCallback = getTotalExtent; + renderObject.minCrossAxisExtentCallback = getMinCrossAxisExtent; + renderObject.maxCrossAxisExtentCallback = getMaxCrossAxisExtent; + } + + void unmount() { + renderObject.callback = null; + renderObject.totalExtentCallback = null; + renderObject.minCrossAxisExtentCallback = null; + renderObject.maxCrossAxisExtentCallback = null; + super.unmount(); + } + + void update(HomogeneousViewport newWidget) { + bool needLayout = newWidget.isLayoutDifferentThan(widget); + super.update(newWidget); + if (needLayout) + renderObject.markNeedsLayout(); + else + _updateChildren(); + } + + void layout(BoxConstraints constraints) { + // we lock the framework state (meaning that no elements can call markNeedsBuild()) because we are + // in the middle of layout and if we allowed people to set state, they'd expect to have that state + // reflected immediately, which, if we were to try to honour it, would potentially result in + // assertions since you can't normally mutate the render object tree during layout. (If there was + // a way to limit this to only descendants of this, it'd be ok, since we are exempt from that + // assert since we are actively doing our own layout still.) + BuildableElement.lockState(() { + double mainAxisExtent = widget.direction == ScrollDirection.vertical ? constraints.maxHeight : constraints.maxWidth; + double offset; + if (widget.startOffset <= 0.0 && !widget.itemsWrap) { + _layoutFirstIndex = 0; + offset = -widget.startOffset; + } else { + _layoutFirstIndex = (widget.startOffset / widget.itemExtent).floor(); + offset = -(widget.startOffset % widget.itemExtent); + } + if (mainAxisExtent < double.INFINITY) { + _layoutItemCount = ((mainAxisExtent - offset) / widget.itemExtent).ceil(); + if (widget.itemCount != null && !widget.itemsWrap) + _layoutItemCount = math.min(_layoutItemCount, widget.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 widget.itemCount != null; + }); + _layoutItemCount = widget.itemCount - _layoutFirstIndex; + } + _layoutItemCount = math.max(0, _layoutItemCount); + _updateChildren(); + // Update the renderObject configuration + renderObject.direction = widget.direction == ScrollDirection.vertical ? BlockDirection.vertical : BlockDirection.horizontal; + renderObject.itemExtent = widget.itemExtent; + renderObject.minExtent = getTotalExtent(null); + renderObject.startOffset = offset; + }); + } + + void _updateChildren() { + assert(_layoutFirstIndex != null); + assert(_layoutItemCount != null); + List newWidgets; + if (_layoutItemCount > 0) + newWidgets = widget.builder(_layoutFirstIndex, _layoutItemCount, this); + else + newWidgets = []; + _children = updateChildren(_children, newWidgets); + } + + double getTotalExtent(BoxConstraints constraints) { + // constraints is null when called by layout() above + return widget.itemCount != null ? widget.itemCount * widget.itemExtent : double.INFINITY; + } + + double getMinCrossAxisExtent(BoxConstraints constraints) { + return 0.0; + } + + double getMaxCrossAxisExtent(BoxConstraints constraints) { + if (widget.direction == ScrollDirection.vertical) + return constraints.maxWidth; + return constraints.maxHeight; + } + + void insertChildRenderObject(RenderObject child, Element slot) { + RenderObject nextSibling = slot?.renderObject; + renderObject.add(child, before: nextSibling); + } + + void moveChildRenderObject(RenderObject child, dynamic slot) { + RenderObject nextSibling = slot?.renderObject; + renderObject.move(child, before: nextSibling); + } + + void removeChildRenderObject(RenderObject child) { + assert(child.parent == renderObject); + renderObject.remove(child); + } + +} diff --git a/packages/unit/test/fn3/homogeneous_viewport_test.dart b/packages/unit/test/fn3/homogeneous_viewport_test.dart new file mode 100644 index 0000000000..c1d16deaf8 --- /dev/null +++ b/packages/unit/test/fn3/homogeneous_viewport_test.dart @@ -0,0 +1,172 @@ +import 'package:sky/src/fn3.dart'; +import 'package:test/test.dart'; + +import 'widget_tester.dart'; + +class TestComponent extends StatefulComponent { + TestComponent(this.viewport); + final HomogeneousViewport viewport; + TestComponentState createState() => new TestComponentState(this); +} + +class TestComponentState extends ComponentState { + TestComponentState(TestComponent config): super(config); + bool _flag = true; + void go(bool flag) { + setState(() { + _flag = flag; + }); + } + Widget build(BuildContext context) { + return _flag ? config.viewport : new Text('Not Today'); + } +} + +void main() { + test('HomogeneousViewport mount/dismount smoke test', () { + WidgetTester tester = new WidgetTester(); + + List callbackTracker = []; + + // the root view is 800x600 in the test environment + // so if our widget is 100 pixels tall, it should fit exactly 6 times. + + Widget builder() { + return new TestComponent(new HomogeneousViewport( + builder: (int start, int count, BuildContext context) { + List result = []; + for (int index = start; index < start + count; index += 1) { + callbackTracker.add(index); + result.add(new Container( + key: new ValueKey(index), + height: 100.0, + child: new Text("$index") + )); + } + return result; + }, + startOffset: 0.0, + itemExtent: 100.0 + )); + } + + tester.pumpFrame(builder()); + + TestComponentState testComponent = tester.findElement((element) => element.widget is TestComponent).state; + + 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 callbackTracker = []; + + // 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, BuildContext context) { + List result = []; + for (int index = start; index < start + count; index += 1) { + callbackTracker.add(index); + result.add(new Container( + key: new ValueKey(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 callbackTracker = []; + + // 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, BuildContext context) { + List result = []; + for (int index = start; index < start + count; index += 1) { + callbackTracker.add(index); + result.add(new Container( + key: new ValueKey(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(); + }); +}