Support infinite scrolling for CupertinoPicker. (#19789)

Allows the cupertino picker to be scroll infinitely by adding builder.
diff --git a/examples/flutter_gallery/lib/demo/cupertino/cupertino_picker_demo.dart b/examples/flutter_gallery/lib/demo/cupertino/cupertino_picker_demo.dart
index a9bd2a4..0623112 100644
--- a/examples/flutter_gallery/lib/demo/cupertino/cupertino_picker_demo.dart
+++ b/examples/flutter_gallery/lib/demo/cupertino/cupertino_picker_demo.dart
@@ -99,6 +99,7 @@
                 child: new Text(index.toString()),
               );
             }),
+            looping: true,
           ),
         ),
         new Expanded(
@@ -123,6 +124,7 @@
                 child: new Text(index.toString()),
               );
             }),
+            looping: true,
           ),
         ),
       ],
diff --git a/packages/flutter/lib/src/cupertino/picker.dart b/packages/flutter/lib/src/cupertino/picker.dart
index 8e21dd8..3d1f39a 100644
--- a/packages/flutter/lib/src/cupertino/picker.dart
+++ b/packages/flutter/lib/src/cupertino/picker.dart
@@ -18,7 +18,7 @@
 
 /// An iOS-styled picker.
 ///
-/// Displays the provided [children] widgets on a wheel for selection and
+/// Displays its children widgets on a wheel for selection and
 /// calls back when the currently selected item changes.
 ///
 /// Can be used with [showModalBottomSheet] to display the picker modally at the
@@ -30,7 +30,7 @@
 ///    the iOS design specific chrome.
 ///  * <https://developer.apple.com/ios/human-interface-guidelines/controls/pickers/>
 class CupertinoPicker extends StatefulWidget {
-  /// Creates a control used for selecting values.
+  /// Creates a picker from a concrete list of children.
   ///
   /// The [diameterRatio] and [itemExtent] arguments must not be null. The
   /// [itemExtent] must be greater than zero.
@@ -38,7 +38,12 @@
   /// The [backgroundColor] defaults to light gray. It can be set to null to
   /// disable the background painting entirely; this is mildly more efficient
   /// than using [Colors.transparent].
-  const CupertinoPicker({
+  ///
+  /// The [looping] argument decides whether the child list loops and can be
+  /// scrolled infinitely.  If set to true, scrolling past the end of the list
+  /// will loop the list back to the beginning.  If set to false, the list will
+  /// stop scrolling when you reach the end or the beginning.
+  CupertinoPicker({
     Key key,
     this.diameterRatio = _kDefaultDiameterRatio,
     this.backgroundColor = _kDefaultBackground,
@@ -48,12 +53,55 @@
     this.scrollController,
     @required this.itemExtent,
     @required this.onSelectedItemChanged,
-    @required this.children,
-  }) : assert(diameterRatio != null),
+    @required List<Widget> children,
+    bool looping = false,
+  }) : assert(children != null),
+       assert(diameterRatio != null),
        assert(diameterRatio > 0.0, RenderListWheelViewport.diameterRatioZeroMessage),
        assert(magnification > 0),
        assert(itemExtent != null),
        assert(itemExtent > 0),
+       childDelegate = looping
+                       ? new ListWheelChildLoopingListDelegate(children: children)
+                       : new ListWheelChildListDelegate(children: children),
+       super(key: key);
+
+  /// Creates a picker from an [IndexedWidgetBuilder] callback where the builder
+  /// is dynamically invoked during layout.
+  ///
+  /// A child is lazily created when it starts becoming visible in the viewport.
+  /// All of the children provided by the builder are cached and reused, so
+  /// normally the builder is only called once for each index (except when
+  /// rebuilding - the cache is cleared).
+  ///
+  /// The [itemBuilder] argument must not be null. The [childCount] argument
+  /// reflects the number of children that will be provided by the [itemBuilder].
+  /// {@macro flutter.widgets.wheelList.childCount}
+  ///
+  /// The [itemExtent] argument must be non-null and positive.
+  ///
+  /// The [backgroundColor] defaults to light gray. It can be set to null to
+  /// disable the background painting entirely; this is mildly more efficient
+  /// than using [Colors.transparent].
+  CupertinoPicker.builder({
+    Key key,
+    this.diameterRatio = _kDefaultDiameterRatio,
+    this.backgroundColor = _kDefaultBackground,
+    this.offAxisFraction = 0.0,
+    this.useMagnifier = false,
+    this.magnification = 1.0,
+    this.scrollController,
+    @required this.itemExtent,
+    @required this.onSelectedItemChanged,
+    @required IndexedWidgetBuilder itemBuilder,
+    int childCount,
+  }) : assert(itemBuilder != null),
+       assert(diameterRatio != null),
+       assert(diameterRatio > 0.0, RenderListWheelViewport.diameterRatioZeroMessage),
+       assert(magnification > 0),
+       assert(itemExtent != null),
+       assert(itemExtent > 0),
+       childDelegate = new ListWheelChildBuilderDelegate(builder: itemBuilder, childCount: childCount),
        super(key: key);
 
   /// Relative ratio between this picker's height and the simulated cylinder's diameter.
@@ -102,8 +150,8 @@
   /// listen for [ScrollEndNotification] and read its [FixedExtentMetrics].
   final ValueChanged<int> onSelectedItemChanged;
 
-  /// [Widget]s in the picker's scroll wheel.
-  final List<Widget> children;
+  /// A delegate that lazily instantiates children.
+  final ListWheelChildDelegate childDelegate;
 
   @override
   State<StatefulWidget> createState() => new _CupertinoPickerState();
@@ -196,7 +244,7 @@
     Widget result = new Stack(
       children: <Widget>[
         new Positioned.fill(
-          child: new ListWheelScrollView(
+          child: new ListWheelScrollView.useDelegate(
             controller: widget.scrollController,
             physics: const FixedExtentScrollPhysics(),
             diameterRatio: widget.diameterRatio,
@@ -205,7 +253,7 @@
             magnification: widget.magnification,
             itemExtent: widget.itemExtent,
             onSelectedItemChanged: _handleSelectedItemChanged,
-            children: widget.children,
+            childDelegate: widget.childDelegate,
           ),
         ),
         _buildGradientScreen(),
diff --git a/packages/flutter/lib/src/rendering/list_wheel_viewport.dart b/packages/flutter/lib/src/rendering/list_wheel_viewport.dart
index 1a72efc..008eec0 100644
--- a/packages/flutter/lib/src/rendering/list_wheel_viewport.dart
+++ b/packages/flutter/lib/src/rendering/list_wheel_viewport.dart
@@ -15,8 +15,44 @@
 
 typedef double _ChildSizingFunction(RenderBox child);
 
+/// A delegate used by [RenderListWheelViewport] to manage its children.
+///
+/// [RenderListWheelViewport] during layout will ask the delegate to create
+/// children that are visible in the viewport and remove those that are not.
+abstract class ListWheelChildManager {
+  /// The maximum number of children that can be provided to
+  /// [RenderListWheelViewport].
+  ///
+  /// If non-null, the children will have index in the range [0, childCount - 1].
+  ///
+  /// If null, then there's no explicit limits to the range of the children
+  /// except that it has to be contiguous. If [childExistsAt] for a certain
+  /// index returns false, that index is already past the limit.
+  int get childCount;
+
+  /// Checks whether the delegate is able to provide a child widget at the given
+  /// index.
+  ///
+  /// This function is not about whether the child at the given index is
+  /// attached to the [RenderListWheelViewport] or not.
+  bool childExistsAt(int index);
+
+  /// Creates a new child at the given index and updates it to the child list
+  /// of [RenderListWheelViewport]. If no child corresponds to `index`, then do
+  /// nothing.
+  ///
+  /// It is possible to create children with negative indices.
+  void createChild(int index, {@required RenderBox after});
+
+  /// Removes the child element corresponding with the given RenderBox.
+  void removeChild(RenderBox child);
+}
+
 /// [ParentData] for use with [RenderListWheelViewport].
-class ListWheelParentData extends ContainerBoxParentData<RenderBox> { }
+class ListWheelParentData extends ContainerBoxParentData<RenderBox> {
+  /// Index of this child in its parent's child list.
+  int index;
+}
 
 /// Render, onto a wheel, a bigger sequential set of objects inside this viewport.
 ///
@@ -97,6 +133,7 @@
   ///
   /// All arguments must not be null. Optional arguments have reasonable defaults.
   RenderListWheelViewport({
+    @required this.childManager,
     @required ViewportOffset offset,
     double diameterRatio = defaultDiameterRatio,
     double perspective = defaultPerspective,
@@ -107,7 +144,8 @@
     bool clipToSize = true,
     bool renderChildrenOutsideViewport = false,
     List<RenderBox> children,
-  }) : assert(offset != null),
+  }) : assert(childManager != null),
+       assert(offset != null),
        assert(diameterRatio != null),
        assert(diameterRatio > 0, diameterRatioZeroMessage),
        assert(perspective != null),
@@ -159,6 +197,9 @@
       'Cannot renderChildrenOutsideViewport and clipToSize since children '
       'rendered outside will be clipped anyway.';
 
+  /// The delegate that manages the children of this object.
+  final ListWheelChildManager childManager;
+
   /// The associated ViewportOffset object for the viewport describing the part
   /// of the content inside that's visible.
   ///
@@ -221,7 +262,8 @@
     if (value == _diameterRatio)
       return;
     _diameterRatio = value;
-    _hasScrolled();
+    markNeedsPaint();
+    markNeedsSemanticsUpdate();
   }
 
   /// {@template flutter.rendering.wheelList.perspective}
@@ -250,7 +292,8 @@
     if (value == _perspective)
       return;
     _perspective = value;
-    _hasScrolled();
+    markNeedsPaint();
+    markNeedsSemanticsUpdate();
   }
 
   /// {@template flutter.rendering.wheelList.offAxisFraction}
