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);