@@ -357,7 +400,8 @@
     if (value == _clipToSize)
       return;
     _clipToSize = value;
-    _hasScrolled();
+    markNeedsPaint();
+    markNeedsSemanticsUpdate();
   }
 
   /// {@template flutter.rendering.wheelList.renderChildrenOutsideViewport}
@@ -381,11 +425,12 @@
     if (value == _renderChildrenOutsideViewport)
       return;
     _renderChildrenOutsideViewport = value;
-    _hasScrolled();
+    markNeedsLayout();
+    markNeedsSemanticsUpdate();
   }
 
   void _hasScrolled() {
-    markNeedsPaint();
+    markNeedsLayout();
     markNeedsSemanticsUpdate();
   }
 
@@ -418,19 +463,21 @@
 
   /// Main axis scroll extent in the **scrollable layout coordinates** that puts
   /// the first item in the center.
-  double get _minScrollExtent {
+  double get _minEstimatedScrollExtent {
     assert(hasSize);
+    if (childManager.childCount == null)
+      return double.negativeInfinity;
     return 0.0;
   }
 
   /// Main axis scroll extent in the **scrollable layout coordinates** that puts
   /// the last item in the center.
-  double get _maxScrollExtent {
+  double get _maxEstimatedScrollExtent {
     assert(hasSize);
-    if (!(childCount > 0))
-      return 0.0;
+    if (childManager.childCount == null)
+      return double.infinity;
 
-    return math.max(0.0, (childCount - 1) * _itemExtent);
+    return math.max(0.0, (childManager.childCount - 1) * _itemExtent);
   }
 
   /// Scroll extent distance in the untransformed plane between the center
@@ -441,7 +488,7 @@
   double get _topScrollMarginExtent {
     assert(hasSize);
     // Consider adding alignment options other than center.
-    return _minScrollExtent - size.height / 2.0 + _itemExtent / 2.0;
+    return -size.height / 2.0 + _itemExtent / 2.0;
   }
 
   /// Transforms a **scrollable layout coordinates**' y position to the
@@ -451,24 +498,6 @@
     return layoutCoordinateY - _topScrollMarginExtent - offset.pixels;
   }
 
-  /// Children with offsets larger than this value in the **scrollable layout
-  /// coordinates** can be painted.
-  double get _firstVisibleLayoutOffset {
-    assert(hasSize);
-    if (_renderChildrenOutsideViewport)
-      return double.negativeInfinity;
-    return _minScrollExtent - size.height / 2.0 - _itemExtent / 2.0 + offset.pixels;
-  }
-
-  /// Children with offsets smaller than this value in the **scrollable layout
-  /// coordinates** can be painted.
-  double get _lastVisibleLayoutOffset {
-    assert(hasSize);
-    if (_renderChildrenOutsideViewport)
-      return double.infinity;
-    return _minScrollExtent + size.height / 2.0 + _itemExtent / 2.0 + offset.pixels;
-  }
-
   /// Given the _diameterRatio, return the largest absolute angle of the item
   /// at the edge of the portion of the visible cylinder.
   ///
@@ -509,16 +538,16 @@
 
   @override
   double computeMinIntrinsicHeight(double width) {
-    if (childCount > 0)
-      return childCount * _itemExtent;
-    return 0.0;
+    if (childManager.childCount == null)
+      return 0.0;
+    return childManager.childCount * _itemExtent;
   }
 
   @override
   double computeMaxIntrinsicHeight(double width) {
-    if (childCount > 0)
-      return childCount * _itemExtent;
-    return 0.0;
+    if (childManager.childCount == null)
+      return 0.0;
+    return childManager.childCount * _itemExtent;
   }
 
   @override
@@ -529,35 +558,170 @@
     size = constraints.biggest;
   }
 
+  /// Gets the index of a child by looking at its parentData.
+  int indexOf(RenderBox child) {
+    assert(child != null);
+    final ListWheelParentData childParentData = child.parentData;
+    assert(childParentData.index != null);
+    return childParentData.index;
+  }
+
+  /// Returns the index of the child at the given offset.
+  int scrollOffsetToIndex(double scrollOffset) => (scrollOffset / itemExtent).floor();
+
+  /// Returns the scroll offset of the child with the given index.
+  double indexToScrollOffset(int index) => index * itemExtent;
+
+  void _createChild(int index, {RenderBox after}) {
+    invokeLayoutCallback<BoxConstraints>((BoxConstraints constraints) {
+      assert(constraints == this.constraints);
+      childManager.createChild(index, after: after);
+    });
+  }
+
+  void _destroyChild(RenderBox child) {
+    invokeLayoutCallback<BoxConstraints>((BoxConstraints constraints) {
+      assert(constraints == this.constraints);
+      childManager.removeChild(child);
+    });
+  }
+
+  void _layoutChild(RenderBox child, BoxConstraints constraints, int index) {
+    child.layout(constraints, parentUsesSize: true);
+    final ListWheelParentData childParentData = child.parentData;
+    // Centers the child horizontally.
+    final double crossPosition = size.width / 2.0 - child.size.width / 2.0;
+    childParentData.offset = new Offset(crossPosition, indexToScrollOffset(index));
+  }
+
+  /// Performs layout based on how [childManager] provides children.
+  ///
+  /// From the current scroll offset, the minimum index and maximum index that
+  /// is visible in the viewport can be calculated. The index range of the
+  /// currently active children can also be acquired by looking directly at
+  /// the current child list. This function has to modify the current index
+  /// range to match the target index range by removing children that are no
+  /// longer visible and creating those that are visible but not yet provided
+  /// by [childManager].
   @override
   void performLayout() {
-    double currentOffset = 0.0;
+    final BoxConstraints childConstraints =
+      constraints.copyWith(
+        minHeight: _itemExtent,
+        maxHeight: _itemExtent,
+        minWidth: 0.0,
+      );
+
+    // The height, in pixel, that children will be visible and might be laid out
+    // and painted.
+    double visibleHeight = size.height;
+    // If renderChildrenOutsideViewport is true, we spawn extra children by
+    // doubling the visibility range, those that are in the backside of the
+    // cylinder won't be painted anyway.
+    if (renderChildrenOutsideViewport)
+      visibleHeight *= 2;
+
+    final double firstVisibleOffset =
+        offset.pixels + _itemExtent / 2 - visibleHeight / 2;
+    final double lastVisibleOffset = firstVisibleOffset + visibleHeight;
+
+    // The index range that we want to spawn children. We find indexes that
+    // are in the interval [firstVisibleOffset, lastVisibleOffset).
+    int targetFirstIndex = scrollOffsetToIndex(firstVisibleOffset);
+    int targetLastIndex = scrollOffsetToIndex(lastVisibleOffset);
+    // Because we exclude lastVisibleOffset, if there's a new child starting at
+    // that offset, it is removed.
+    if (targetLastIndex * _itemExtent == lastVisibleOffset)
+      targetLastIndex--;
+
+    // Validates the target index range.
+    while (!childManager.childExistsAt(targetFirstIndex) && targetFirstIndex <= targetLastIndex)
+      targetFirstIndex++;
+    while (!childManager.childExistsAt(targetLastIndex) && targetFirstIndex <= targetLastIndex)
+      targetLastIndex--;
+
+    // If it turns out there's no children to layout, we remove old children and
+    // return.
+    if (targetFirstIndex > targetLastIndex) {
+      while (firstChild != null)
+        _destroyChild(firstChild);
+      return;
+    }
+
+    // Now there are 2 cases:
+    //  - The target index range and our current index range have intersection:
+    //    We shorten and extend our current child list so that the two lists
+    //    match. Most of the time we are in this case.
+    //  - The target list and our current child list have no intersection:
+    //    We first remove all children and then add one child from the target
+    //    list => this case becomes the other case.
+
+    // Case when there is no intersection.
+    if (childCount > 0 &&
+        (indexOf(firstChild) > targetLastIndex || indexOf(lastChild) < targetFirstIndex)) {
+      while (firstChild != null)
+        _destroyChild(firstChild);
+    }
+
+    // If there is no child at this stage, we add the first one that is in
+    // target range.
+    if (childCount == 0) {
+      _createChild(targetFirstIndex);
+      _layoutChild(firstChild, childConstraints, targetFirstIndex);
+    }
+
+    int currentFirstIndex = indexOf(firstChild);
+    int currentLastIndex = indexOf(lastChild);
+
+    // Remove all unnecessary children by shortening the current child list, in
+    // both directions.
+    while (currentFirstIndex < targetFirstIndex) {
+      _destroyChild(firstChild);
+      currentFirstIndex++;
+    }
+    while (currentLastIndex > targetLastIndex) {
+      _destroyChild(lastChild);
+      currentLastIndex--;
+    }
+
+    // Relayout all active children.
     RenderBox child = firstChild;
-    final BoxConstraints innerConstraints =
-        constraints.copyWith(
-          minHeight: _itemExtent,
-          maxHeight: _itemExtent,
-          minWidth: 0.0,
-        );
     while (child != null) {
-      child.layout(innerConstraints, parentUsesSize: true);
-      final ListWheelParentData childParentData = child.parentData;
-      // Centers the child in the cross axis. Consider making it configurable.
-      final double crossPosition = size.width / 2.0 - child.size.width / 2.0;
-      childParentData.offset = new Offset(crossPosition, currentOffset);
-      currentOffset += _itemExtent;
+      child.layout(childConstraints, parentUsesSize: true);
       child = childAfter(child);
     }
 
+    // Spawning new children that are actually visible but not in child list yet.
+    while (currentFirstIndex > targetFirstIndex) {
+      _createChild(currentFirstIndex - 1);
+      _layoutChild(firstChild, childConstraints, --currentFirstIndex);
+    }
+    while (currentLastIndex < targetLastIndex) {
+      _createChild(currentLastIndex + 1, after: lastChild);
+      _layoutChild(lastChild, childConstraints, ++currentLastIndex);
+    }
+
     offset.applyViewportDimension(_viewportExtent);
-    offset.applyContentDimensions(_minScrollExtent, _maxScrollExtent);
+
+    // Applying content dimensions bases on how the childManager builds widgets:
+    // if it is available to provide a child just out of target range, then
+    // we don't know whether there's a limit yet, and set the dimension to the
+    // estimated value. Otherwise, we set the dimension limited to our target
+    // range.
+    final double minScrollExtent = childManager.childExistsAt(targetFirstIndex - 1)
+      ? _minEstimatedScrollExtent
+      : indexToScrollOffset(targetFirstIndex);
+    final double maxScrollExtent = childManager.childExistsAt(targetLastIndex + 1)
+        ? _maxEstimatedScrollExtent
+        : indexToScrollOffset(targetLastIndex);
+    offset.applyContentDimensions(minScrollExtent, maxScrollExtent);
   }
 
   bool _shouldClipAtCurrentOffset() {
     final double highestUntransformedPaintY =
         _getUntransformedPaintingCoordinateY(0.0);
     return highestUntransformedPaintY < 0.0
-        || size.height < highestUntransformedPaintY + _maxScrollExtent + _itemExtent;
+        || size.height < highestUntransformedPaintY + _maxEstimatedScrollExtent + _itemExtent;
   }
 
   @override
@@ -576,33 +740,12 @@
     }
   }
 
-  /// Visits all the children until one is partially visible in the viewport.
-  RenderBox _getFirstVisibleChild() {
-    assert(childCount > 0);
-    final double firstVisibleLayoutOffset = _firstVisibleLayoutOffset;
-
-    RenderBox child = firstChild;
-    ListWheelParentData childParentData = child.parentData;
-
-    while (childParentData != null
-        && childParentData.offset.dy <= firstVisibleLayoutOffset) {
-      child = childAfter(child);
-      childParentData = child?.parentData;
-    }
-
-    return child;
-  }
-
   /// Paints all children visible in the current viewport.
   void _paintVisibleChildren(PaintingContext context, Offset offset) {
-    assert(childCount > 0);
-    final double lastVisibleLayoutOffset = _lastVisibleLayoutOffset;
-
-    RenderBox childToPaint = _getFirstVisibleChild();
+    RenderBox childToPaint = firstChild;
     ListWheelParentData childParentData = childToPaint?.parentData;
 
-    while (childParentData != null
-        && childParentData.offset.dy < lastVisibleLayoutOffset) {
+    while (childParentData != null) {
       _paintTransformedChild(childToPaint, context, offset, childParentData.offset);
       childToPaint = childAfter(childToPaint);
       childParentData = childToPaint?.parentData;
diff --git a/packages/flutter/lib/src/widgets/list_wheel_scroll_view.dart b/packages/flutter/lib/src/widgets/list_wheel_scroll_view.dart
index a2f168e..ecac23d 100644
--- a/packages/flutter/lib/src/widgets/list_wheel_scroll_view.dart
+++ b/packages/flutter/lib/src/widgets/list_wheel_scroll_view.dart
@@ -2,6 +2,7 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 import 'dart:async';
+import 'dart:collection';
 import 'dart:math' as math;
 
 import 'package:flutter/animation.dart';
@@ -20,6 +21,176 @@
 import 'scroll_position_with_single_context.dart';
 import 'scrollable.dart';
 
+/// A delegate that supplies children for [ListWheelScrollView].
+///
+/// [ListWheelScrollView] lazily constructs its children during layout to avoid
+/// creating more children than are visible through the [Viewport]. This
+/// delegate is responsible for providing children to [ListWheelScrollView]
+/// during that stage.
+///
+/// See also:
+///  * [ListWheelChildListDelegate], a delegate that supplies children using an
+///    explicit list.
+///  * [ListWheelChildLoopingListDelegate], a delegate that supplies infinite
+///    children by looping an explicit list.
+///  * [ListWheelChildBuilderDelegate], a delegate that supplies children using
+///    a builder callback.
+abstract class ListWheelChildDelegate {
+  /// Return the child at the given index. If the child at the given
+  /// index does not exist, return null.
+  Widget build(BuildContext context, int index);
+
+  /// Returns an estimate of the number of children this delegate will build.
+  int get estimatedChildCount;
+
+  /// Returns the true index for a child built at a given index. Defaults to
+  /// the given index, however if the delegate is [ListWheelChildLoopingListDelegate],
+  /// this value is the index of the true element that the delegate is looping to.
+  ///
+  ///
+  /// Example: [ListWheelChildLoopingListDelegate] is built by looping a list of
+  /// length 8. Then, trueIndexOf(10) = 2 and trueIndexOf(-5) = 3.
+  int trueIndexOf(int index) => index;
+
+  /// Called to check whether this and the old delegate are actually 'different',
+  /// so that the caller can decide to rebuild or not.
+  bool shouldRebuild(covariant ListWheelChildDelegate oldDelegate);
+}
+
+/// A delegate that supplies children for [ListWheelScrollView] using an
+/// explicit list.
+///
+/// [ListWheelScrollView] lazily constructs its children to avoid creating more
+/// children than are visible through the [Viewport]. This delegate provides
+/// children using an explicit list, which is convenient but reduces the benefit
+/// of building children lazily.
+///
+/// In general building all the widgets in advance is not efficient. It is
+/// better to create a delegate that builds them on demand using
+/// [ListWheelChildBuilderDelegate] or by subclassing [ListWheelChildDelegate]
+/// directly.
+///
+/// This class is provided for the cases where either the list of children is
+/// known well in advance (ideally the children are themselves compile-time
+/// constants, for example), and therefore will not be built each time the
+/// delegate itself is created, or the list is small, such that it's likely
+/// always visible (and thus there is nothing to be gained by building it on
+/// demand). For example, the body of a dialog box might fit both of these
+/// conditions.
+class ListWheelChildListDelegate extends ListWheelChildDelegate {
+  /// Constructs the delegate from a concrete list of children.
+  ListWheelChildListDelegate({@required this.children}) : assert(children != null);
+
+  /// The list containing all children that can be supplied.
+  final List<Widget> children;
+
+  @override
+  int get estimatedChildCount => children.length;
+
+  @override
+  Widget build(BuildContext context, int index) {
+    if (index < 0 || index >= children.length)
+      return null;
+    return children[index];
+  }
+
+  @override
+  bool shouldRebuild(covariant ListWheelChildListDelegate oldDelegate) {
+    return children != oldDelegate.children;
+  }
+}
+
+/// A delegate that supplies infinite children for [ListWheelScrollView] by
+/// looping an explicit list.
+///
+/// [ListWheelScrollView] lazily constructs its children to avoid creating more
+/// children than are visible through the [Viewport]. This delegate provides
+/// children using an explicit list, which is convenient but reduces the benefit
+/// of building children lazily.
+///
+/// In general building all the widgets in advance is not efficient. It is
+/// better to create a delegate that builds them on demand using
+/// [ListWheelChildBuilderDelegate] or by subclassing [ListWheelChildDelegate]
+/// directly.
+///
+/// This class is provided for the cases where either the list of children is
+/// known well in advance (ideally the children are themselves compile-time
+/// constants, for example), and therefore will not be built each time the
+/// delegate itself is created, or the list is small, such that it's likely
+/// always visible (and thus there is nothing to be gained by building it on
+/// demand). For example, the body of a dialog box might fit both of these
+/// conditions.
+class ListWheelChildLoopingListDelegate extends ListWheelChildDelegate {
+  /// Constructs the delegate from a concrete list of children.
+  ListWheelChildLoopingListDelegate({@required this.children}) : assert(children != null);
+
+  /// The list containing all children that can be supplied.
+  final List<Widget> children;
+
+  @override
+  int get estimatedChildCount => null;
+
+  @override
+  int trueIndexOf(int index) => index % children.length;
+
+  @override
+  Widget build(BuildContext context, int index) {
+    if (children.isEmpty)
+      return null;
+    return children[index % children.length];
+  }
+
+  @override
+  bool shouldRebuild(covariant ListWheelChildLoopingListDelegate oldDelegate) {
+    return children != oldDelegate.children;
+  }
+}
+
+/// A delegate that supplies children for [ListWheelScrollView] using a builder
+/// callback.
+///
+/// [ListWheelScrollView] lazily constructs its children to avoid creating more
+/// children than are visible through the [Viewport]. This delegate provides
+/// children using an [IndexedWidgetBuilder] callback, so that the children do
+/// not have to be built until they are displayed.
+class ListWheelChildBuilderDelegate extends ListWheelChildDelegate {
+  /// Constructs the delegate from a builder callback.
+  ListWheelChildBuilderDelegate({
+    @required this.builder,
+    this.childCount,
+  }) : assert(builder != null);
+
+  /// Called lazily to build children.
+  final IndexedWidgetBuilder builder;
+
+  /// {@template flutter.widgets.wheelList.childCount}
+  /// If non-null, [childCount] is the maximum number of children that can be
+  /// provided, and children are available from 0 to [childCount] - 1.
+  ///
+  /// If null, then the lower and upper limit are not known. However the [builder]
+  /// must provide children for a contiguous segment. If the builder returns null
+  /// at some index, the segment terminates there.
+  /// {@endtemplate}
+  final int childCount;
+
+  @override
+  int get estimatedChildCount => childCount;
+
+  @override
+  Widget build(BuildContext context, int index) {
+    if (childCount == null)
+      return builder(context, index);
+    if (index < 0 || index >= childCount)
+      return null;
+    return builder(context, index);
+  }
+
+  @override
+  bool shouldRebuild(covariant ListWheelChildBuilderDelegate oldDelegate) {
+    return builder != oldDelegate.builder || childCount != oldDelegate.childCount;
+  }
+}
+
 /// A controller for scroll views whose items have the same size.
 ///
 /// Similar to a standard [ScrollController] but with the added convenience
@@ -388,8 +559,9 @@
 /// The children are rendered as if rotating on a wheel instead of scrolling on
 /// a plane.
 class ListWheelScrollView extends StatefulWidget {
-  /// Creates a box in which children are scrolled on a wheel.
-  const ListWheelScrollView({
+  /// Constructs a list in which children are scrolled a wheel. Its children
+  /// are passed to a delegate and lazily built during layout.
+  ListWheelScrollView({
     Key key,
     this.controller,
     this.physics,
@@ -402,8 +574,43 @@
     this.onSelectedItemChanged,
     this.clipToSize = true,
     this.renderChildrenOutsideViewport = false,
-    @required this.children,
-  }) : assert(diameterRatio != null),
+    @required List<Widget> children,
+  }) : assert(children != null),
+       assert(diameterRatio != null),
+       assert(diameterRatio > 0.0, RenderListWheelViewport.diameterRatioZeroMessage),
+       assert(perspective != null),
+       assert(perspective > 0),
+       assert(perspective <= 0.01, RenderListWheelViewport.perspectiveTooHighMessage),
+       assert(magnification > 0),
+       assert(itemExtent != null),
+       assert(itemExtent > 0),
+       assert(clipToSize != null),
+       assert(renderChildrenOutsideViewport != null),
+       assert(
+         !renderChildrenOutsideViewport || !clipToSize,
+         RenderListWheelViewport.clipToSizeAndRenderChildrenOutsideViewportConflict,
+       ),
+       childDelegate = new ListWheelChildListDelegate(children: children),
+       super(key: key);
+
+  /// Constructs a list in which children are scrolled a wheel. Its children
+  /// are managed by a delegate and are lazily built during layout.
+  const ListWheelScrollView.useDelegate({
+    Key key,
+    this.controller,
+    this.physics,
+    this.diameterRatio = RenderListWheelViewport.defaultDiameterRatio,
+    this.perspective = RenderListWheelViewport.defaultPerspective,
+    this.offAxisFraction = 0.0,
+    this.useMagnifier = false,
+    this.magnification = 1.0,
+    @required this.itemExtent,
+    this.onSelectedItemChanged,
+    this.clipToSize = true,
+    this.renderChildrenOutsideViewport = false,
+    @required this.childDelegate,
+  }) : assert(childDelegate != null),
+       assert(diameterRatio != null),
        assert(diameterRatio > 0.0, RenderListWheelViewport.diameterRatioZeroMessage),
        assert(perspective != null),
        assert(perspective > 0),
@@ -472,8 +679,8 @@
   /// {@macro flutter.rendering.wheelList.renderChildrenOutsideViewport}
   final bool renderChildrenOutsideViewport;
 
-  /// List of children to scroll on top of the cylinder.
-  final List<Widget> children;
+  /// A delegate that helps lazily instantiating child.
+  final ListWheelChildDelegate childDelegate;
 
   @override
   _ListWheelScrollViewState createState() => new _ListWheelScrollViewState();
@@ -517,7 +724,8 @@
           final int currentItemIndex = metrics.itemIndex;
           if (currentItemIndex != _lastReportedItemIndex) {
             _lastReportedItemIndex = currentItemIndex;
-            widget.onSelectedItemChanged(currentItemIndex);
+            final int trueIndex = widget.childDelegate.trueIndexOf(currentItemIndex);
+            widget.onSelectedItemChanged(trueIndex);
           }
         }
         return false;
@@ -537,7 +745,7 @@
             clipToSize: widget.clipToSize,
             renderChildrenOutsideViewport: widget.renderChildrenOutsideViewport,
             offset: offset,
-            children: widget.children,
+            childDelegate: widget.childDelegate,
           );
         },
       ),
@@ -545,11 +753,159 @@
   }
 }
 
+/// Element that supports building children lazily for [ListWheelViewport].
+class ListWheelElement extends RenderObjectElement implements ListWheelChildManager {
+  /// Creates an element that lazily builds children for the given widget.
+  ListWheelElement(ListWheelViewport widget) : super(widget);
+
+  @override
+  ListWheelViewport get widget => super.widget;
+
+  @override
+  RenderListWheelViewport get renderObject => super.renderObject;
+
+  // We inflate widgets at two different times:
+  //  1. When we ourselves are told to rebuild (see performRebuild).
+  //  2. When our render object needs a new child (see createChild).
+  // In both cases, we cache the results of calling into our delegate to get the
+  // widget, so that if we do case 2 later, we don't call the builder again.
+  // Any time we do case 1, though, we reset the cache.
+
+  /// A cache of widgets so that we don't have to rebuild every time.
+  final Map<int, Widget> _childWidgets = new HashMap<int, Widget>();
+
+  /// The map containing all active child elements. SplayTreeMap is used so that
+  /// we have all elements ordered and iterable by their keys.
+  final SplayTreeMap<int, Element> _childElements = new SplayTreeMap<int, Element>();
+
+  @override
+  void update(ListWheelViewport newWidget) {
+    final ListWheelViewport oldWidget = widget;
+    super.update(newWidget);
+    final ListWheelChildDelegate newDelegate = newWidget.childDelegate;
+    final ListWheelChildDelegate oldDelegate = oldWidget.childDelegate;
+    if (newDelegate != oldDelegate &&
+        (newDelegate.runtimeType != oldDelegate.runtimeType || newDelegate.shouldRebuild(oldDelegate)))
+      performRebuild();
+  }
+
+  @override
+  int get childCount => widget.childDelegate.estimatedChildCount;
+
+  @override
+  void performRebuild() {
+    _childWidgets.clear();
+    super.performRebuild();
+    if (_childElements.isEmpty)
+      return;
+
+    final int firstIndex = _childElements.firstKey();
+    final int lastIndex = _childElements.lastKey();
+
+    for (int index = firstIndex; index <= lastIndex; ++index) {
+      final Element newChild = updateChild(_childElements[index], retrieveWidget(index), index);
+      if (newChild != null) {
+        _childElements[index] = newChild;
+      } else {
+        _childElements.remove(index);
+      }
+    }
+  }
+
+  /// Asks the underlying delegate for a widget at the given index.
+  ///
+  /// Normally the builder is only called once for each index and the result
+  /// will be cached. However when the element is rebuilt, the cache will be
+  /// cleared.
+  Widget retrieveWidget(int index) {
+    return _childWidgets.putIfAbsent(index, () => widget.childDelegate.build(this, index));
+  }
+
+  @override
+  bool childExistsAt(int index) => retrieveWidget(index) != null;
+
+  @override
+  void createChild(int index, { @required RenderBox after }) {
+    owner.buildScope(this, () {
+      final bool insertFirst = after == null;
+      assert(insertFirst || _childElements[index - 1] != null);
+      final Element newChild =
+        updateChild(_childElements[index], retrieveWidget(index), index);
+      if (newChild != null) {
+        _childElements[index] = newChild;
+      } else {
+        _childElements.remove(index);
+      }
+    });
+  }
+
+  @override
+  void removeChild(RenderBox child) {
+    final int index = renderObject.indexOf(child);
+    owner.buildScope(this, () {
+      assert(_childElements.containsKey(index));
+      final Element result = updateChild(_childElements[index], null, index);
+      assert(result == null);
+      _childElements.remove(index);
+      assert(!_childElements.containsKey(index));
+    });
+  }
+
+  @override
+  Element updateChild(Element child, Widget newWidget, dynamic newSlot) {
+    final ListWheelParentData oldParentData = child?.renderObject?.parentData;
+    final Element newChild = super.updateChild(child, newWidget, newSlot);
+    final ListWheelParentData newParentData = newChild?.renderObject?.parentData;
+    if (newParentData != null) {
+      newParentData.index = newSlot;
+      if (oldParentData != null)
+        newParentData.offset = oldParentData.offset;
+    }
+
+    return newChild;
+  }
+
+  @override
+  void insertChildRenderObject(RenderObject child, int slot) {
+    final RenderListWheelViewport renderObject = this.renderObject;
+    assert(renderObject.debugValidateChild(child));
+    renderObject.insert(child, after: _childElements[slot - 1]?.renderObject);
+    assert(renderObject == this.renderObject);
+  }
+
+  @override
+  void moveChildRenderObject(RenderObject child, dynamic slot) {
+    const String moveChildRenderObjectErrorMessage =
+        'Currently we maintain the list in contiguous increasing order, so '
+        'moving children around is not allowed.';
+    assert(false, moveChildRenderObjectErrorMessage);
+  }
+
+  @override
+  void removeChildRenderObject(RenderObject child) {
+    assert(child.parent == renderObject);
+    renderObject.remove(child);
+  }
+
+  @override
+  void visitChildren(ElementVisitor visitor) {
+    _childElements.forEach((int key, Element child) {
+      visitor(child);
+    });
+  }
+
+  @override
+  void forgetChild(Element child) {
+    _childElements.remove(child.slot);
+  }
+
+}
+
 /// A viewport showing a subset of children on a wheel.
 ///
 /// Typically used with [ListWheelScrollView], this viewport is similar to
 /// [Viewport] in that it shows a subset of children in a scrollable based
-/// on the scrolling offset and the childrens' dimensions. But uses
+/// on the scrolling offset and the children's dimensions. But uses
 /// [RenderListWheelViewport] to display the children on a wheel.
 ///
 /// See also:
@@ -557,8 +913,8 @@
 ///  * [ListWheelScrollView], widget that combines this viewport with a scrollable.
 ///  * [RenderListWheelViewport], the render object that renders the children
 ///    on a wheel.
-class ListWheelViewport extends MultiChildRenderObjectWidget {
-  /// Create a viewport where children are rendered onto a wheel.
+class ListWheelViewport extends RenderObjectWidget {
+  /// Creates a viewport where children are rendered onto a wheel.
   ///
   /// The [diameterRatio] argument defaults to 2.0 and must not be null.
   ///
@@ -572,7 +928,7 @@
   /// not be null.
   ///
   /// The [offset] argument must be provided and must not be null.
-  ListWheelViewport({
+  const ListWheelViewport({
     Key key,
     this.diameterRatio = RenderListWheelViewport.defaultDiameterRatio,
     this.perspective = RenderListWheelViewport.defaultPerspective,
@@ -583,8 +939,9 @@
     this.clipToSize = true,
     this.renderChildrenOutsideViewport = false,
     @required this.offset,
-    List<Widget> children,
-  }) : assert(offset != null),
+    @required this.childDelegate,
+  }) : assert(childDelegate != null),
+       assert(offset != null),
        assert(diameterRatio != null),
        assert(diameterRatio > 0, RenderListWheelViewport.diameterRatioZeroMessage),
        assert(perspective != null),
@@ -598,7 +955,7 @@
          !renderChildrenOutsideViewport || !clipToSize,
          RenderListWheelViewport.clipToSizeAndRenderChildrenOutsideViewportConflict,
        ),
-       super(key: key, children: children);
+       super(key: key);
 
   /// {@macro flutter.rendering.wheelList.diameterRatio}
   final double diameterRatio;
@@ -628,9 +985,18 @@
   /// in the viewport.
   final ViewportOffset offset;
 
+  /// A delegate that lazily instantiates children.
+  final ListWheelChildDelegate childDelegate;
+
+  @override
+  ListWheelElement createElement() => new ListWheelElement(this);
+
   @override
   RenderListWheelViewport createRenderObject(BuildContext context) {
+    final ListWheelElement childManager = context;
     return new RenderListWheelViewport(
+      childManager: childManager,
+      offset: offset,
       diameterRatio: diameterRatio,
       perspective: perspective,
       offAxisFraction: offAxisFraction,
@@ -639,13 +1005,13 @@
       itemExtent: itemExtent,
       clipToSize: clipToSize,
       renderChildrenOutsideViewport: renderChildrenOutsideViewport,
-      offset: offset,
     );
   }
 
   @override
   void updateRenderObject(BuildContext context, RenderListWheelViewport renderObject) {
     renderObject
+      ..offset = offset
       ..diameterRatio = diameterRatio
       ..perspective = perspective
       ..offAxisFraction = offAxisFraction
@@ -653,7 +1019,6 @@
       ..magnification = magnification
       ..itemExtent = itemExtent
       ..clipToSize = clipToSize
-      ..renderChildrenOutsideViewport = renderChildrenOutsideViewport
-      ..offset = offset;
+      ..renderChildrenOutsideViewport = renderChildrenOutsideViewport;
   }
 }
diff --git a/packages/flutter/test/cupertino/picker_test.dart b/packages/flutter/test/cupertino/picker_test.dart
index 7c4a046..cfdf00a 100644
--- a/packages/flutter/test/cupertino/picker_test.dart
+++ b/packages/flutter/test/cupertino/picker_test.dart
@@ -240,11 +240,10 @@
         1000.0,
       );
 
-      expect(
-        tester.getTopLeft(find.widgetWithText(Container, '0')).dy,
-        // Should have been flung far enough to go off screen.
-        greaterThan(600.0),
-      );
+      // Should have been flung far enough that even the first item goes off
+      // screen and gets removed.
+      expect(find.widgetWithText(Container, '0').evaluate().isEmpty, true);
+
       expect(
         selectedItems,
         // This specific throw was fast enough that each scroll update landed
diff --git a/packages/flutter/test/widgets/list_wheel_scroll_view_test.dart b/packages/flutter/test/widgets/list_wheel_scroll_view_test.dart
index 0c199c8..2eb88f4 100644
--- a/packages/flutter/test/widgets/list_wheel_scroll_view_test.dart
+++ b/packages/flutter/test/widgets/list_wheel_scroll_view_test.dart
@@ -41,11 +41,11 @@
 
     testWidgets('ListWheelScrollView can have zero child', (WidgetTester tester) async {
       await tester.pumpWidget(
-        const Directionality(
+        Directionality(
           textDirection: TextDirection.ltr,
           child: ListWheelScrollView(
             itemExtent: 50.0,
-            children: <Widget>[],
+            children: const <Widget>[],
           ),
         ),
       );
@@ -68,6 +68,149 @@
     });
   });
 
+  group('infinite scrolling', () {
+    testWidgets('infinite looping list', (WidgetTester tester) async {
+      final FixedExtentScrollController controller =
+        new FixedExtentScrollController();
+
+      await tester.pumpWidget(
+        new Directionality(
+          textDirection: TextDirection.ltr,
+          child: new ListWheelScrollView.useDelegate(
+            controller: controller,
+            itemExtent: 100.0,
+            onSelectedItemChanged: (_) {},
+            childDelegate: new ListWheelChildLoopingListDelegate(
+              children: List<Widget>.generate(10, (int index) {
+                return new Container(
+                  width: 400.0,
+                  height: 100.0,
+                  child: Text(index.toString()),
+                );
+              }),
+            ),
+          ),
+        ),
+      );
+
+      // The first item is at the center of the viewport.
+      expect(
+      tester.getTopLeft(find.widgetWithText(Container, '0')),
+      const Offset(0.0, 250.0)
+      );
+
+      // The last item is just before the first item.
+      expect(
+        tester.getTopLeft(find.widgetWithText(Container, '9')),
+        const Offset(0.0, 150.0)
+      );
+
+      controller.jumpTo(1000.0);
+      await tester.pump();
+
+      // We have passed the end of the list, the list should have looped back.
+      expect(
+        tester.getTopLeft(find.widgetWithText(Container, '0')),
+        const Offset(0.0, 250.0)
+      );
+    });
+
+    testWidgets('infinite child builder', (WidgetTester tester) async {
+      final FixedExtentScrollController controller =
+        new FixedExtentScrollController();
+
+      await tester.pumpWidget(
+        new Directionality(
+          textDirection: TextDirection.ltr,
+          child: new ListWheelScrollView.useDelegate(
+            controller: controller,
+            itemExtent: 100.0,
+            onSelectedItemChanged: (_) {},
+            childDelegate: new ListWheelChildBuilderDelegate(
+              builder: (BuildContext context, int index) {
+                return new Container(
+                  width: 400.0,
+                  height: 100.0,
+                  child: Text(index.toString()),
+                );
+              },
+            ),
+          ),
+        )
+      );
+
+      // Can be scrolled infinitely for negative indexes.
+      controller.jumpTo(-100000.0);
+      await tester.pump();
+      expect(
+        tester.getTopLeft(find.widgetWithText(Container, '-1000')),
+        const Offset(0.0, 250.0)
+      );
+
+      // Can be scrolled infinitely for positive indexes.
+      controller.jumpTo(100000.0);
+      await tester.pump();
+      expect(
+        tester.getTopLeft(find.widgetWithText(Container, '1000')),
+        const Offset(0.0, 250.0)
+      );
+    });
+
+    testWidgets('child builder with lower and upper limits', (WidgetTester tester) async {
+      final List<int> paintedChildren = <int>[];
+
+      final FixedExtentScrollController controller =
+        new FixedExtentScrollController(initialItem: -10);
+
+      await tester.pumpWidget(
+        new Directionality(
+          textDirection: TextDirection.ltr,
+          child: new ListWheelScrollView.useDelegate(
+            controller: controller,
+            itemExtent: 100.0,
+            onSelectedItemChanged: (_) {},
+            childDelegate: new ListWheelChildBuilderDelegate(
+              builder: (BuildContext context, int index) {
+                if (index < -15 || index > -5)
+                  return null;
+                return new Container(
+                  width: 400.0,
+                  height: 100.0,
+                  child: new CustomPaint(
+                    painter: new TestCallbackPainter(onPaint: () {
+                      paintedChildren.add(index);
+                    }),
+                  ),
+                );
+              },
+            ),
+          ),
+        )
+      );
+
+      expect(paintedChildren, <int>[-13, -12, -11, -10, -9, -8, -7]);
+
+      // Flings with high velocity and stop at the lower limit.
+      paintedChildren.clear();
+      await tester.fling(
+        find.byType(ListWheelScrollView),
+        const Offset(0.0, 1000.0),
+        1000.0,
+      );
+      await tester.pumpAndSettle();
+      expect(controller.selectedItem, -15);
+
+      // Flings with high velocity and stop at the upper limit.
+      await tester.fling(
+        find.byType(ListWheelScrollView),
+        const Offset(0.0, -1000.0),
+        1000.0,
+      );
+      await tester.pumpAndSettle();
+      expect(controller.selectedItem, -5);
+    });
+  });
+
   group('layout', () {
     testWidgets("ListWheelScrollView takes parent's size with small children", (WidgetTester tester) async {
       await tester.pumpWidget(
@@ -113,11 +256,11 @@
 
     testWidgets("ListWheelScrollView children can't be bigger than itemExtent", (WidgetTester tester) async {
       await tester.pumpWidget(
-        const Directionality(
+        Directionality(
           textDirection: TextDirection.ltr,
           child: ListWheelScrollView(
             itemExtent: 50.0,
-            children: <Widget>[
+            children: const <Widget>[
               SizedBox(
                 height: 200.0,
                 width: 200.0,
@@ -132,6 +275,80 @@
       expect(tester.getSize(find.byType(SizedBox)), const Size(200.0, 50.0));
       expect(find.text('blah'), findsOneWidget);
     });
+
+    testWidgets('builder is never called twice for same index', (WidgetTester tester) async {
+      final Set<int> builtChildren = Set<int>();
+      final FixedExtentScrollController controller =
+        new FixedExtentScrollController();
+
+      await tester.pumpWidget(
+        new Directionality(
+          textDirection: TextDirection.ltr,
+          child: new ListWheelScrollView.useDelegate(
+            controller: controller,
+            itemExtent: 100.0,
+            onSelectedItemChanged: (_) {},
+            childDelegate: new ListWheelChildBuilderDelegate(
+              builder: (BuildContext context, int index) {
+                expect(builtChildren.contains(index), false);
+                builtChildren.add(index);
+
+                return new Container(
+                  width: 400.0,
+                  height: 100.0,
+                  child: Text(index.toString()),
+                );
+              },
+            ),
+          ),
+        )
+      );
+
+      // Scrolls up and down to check if builder is called twice.
+      controller.jumpTo(-10000.0);
+      await tester.pump();
+      controller.jumpTo(10000.0);
+      await tester.pump();
+      controller.jumpTo(-10000.0);
+      await tester.pump();
+    });
+
+    testWidgets('only visible children are maintained as children of the rendered viewport', (WidgetTester tester) async {
+      final FixedExtentScrollController controller =
+        new FixedExtentScrollController();
+
+      await tester.pumpWidget(
+        new Directionality(
+          textDirection: TextDirection.ltr,
+          child: new ListWheelScrollView(
+            controller: controller,
+            itemExtent: 100.0,
+            onSelectedItemChanged: (_) {},
+            children: List<Widget>.generate(16, (int index) {
+              return new Text(index.toString());
+            }),
+          ),
+        )
+      );
+
+      final RenderListWheelViewport viewport = tester.firstRenderObject(find.byType(Text)).parent;
+
+      // Item 0 is in the middle. There are 3 children visible after it, so the
+      // value of childCount should be 4.
+      expect(viewport.childCount, 4);
+
+      controller.jumpToItem(8);
+      await tester.pump();
+      // Item 8 is in the middle. There are 3 children visible before it and 3
+      // after it, so the value of childCount should be 7.
+      expect(viewport.childCount, 7);
+
+      controller.jumpToItem(15);
+      await tester.pump();
+      // Item 15 is in the middle. There are 3 children visible before it, so the
+      // value of childCount should be 4.
+      expect(viewport.childCount, 4);
+    });
   });
 
   group('pre-transform viewport', () {
@@ -1014,6 +1231,10 @@
     await tester.pumpAndSettle();
     expect(controller.offset, 500.0);
 
+    tester.renderObject(find.byWidget(outerChildren[7])).showOnScreen();
+    await tester.pumpAndSettle();
+    expect(controller.offset, 700.0);
+
     tester.renderObject(find.byWidget(innerChildren[9])).showOnScreen();
     await tester.pumpAndSettle();
     expect(controller.offset, 900.0);