CupertinoPicker part 4 - create CupertinoPicker and add gallery demo (#14091) * controller, position and test * Make controllers swappable * WIP * Create a ListWheelScrollPhysics * Created picker and gallery demo and testing now * Works. Ready to document and test. * Document and add tests. Make the scroll controller more generic. * minor cleanup * review * review * fix tests * stop using TransformLayers for now
diff --git a/examples/flutter_gallery/lib/demo/cupertino/cupertino.dart b/examples/flutter_gallery/lib/demo/cupertino/cupertino.dart index 7547118..4e32ff2 100644 --- a/examples/flutter_gallery/lib/demo/cupertino/cupertino.dart +++ b/examples/flutter_gallery/lib/demo/cupertino/cupertino.dart
@@ -6,5 +6,6 @@ export 'cupertino_buttons_demo.dart'; export 'cupertino_dialog_demo.dart'; export 'cupertino_navigation_demo.dart'; +export 'cupertino_picker_demo.dart'; export 'cupertino_slider_demo.dart'; export 'cupertino_switch_demo.dart';
diff --git a/examples/flutter_gallery/lib/demo/cupertino/cupertino_picker_demo.dart b/examples/flutter_gallery/lib/demo/cupertino/cupertino_picker_demo.dart new file mode 100644 index 0000000..9c8490e --- /dev/null +++ b/examples/flutter_gallery/lib/demo/cupertino/cupertino_picker_demo.dart
@@ -0,0 +1,127 @@ +// Copyright 2017 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +import 'cupertino_navigation_demo.dart' show coolColorNames; + +const double _kPickerSheetHeight = 216.0; +const double _kPickerItemHeight = 32.0; + +class CupertinoPickerDemo extends StatefulWidget { + static const String routeName = '/cupertino/picker'; + + @override + _CupertinoPickerDemoState createState() => new _CupertinoPickerDemoState(); +} + +class _CupertinoPickerDemoState extends State<CupertinoPickerDemo> { + int _selectedItemIndex = 0; + + Widget _buildMenu() { + return new Container( + decoration: const BoxDecoration( + color: CupertinoColors.white, + border: const Border( + top: const BorderSide(color: const Color(0xFFBCBBC1), width: 0.0), + bottom: const BorderSide(color: const Color(0xFFBCBBC1), width: 0.0), + ), + ), + height: 44.0, + child: new Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: new SafeArea( + top: false, + bottom: false, + child: new DefaultTextStyle( + style: const TextStyle( + letterSpacing: -0.24, + fontSize: 17.0, + color: CupertinoColors.black, + ), + child: new Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: <Widget>[ + const Text('Favorite Color'), + new Text( + coolColorNames[_selectedItemIndex], + style: const TextStyle(color: CupertinoColors.inactiveGray), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildBottomPicker() { + final FixedExtentScrollController scrollController = + new FixedExtentScrollController(initialItem: _selectedItemIndex); + + return new SizedBox( + height: _kPickerSheetHeight, + child: new DefaultTextStyle( + style: const TextStyle( + color: CupertinoColors.black, + fontSize: 22.0, + ), + child: new GestureDetector( + // Blocks taps from propagating to the modal sheet and popping. + onTap: () {}, + child: new CupertinoPicker( + scrollController: scrollController, + itemExtent: _kPickerItemHeight, + backgroundColor: CupertinoColors.white, + onSelectedItemChanged: (int index) { + setState(() { + _selectedItemIndex = index; + }); + }, + children: new List<Widget>.generate(coolColorNames.length, (int index) { + return new Center(child: + new Text(coolColorNames[index]), + ); + }), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return new Scaffold( + appBar: new AppBar( + title: const Text('Cupertino Picker'), + ), + body: new DefaultTextStyle( + style: const TextStyle( + fontFamily: '.SF UI Text', + fontSize: 17.0, + color: CupertinoColors.black, + ), + child: new DecoratedBox( + decoration: const BoxDecoration(color: const Color(0xFFEFEFF4)), + child: new ListView( + children: <Widget>[ + const Padding(padding: const EdgeInsets.only(top: 32.0)), + new GestureDetector( + onTap: () async { + await showModalBottomSheet<Null>( + context: context, + builder: (BuildContext context) { + return _buildBottomPicker(); + }, + ); + }, + child: _buildMenu(), + ), + ], + ), + ), + ), + ); + } +}
diff --git a/examples/flutter_gallery/lib/gallery/item.dart b/examples/flutter_gallery/lib/gallery/item.dart index eef79d3..9f39a80 100644 --- a/examples/flutter_gallery/lib/gallery/item.dart +++ b/examples/flutter_gallery/lib/gallery/item.dart
@@ -307,6 +307,13 @@ buildRoute: (BuildContext context) => new CupertinoNavigationDemo(), ), new GalleryItem( + title: 'Pickers', + subtitle: 'Cupertino styled pickers', + category: 'Cupertino Components', + routeName: CupertinoPickerDemo.routeName, + buildRoute: (BuildContext context) => new CupertinoPickerDemo(), + ), + new GalleryItem( title: 'Sliders', subtitle: 'Cupertino styled sliders', category: 'Cupertino Components',
diff --git a/examples/flutter_gallery/test_driver/transitions_perf_test.dart b/examples/flutter_gallery/test_driver/transitions_perf_test.dart index 451fc37..0605380 100644 --- a/examples/flutter_gallery/test_driver/transitions_perf_test.dart +++ b/examples/flutter_gallery/test_driver/transitions_perf_test.dart
@@ -71,6 +71,7 @@ const Demo('Buttons'), const Demo('Dialogs'), const Demo('Navigation'), + const Demo('Pickers'), const Demo('Sliders'), const Demo('Switches'),
diff --git a/packages/flutter/lib/cupertino.dart b/packages/flutter/lib/cupertino.dart index ea5d0df..3920b48 100644 --- a/packages/flutter/lib/cupertino.dart +++ b/packages/flutter/lib/cupertino.dart
@@ -15,6 +15,7 @@ export 'src/cupertino/icons.dart'; export 'src/cupertino/nav_bar.dart'; export 'src/cupertino/page_scaffold.dart'; +export 'src/cupertino/picker.dart'; export 'src/cupertino/route.dart'; export 'src/cupertino/scrollbar.dart'; export 'src/cupertino/slider.dart';
diff --git a/packages/flutter/lib/src/cupertino/colors.dart b/packages/flutter/lib/src/cupertino/colors.dart index 62dc7b2..76b6c25 100644 --- a/packages/flutter/lib/src/cupertino/colors.dart +++ b/packages/flutter/lib/src/cupertino/colors.dart
@@ -37,11 +37,11 @@ /// Used in iOS 10 for light background fills such as the chat bubble background. static const Color lightBackgroundGray = const Color(0xFFE5E5EA); - /// Used in iOS 10 for unselected selectables such as tab bar items in their - /// inactive state. + /// Used in iOS 11 for unselected selectables such as tab bar items in their + /// inactive state or de-emphasized subtitles and details text. /// /// Not the same gray as disabled buttons etc. - static const Color inactiveGray = const Color(0xFF929292); + static const Color inactiveGray = const Color(0xFF8E8E93); /// Used for iOS 10 for destructive actions such as the delete actions in /// table view cells and dialogs.
diff --git a/packages/flutter/lib/src/cupertino/picker.dart b/packages/flutter/lib/src/cupertino/picker.dart new file mode 100644 index 0000000..2f18d6f --- /dev/null +++ b/packages/flutter/lib/src/cupertino/picker.dart
@@ -0,0 +1,189 @@ +// Copyright 2017 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// Color of the 'magnifier' lens border. +const Color _kHighlighterBorder = const Color(0xFF7F7F7F); +const Color _kDefaultBackground = const Color(0xFFD2D4DB); +/// Eyeballed value comparing with a native picker. +const double _kDefaultDiameterRatio = 1.1; +/// Opacity fraction value that hides the wheel above and below the 'magnifier' +/// lens with the same color as the background. +const double _kForegroundScreenOpacityFraction = 0.7; + +/// An iOS-styled picker. +/// +/// Displays the provided [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 +/// bottom of the screen. +/// +/// See also: +/// +/// * [ListWheelScrollView], the generic widget backing this picker without +/// the iOS design specific chrome. +/// * <https://developer.apple.com/ios/human-interface-guidelines/controls/pickers/> +class CupertinoPicker extends StatefulWidget { + const CupertinoPicker({ + Key key, + this.diameterRatio: _kDefaultDiameterRatio, + this.backgroundColor: _kDefaultBackground, + this.scrollController, + @required this.itemExtent, + @required this.onSelectedItemChanged, + @required this.children, + }) : assert(diameterRatio != null), + assert(diameterRatio > 0.0, RenderListWheelViewport.diameterRatioZeroMessage), + assert(itemExtent != null), + assert(itemExtent > 0), + super(key: key); + + /// Relative ratio between this picker's height and the simulated cylinder's diameter. + /// + /// Smaller values creates more pronounced curvatures in the scrollable wheel. + /// + /// For more details, see [ListWheelScrollView.diameterRatio]. + /// + /// Must not be null and defaults to `1.1` to visually mimic iOS. + final double diameterRatio; + + /// Background color behind the children. + /// + /// Defaults to a gray color in the iOS color palette. + final Color backgroundColor; + + /// A [FixedExtentScrollController] to read and control the current item. + /// + /// If null, an implicit one will be created internally. + final FixedExtentScrollController scrollController; + + /// The uniform height of all children. + /// + /// All children will be given the [BoxConstraints] to match this exact + /// height. Must not be null and must be positive. + final double itemExtent; + + /// An option callback when the currently centered item changes. + /// + /// Value changes when the item closest to the center changes. + /// + /// This can be called during scrolls and during ballistic flings. To get the + /// value only when the scrolling settles, use a [NotificationListener], + /// listen for [ScrollEndNotification] and read its [FixedExtentMetrics]. + final ValueChanged<int> onSelectedItemChanged; + + /// [Widget]s in the picker's scroll wheel. + final List<Widget> children; + + @override + State<StatefulWidget> createState() => new _CupertinoPickerState(); +} + +class _CupertinoPickerState extends State<CupertinoPicker> { + int _lastHapticIndex; + + void _handleSelectedItemChanged(int index) { + if (index != _lastHapticIndex) { + // TODO(xster): Insert haptic feedback with lighter knock. + // https://github.com/flutter/flutter/issues/13710. + _lastHapticIndex = index; + } + + if (widget.onSelectedItemChanged != null) { + widget.onSelectedItemChanged(index); + } + } + + /// Makes the fade to white edge gradients. + Widget _buildGradientScreen() { + return new Positioned.fill( + child: new IgnorePointer( + child: new Container( + decoration: const BoxDecoration( + gradient: const LinearGradient( + colors: const <Color>[ + const Color(0xFFFFFFFF), + const Color(0xF2FFFFFF), + const Color(0xDDFFFFFF), + const Color(0x00FFFFFF), + const Color(0x00FFFFFF), + const Color(0xDDFFFFFF), + const Color(0xF2FFFFFF), + const Color(0xFFFFFFFF), + ], + stops: const <double>[ + 0.0, 0.05, 0.09, 0.22, 0.78, 0.91, 0.95, 1.0, + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + ), + ), + ); + } + + /// Makes the magnifier lens look so that the colors are normal through + /// the lens and partially grayed out around it. + Widget _buildMagnifierScreen() { + final Color foreground = widget.backgroundColor?.withAlpha( + (widget.backgroundColor?.alpha * _kForegroundScreenOpacityFraction).toInt() + ); + + return new IgnorePointer( + child: new Column( + children: <Widget>[ + new Expanded( + child: new Container( + color: foreground, + ), + ), + new Container( + decoration: const BoxDecoration( + border: const Border( + top: const BorderSide(width: 0.0, color: _kHighlighterBorder), + bottom: const BorderSide(width: 0.0, color: _kHighlighterBorder), + ) + ), + constraints: new BoxConstraints.expand(height: widget.itemExtent), + ), + new Expanded( + child: new Container( + color: foreground, + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return new DecoratedBox( + decoration: new BoxDecoration( + color: widget.backgroundColor, + ), + child: new Stack( + children: <Widget>[ + new Positioned.fill( + child: new ListWheelScrollView( + controller: widget.scrollController, + physics: const FixedExtentScrollPhysics(), + diameterRatio: widget.diameterRatio, + itemExtent: widget.itemExtent, + onSelectedItemChanged: _handleSelectedItemChanged, + children: widget.children, + ), + ), + _buildGradientScreen(), + _buildMagnifierScreen(), + ], + ), + ); + } +}
diff --git a/packages/flutter/lib/src/rendering/list_wheel_viewport.dart b/packages/flutter/lib/src/rendering/list_wheel_viewport.dart index 9ee55c8..30c89b8 100644 --- a/packages/flutter/lib/src/rendering/list_wheel_viewport.dart +++ b/packages/flutter/lib/src/rendering/list_wheel_viewport.dart
@@ -35,7 +35,7 @@ /// /// This class works in 3 coordinate systems: /// -/// 1- The **scrollable layout coordinates**. This coordinate system is used to +/// 1. The **scrollable layout coordinates**. This coordinate system is used to /// communicate with [ViewportOffset] and describes its children's abstract /// offset from the beginning of the scrollable list at (0.0, 0.0). /// @@ -44,7 +44,7 @@ /// /// Children's layout coordinates don't change as the viewport scrolls. /// -/// 2- The **untransformed plane's viewport painting coordinates**. Children are +/// 2. The **untransformed plane's viewport painting coordinates**. Children are /// not painted in this coordinate system. It's an abstract intermediary used /// before transforming into the next cylindrical coordinate system. /// @@ -63,7 +63,7 @@ /// paint 10-11 visible 10px children if there are enough children in the /// viewport. /// -/// 3- The **transformed cylindrical space viewport painting coordinates**. +/// 3. The **transformed cylindrical space viewport painting coordinates**. /// Children from system 2 get their positions transformed into a cylindrical /// projection matrix instead of its cartesian offset with respect to the /// scroll offset. @@ -130,13 +130,13 @@ /// An arbitrary but aesthetically reasonable default value for [diameterRatio]. static const double defaultDiameterRatio = 2.0; - /// Ar arbitrary but aesthetically reasonable default value for [perspective]. + /// An arbitrary but aesthetically reasonable default value for [perspective]. static const double defaultPerspective = 0.003; /// An error message to show when the provided [diameterRatio] is zero. static const String diameterRatioZeroMessage = "You can't set a diameterRatio " - 'of 0. It would imply a cylinder of 0 in diameter in which case nothing ' - 'will be drawn.'; + 'of 0 or of a negative number. It would imply a cylinder of 0 in diameter ' + 'in which case nothing will be drawn.'; /// An error message to show when the [perspective] value is too high. static const String perspectiveTooHighMessage = 'A perspective too high will ' @@ -563,7 +563,9 @@ ); context.pushTransform( - needsCompositing, + // Text with TransformLayers and no cullRects currently have an issue rendering + // https://github.com/flutter/flutter/issues/14224. + false, offset, _centerOriginTransform(transform), // Pre-transform painting function. @@ -591,9 +593,12 @@ return result; } + /// This returns the matrices relative to the **untransformed plane's viewport + /// painting coordinates** system. @override void applyPaintTransform(RenderBox child, Matrix4 transform) { - transform.translate(0.0, _getUntransformedPaintingCoordinateY(0.0)); + final ListWheelParentData parentData = child?.parentData; + transform.translate(0.0, _getUntransformedPaintingCoordinateY(parentData.offset.dy)); } @override
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 39f2534..9c7df00 100644 --- a/packages/flutter/lib/src/widgets/list_wheel_scroll_view.dart +++ b/packages/flutter/lib/src/widgets/list_wheel_scroll_view.dart
@@ -1,15 +1,338 @@ // Copyright 2017 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; +import 'dart:math' as math; +import 'package:flutter/animation.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/physics.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; import 'framework.dart'; +import 'notification_listener.dart'; +import 'scroll_context.dart'; import 'scroll_controller.dart'; +import 'scroll_metrics.dart'; +import 'scroll_notification.dart'; import 'scroll_physics.dart'; +import 'scroll_position.dart'; +import 'scroll_position_with_single_context.dart'; import 'scrollable.dart'; +/// A controller for scroll views whose items have the same size. +/// +/// Similar to a standard [ScrollController] but with the added convenience +/// mechanisms to read and go to item indices rather than a raw pixel scroll +/// offset. +/// +/// See also: +/// +/// * [ListWheelScrollView], a scrollable view widget with fixed size items +/// that this widget controls. +/// * [FixedExtentMetrics], the `metrics` property exposed by +/// [ScrollNotification] from [ListWheelScrollView] which can be used +/// to listen to the current item index on a push basis rather than polling +/// the [FixedExtentScrollController]. +class FixedExtentScrollController extends ScrollController { + /// Creates a scroll controller for scrollables whose items have the same size. + /// + /// [initialItem] defaults to 0 and must not be null. + FixedExtentScrollController({ + this.initialItem: 0, + }) : assert(initialItem != null); + + /// The page to show when first creating the scroll view. + /// + /// Defaults to 0 and must not be null. + final int initialItem; + + /// The currently selected item index that's closest to the center of the viewport. + /// + /// There are circumstances that this [FixedExtentScrollController] can't know + /// the current item. Reading [selectedItem] will throw an [AssertionError] in + /// the following cases: + /// + /// 1. No scroll view is currently using this [FixedExtentScrollController]. + /// 2. More than one scroll views using the same [FixedExtentScrollController]. + /// + /// The [hasClients] property can be used to check if a scroll view is + /// attached prior to accessing [selectedItem]. + int get selectedItem { + assert( + positions.isNotEmpty, + 'FixedExtentScrollController.selectedItem cannot be accessed before a ' + 'scroll view is built with it.', + ); + assert( + positions.length == 1, + 'The selectedItem property cannot be read when multiple scroll views are ' + 'attached to the same FixedExtentScrollController.', + ); + final _FixedExtentScrollPosition position = this.position; + return position.itemIndex; + } + + /// Animates the controlled scroll view to the given item index. + /// + /// The animation lasts for the given duration and follows the given curve. + /// The returned [Future] resolves when the animation completes. + /// + /// The `duration` and `curve` arguments must not be null. + Future<Null> animateToItem(int itemIndex, { + @required Duration duration, + @required Curve curve, + }) { + if (!hasClients) { + return new Future<Null>.value(); + } + + final List<Future<Null>> futures = <Future<Null>>[]; + for (_FixedExtentScrollPosition position in positions) { + futures.add(position.animateTo( + itemIndex * position.itemExtent, + duration: duration, + curve: curve, + )); + } + return Future.wait(futures); + } + + /// Changes which item index is centered in the controlled scroll view. + /// + /// Jumps the item index position from its current value to the given value, + /// without animation, and without checking if the new value is in range. + void jumpToItem(int itemIndex) { + for (_FixedExtentScrollPosition position in positions) { + position.jumpTo(itemIndex * position.itemExtent); + } + } + + @override + ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition oldPosition) { + return new _FixedExtentScrollPosition( + physics: physics, + context: context, + initialItem: initialItem, + oldPosition: oldPosition, + ); + } +} + +/// Metrics for a [ScrollPosition] to a scroll view with fixed item sizes. +/// +/// The metrics are available on [ScrollNotification]s generated from a scroll +/// views such as [ListWheelScrollView]s with a [FixedExtentScrollController] and +/// exposes the current [itemIndex] and the scroll view's [itemExtent]. +/// +/// `FixedExtent` refers to the fact that the scrollable items have the same size. +/// This is distinct from `Fixed` in the parent class name's [FixedScrollMetric] +/// which refers to its immutability. +class FixedExtentMetrics extends FixedScrollMetrics { + /// Creates fixed extent metrics that add the item index information to the + /// `parent` [FixedScrollMetrics]. + FixedExtentMetrics({ + ScrollMetrics parent, + @required this.itemIndex, + }) : super.clone(parent); + + /// The scroll view's currently selected item index. + final int itemIndex; +} + +int _getItemFromOffset({ + double offset, + double itemExtent, + double minScrollExtent, + double maxScrollExtent, +}) { + return (_clipOffsetToScrollableRange(offset, minScrollExtent, maxScrollExtent) / itemExtent).round(); +} + +double _clipOffsetToScrollableRange( + double offset, + double minScrollExtent, + double maxScrollExtent +) { + return math.min(math.max(offset, minScrollExtent), maxScrollExtent); +} + +/// A [ScrollPositionWithSingleContext] that can only be created based on +/// [_FixedExtentScrollable] and can access its `itemExtent` to derive [itemIndex]. +class _FixedExtentScrollPosition extends ScrollPositionWithSingleContext { + _FixedExtentScrollPosition({ + @required ScrollPhysics physics, + @required ScrollContext context, + @required int initialItem, + bool keepScrollOffset: true, + ScrollPosition oldPosition, + String debugLabel, + }) : assert( + context is _FixedExtentScrollableState, + 'FixedExtentScrollController can only be used with ListWheelScrollViews' + ), + super( + physics: physics, + context: context, + initialPixels: _getItemExtentFromScrollContext(context) * initialItem, + keepScrollOffset: keepScrollOffset, + oldPosition: oldPosition, + debugLabel: debugLabel, + ); + + static double _getItemExtentFromScrollContext(ScrollContext context) { + final _FixedExtentScrollableState scrollable = context; + return scrollable.itemExtent; + } + + double get itemExtent => _getItemExtentFromScrollContext(context); + + int get itemIndex { + return _getItemFromOffset( + offset: pixels, + itemExtent: itemExtent, + minScrollExtent: minScrollExtent, + maxScrollExtent: maxScrollExtent, + ); + } + + @override + FixedExtentMetrics cloneMetrics() { + return new FixedExtentMetrics(parent: this, itemIndex: itemIndex); + } +} + +/// A [Scrollable] which must be given its viewport children's item extent +/// size so it can pass it on ultimately to the [FixedExtentScrollController]. +class _FixedExtentScrollable extends Scrollable { + const _FixedExtentScrollable({ + Key key, + AxisDirection axisDirection: AxisDirection.down, + ScrollController controller, + ScrollPhysics physics, + @required this.itemExtent, + @required ViewportBuilder viewportBuilder, + }) : super ( + key: key, + axisDirection: axisDirection, + controller: controller, + physics: physics, + viewportBuilder: viewportBuilder, + ); + + final double itemExtent; + + @override + _FixedExtentScrollableState createState() => new _FixedExtentScrollableState(); +} + +/// This [ScrollContext] is used by [_FixedExtentScrollPosition] to read the +/// prescribed [itemExtent]. +class _FixedExtentScrollableState extends ScrollableState { + double get itemExtent { + // Downcast because only _FixedExtentScrollable can make _FixedExtentScrollableState. + final _FixedExtentScrollable actualWidget = widget; + return actualWidget.itemExtent; + } +} + +/// A snapping physics that always lands directly on items instead of anywhere +/// within the scroll extent. +/// +/// Behaves similarly to a slot machine wheel except the ballistics simulation +/// never overshoots and rolls back within a single item if it's to settle on +/// that item. +/// +/// Must be used with a scrollable that uses a [FixedExtentScrollController]. +/// +/// Defers back to the parent beyond the scroll extents. +class FixedExtentScrollPhysics extends ScrollPhysics { + /// Creates a scroll physics that always lands on items. + const FixedExtentScrollPhysics({ ScrollPhysics parent }) : super(parent: parent); + + @override + FixedExtentScrollPhysics applyTo(ScrollPhysics ancestor) { + return new FixedExtentScrollPhysics(parent: buildParent(ancestor)); + } + + @override + Simulation createBallisticSimulation(ScrollMetrics position, double velocity) { + assert( + position is _FixedExtentScrollPosition, + 'FixedExtentScrollPhysics can only be used with Scrollables that uses ' + 'the FixedExtentScrollController' + ); + + final _FixedExtentScrollPosition metrics = position; + + // Scenario 1: + // If we're out of range and not headed back in range, defer to the parent + // ballistics, which should put us back in range at the scrollable's boundary. + if ((velocity <= 0.0 && metrics.pixels <= metrics.minScrollExtent) || + (velocity >= 0.0 && metrics.pixels >= metrics.maxScrollExtent)) { + return super.createBallisticSimulation(metrics, velocity); + } + + // Create a test simulation to see where it would have ballistically fallen + // naturally without settling onto items. + final Simulation testFrictionSimulation = + super.createBallisticSimulation(metrics, velocity); + + // Scenario 2: + // If it was going to end up past the scroll extent, defer back to the + // parent physics' ballistics again which should put us on the scrollable's + // boundary. + if (testFrictionSimulation != null + && (testFrictionSimulation.x(double.infinity) == metrics.minScrollExtent + || testFrictionSimulation.x(double.infinity) == metrics.maxScrollExtent)) { + return super.createBallisticSimulation(metrics, velocity); + } + + // From the natural final position, find the nearest item it should have + // settled to. + final int settlingItemIndex = _getItemFromOffset( + offset: testFrictionSimulation?.x(double.infinity) ?? metrics.pixels, + itemExtent: metrics.itemExtent, + minScrollExtent: metrics.minScrollExtent, + maxScrollExtent: metrics.maxScrollExtent, + ); + + final double settlingPixels = settlingItemIndex * metrics.itemExtent; + + // Scenario 3: + // If there's no velocity and we're already at where we intend to land, + // do nothing. + if (velocity.abs() < tolerance.velocity + && (settlingPixels - metrics.pixels).abs() < tolerance.distance) { + return null; + } + + // Scenario 4: + // If we're going to end back at the same item because initial velocity + // is too low to break past it, use a spring simulation to get back. + if (settlingItemIndex == metrics.itemIndex) { + return new SpringSimulation( + spring, + metrics.pixels, + settlingPixels, + velocity, + tolerance: tolerance, + ); + } + + // Scenario 5: + // Create a new friction simulation except the drag will be tweaked to land + // exactly on the item closest to the natural stopping point. + return new FrictionSimulation.through( + metrics.pixels, + settlingPixels, + velocity, + tolerance.velocity * velocity.sign, + ); + } +} + /// A box in which children on a wheel can be scrolled. /// /// This widget is similar to a [ListView] but with the restriction that all @@ -21,7 +344,7 @@ /// /// The children are rendered as if rotating on a wheel instead of scrolling on /// a plane. -class ListWheelScrollView extends StatelessWidget { +class ListWheelScrollView extends StatefulWidget { /// Creates a box in which children are scrolled on a wheel. const ListWheelScrollView({ Key key, @@ -30,6 +353,7 @@ this.diameterRatio: RenderListWheelViewport.defaultDiameterRatio, this.perspective: RenderListWheelViewport.defaultPerspective, @required this.itemExtent, + this.onSelectedItemChanged, this.clipToSize: true, this.renderChildrenOutsideViewport: false, @required this.children, @@ -48,16 +372,21 @@ ), super(key: key); - /// An object that can be used to control the position to which this scroll - /// view is scrolled. + /// Typically a [FixedExtentScrollController] used to control the current item. /// - /// A [ScrollController] serves several purposes. It can be used to control - /// the initial scroll position (see [ScrollController.initialScrollOffset]). - /// It can be used to control whether the scroll view should automatically - /// save and restore its scroll position in the [PageStorage] (see - /// [ScrollController.keepScrollOffset]). It can be used to read the current - /// scroll position (see [ScrollController.offset]), or change it (see - /// [ScrollController.animateTo]). + /// A [FixedExtentScrollController] can be used to read the currently + /// selected/centered child item and can be used to change the current item. + /// + /// If none is provided, a new [FixedExtentScrollController] is implicitly + /// created. + /// + /// If a [ScrollController] is used instead of [FixedExtentScrollController], + /// [ScrollNotification.metrics] will no longer provide [FixedExtentMetrics] + /// to indicate the current item index and [onSelectedItemChanged] will not + /// work. + /// + /// To read the current selected item only when the value changes, use + /// [onSelectedItemChanged]. final ScrollController controller; /// How the scroll view should respond to user input. @@ -78,6 +407,9 @@ /// positive. final double itemExtent; + /// On optional listener that's called when the centered item changes. + final ValueChanged<int> onSelectedItemChanged; + /// {@macro flutter.rendering.wheelList.clipToSize} final bool clipToSize; @@ -88,21 +420,68 @@ final List<Widget> children; @override + _ListWheelScrollViewState createState() => new _ListWheelScrollViewState(); +} + +class _ListWheelScrollViewState extends State<ListWheelScrollView> { + int _lastReportedItemIndex = 0; + ScrollController scrollController; + + @override + void initState() { + super.initState(); + scrollController = widget.controller ?? new FixedExtentScrollController(); + if (widget.controller is FixedExtentScrollController) { + final FixedExtentScrollController controller = widget.controller; + _lastReportedItemIndex = controller.initialItem; + } + } + + @override + void didUpdateWidget(ListWheelScrollView oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller != null && widget.controller != scrollController) { + final ScrollController oldScrollController = scrollController; + SchedulerBinding.instance.addPostFrameCallback((_) { + oldScrollController.dispose(); + }); + scrollController = widget.controller; + } + } + + @override Widget build(BuildContext context) { - return new Scrollable( - controller: controller, - physics: physics, - viewportBuilder: (BuildContext context, ViewportOffset offset) { - return new ListWheelViewport( - diameterRatio: diameterRatio, - perspective: perspective, - itemExtent: itemExtent, - clipToSize: clipToSize, - renderChildrenOutsideViewport: renderChildrenOutsideViewport, - offset: offset, - children: children, - ); + return new NotificationListener<ScrollNotification>( + onNotification: (ScrollNotification notification) { + if (notification.depth == 0 + && widget.onSelectedItemChanged != null + && notification is ScrollUpdateNotification + && notification.metrics is FixedExtentMetrics) { + final FixedExtentMetrics metrics = notification.metrics; + final int currentItemIndex = metrics.itemIndex; + if (currentItemIndex != _lastReportedItemIndex) { + _lastReportedItemIndex = currentItemIndex; + widget.onSelectedItemChanged(currentItemIndex); + } + } + return false; }, + child: new _FixedExtentScrollable( + controller: scrollController, + physics: widget.physics, + itemExtent: widget.itemExtent, + viewportBuilder: (BuildContext context, ViewportOffset offset) { + return new ListWheelViewport( + diameterRatio: widget.diameterRatio, + perspective: widget.perspective, + itemExtent: widget.itemExtent, + clipToSize: widget.clipToSize, + renderChildrenOutsideViewport: widget.renderChildrenOutsideViewport, + offset: offset, + children: widget.children, + ); + }, + ), ); } } @@ -120,6 +499,20 @@ /// * [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. + /// + /// The [diameterRatio] argument defaults to 2.0 and must not be null. + /// + /// The [perspective] argument defaults to 0.003 and must not be null. + /// + /// The [itemExtent] argument in pixels must be provided and must be positive. + /// + /// The [clipToSize] argument defaults to true and must not be null. + /// + /// The [renderChildrenOutsideViewport] argument defaults to false and must + /// not be null. + /// + /// The [offset] argument must be provided and must not be null. ListWheelViewport({ Key key, this.diameterRatio: RenderListWheelViewport.defaultDiameterRatio,
diff --git a/packages/flutter/lib/src/widgets/page_view.dart b/packages/flutter/lib/src/widgets/page_view.dart index b71c16b..0bd6946 100644 --- a/packages/flutter/lib/src/widgets/page_view.dart +++ b/packages/flutter/lib/src/widgets/page_view.dart
@@ -99,7 +99,8 @@ ); assert( positions.length == 1, - 'Multiple PageViews cannot be attached to the same PageController.', + 'The page property cannot be read when multiple PageViews are attached to ' + 'the same PageController.', ); final _PagePosition position = this.position; return position.page;
diff --git a/packages/flutter/test/cupertino/picker_test.dart b/packages/flutter/test/cupertino/picker_test.dart new file mode 100644 index 0000000..3e7dcbe --- /dev/null +++ b/packages/flutter/test/cupertino/picker_test.dart
@@ -0,0 +1,178 @@ +// Copyright 2017 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('layout', () { + testWidgets('selected item is in the middle', (WidgetTester tester) async { + final FixedExtentScrollController controller = + new FixedExtentScrollController(initialItem: 1); + + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new Align( + alignment: Alignment.topLeft, + child: new SizedBox( + height: 300.0, + width: 300.0, + child: new CupertinoPicker( + scrollController: controller, + itemExtent: 50.0, + onSelectedItemChanged: (_) {}, + children: new List<Widget>.generate(3, (int index) { + return new Container( + height: 50.0, + width: 300.0, + child: new Text(index.toString()), + ); + }), + ), + ), + ), + ), + ); + + expect( + tester.getTopLeft(find.widgetWithText(Container, '1')), + const Offset(0.0, 125.0), + ); + + controller.jumpToItem(0); + await tester.pump(); + + expect( + tester.getTopLeft(find.widgetWithText(Container, '1')), + const Offset(0.0, 175.0), + ); + expect( + tester.getTopLeft(find.widgetWithText(Container, '0')), + const Offset(0.0, 125.0), + ); + }); + }); + + group('scroll', () { + testWidgets('a drag in between items settles back', (WidgetTester tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final FixedExtentScrollController controller = + new FixedExtentScrollController(initialItem: 10); + final List<int> selectedItems = <int>[]; + + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new CupertinoPicker( + scrollController: controller, + itemExtent: 100.0, + onSelectedItemChanged: (int index) { selectedItems.add(index); }, + children: new List<Widget>.generate(100, (int index) { + return new Center( + child: new Container( + width: 400.0, + height: 100.0, + child: new Text(index.toString()), + ), + ); + }), + ), + ), + ); + + // Drag it by a bit but not enough to move to the next item. + await tester.drag(find.text('10'), const Offset(0.0, 30.0)); + + // The item that was in the center now moved a bit. + expect( + tester.getTopLeft(find.widgetWithText(Container, '10')), + const Offset(200.0, 280.0), + ); + + await tester.pumpAndSettle(); + + expect( + tester.getTopLeft(find.widgetWithText(Container, '10')).dy, + moreOrLessEquals(250.0, epsilon: 0.5), + ); + expect(selectedItems.isEmpty, true); + + // Drag it by enough to move to the next item. + await tester.drag(find.text('10'), const Offset(0.0, 70.0)); + + await tester.pumpAndSettle(); + + expect( + tester.getTopLeft(find.widgetWithText(Container, '10')).dy, + // It's down by 100.0 now. + moreOrLessEquals(350.0, epsilon: 0.5), + ); + expect(selectedItems, <int>[9]); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('a big fling that overscrolls springs back', (WidgetTester tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final FixedExtentScrollController controller = + new FixedExtentScrollController(initialItem: 10); + final List<int> selectedItems = <int>[]; + + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new CupertinoPicker( + scrollController: controller, + itemExtent: 100.0, + onSelectedItemChanged: (int index) { selectedItems.add(index); }, + children: new List<Widget>.generate(100, (int index) { + return new Center( + child: new Container( + width: 400.0, + height: 100.0, + child: new Text(index.toString()), + ), + ); + }), + ), + ), + ); + + // A wild throw appears. + await tester.fling( + find.text('10'), + const Offset(0.0, 10000.0), + 1000.0, + ); + + expect( + tester.getTopLeft(find.widgetWithText(Container, '0')).dy, + // Should have been flung far enough to go off screen. + greaterThan(600.0), + ); + expect( + selectedItems, + // This specific throw was fast enough that each scroll update landed + // on every second item. + <int>[8, 6, 4, 2, 0], + ); + + // Let it spring back. + await tester.pumpAndSettle(); + + expect( + tester.getTopLeft(find.widgetWithText(Container, '0')).dy, + // Should have sprung back to the middle now. + moreOrLessEquals(250.0), + ); + expect( + selectedItems, + // Falling back to 0 shouldn't produce more callbacks. + <int>[8, 6, 4, 2, 0], + ); + + debugDefaultTargetPlatformOverride = null; + }); + }); +}
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 afa08c0..db41ef4 100644 --- a/packages/flutter/test/widgets/list_wheel_scroll_view_test.dart +++ b/packages/flutter/test/widgets/list_wheel_scroll_view_test.dart
@@ -3,6 +3,7 @@ // found in the LICENSE file. import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; @@ -10,371 +11,735 @@ import '../rendering/rendering_tester.dart'; void main() { - testWidgets('ListWheelScrollView needs positive diameter ratio', (WidgetTester tester) async { - try { - new ListWheelScrollView( - diameterRatio: -2.0, - itemExtent: 20.0, - children: <Widget>[], - ); - fail('Expected failure with negative diameterRatio'); - } on AssertionError catch (exception) { - expect(exception.message, contains("You can't set a diameterRatio of 0")); - } - }); - - testWidgets('ListWheelScrollView needs positive item extent', (WidgetTester tester) async { - expect( - () { + group('construction check', () { + testWidgets('ListWheelScrollView needs positive diameter ratio', (WidgetTester tester) async { + try { new ListWheelScrollView( - itemExtent: null, - children: <Widget>[new Container()], - ); - }, - throwsAssertionError, - ); - }); - - testWidgets("ListWheelScrollView takes parent's size with small children", (WidgetTester tester) async { - await tester.pumpWidget( - new Directionality( - textDirection: TextDirection.ltr, - child: new ListWheelScrollView( - // Inner children smaller than the outer window. - itemExtent: 50.0, - children: <Widget>[ - new Container( - height: 50.0, - color: const Color(0xFFFFFFFF), - ), - ], - ), - ), - ); - expect(tester.getTopLeft(find.byType(ListWheelScrollView)), const Offset(0.0, 0.0)); - // Standard test screen size. - expect(tester.getBottomRight(find.byType(ListWheelScrollView)), const Offset(800.0, 600.0)); - }); - - testWidgets("ListWheelScrollView takes parent's size with large children", (WidgetTester tester) async { - await tester.pumpWidget( - new Directionality( - textDirection: TextDirection.ltr, - child: new ListWheelScrollView( - // Inner children 5000.0px. - itemExtent: 50.0, - children: new List<Widget>.generate(100, (int index) { - return new Container( - height: 50.0, - color: const Color(0xFFFFFFFF), - ); - }), - ), - ) - ); - expect(tester.getTopLeft(find.byType(ListWheelScrollView)), const Offset(0.0, 0.0)); - // Still fills standard test screen size. - expect(tester.getBottomRight(find.byType(ListWheelScrollView)), const Offset(800.0, 600.0)); - }); - - testWidgets("ListWheelScrollView children can't be bigger than itemExtent", (WidgetTester tester) async { - await tester.pumpWidget( - new Directionality( - textDirection: TextDirection.ltr, - child: new ListWheelScrollView( - itemExtent: 50.0, - children: <Widget>[ - const SizedBox( - height: 200.0, - width: 200.0, - child: const Center( - child: const Text('blah'), - ), - ), - ], - ), - ), - ); - expect(tester.getSize(find.byType(SizedBox)), const Size(200.0, 50.0)); - expect(find.text('blah'), findsOneWidget); - }); - - testWidgets('ListWheelScrollView can have zero child', (WidgetTester tester) async { - await tester.pumpWidget( - new Directionality( - textDirection: TextDirection.ltr, - child: new ListWheelScrollView( - itemExtent: 50.0, + diameterRatio: -2.0, + itemExtent: 20.0, children: <Widget>[], + ); + fail('Expected failure with negative diameterRatio'); + } on AssertionError catch (exception) { + expect(exception.message, contains("You can't set a diameterRatio of 0")); + } + }); + + testWidgets('ListWheelScrollView needs positive item extent', (WidgetTester tester) async { + expect( + () { + new ListWheelScrollView( + itemExtent: null, + children: <Widget>[new Container()], + ); + }, + throwsAssertionError, + ); + }); + + testWidgets('ListWheelScrollView can have zero child', (WidgetTester tester) async { + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new ListWheelScrollView( + itemExtent: 50.0, + children: <Widget>[], + ), ), - ), - ); - expect(tester.getSize(find.byType(ListWheelScrollView)), const Size(800.0, 600.0)); + ); + expect(tester.getSize(find.byType(ListWheelScrollView)), const Size(800.0, 600.0)); + }); }); - testWidgets('ListWheelScrollView starts and ends from the middle', (WidgetTester tester) async { - final ScrollController controller = new ScrollController(); - final List<int> paintedChildren = <int>[]; + group('layout', () { + testWidgets("ListWheelScrollView takes parent's size with small children", (WidgetTester tester) async { + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new ListWheelScrollView( + // Inner children smaller than the outer window. + itemExtent: 50.0, + children: <Widget>[ + new Container( + height: 50.0, + color: const Color(0xFFFFFFFF), + ), + ], + ), + ), + ); + expect(tester.getTopLeft(find.byType(ListWheelScrollView)), const Offset(0.0, 0.0)); + // Standard test screen size. + expect(tester.getBottomRight(find.byType(ListWheelScrollView)), const Offset(800.0, 600.0)); + }); - await tester.pumpWidget( - new Directionality( - textDirection: TextDirection.ltr, - child: new ListWheelScrollView( - controller: controller, - itemExtent: 100.0, - children: new List<Widget>.generate(100, (int index) { - return new CustomPaint( - painter: new TestCallbackPainter(onPaint: () { - paintedChildren.add(index); + testWidgets("ListWheelScrollView takes parent's size with large children", (WidgetTester tester) async { + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new ListWheelScrollView( + // Inner children 5000.0px. + itemExtent: 50.0, + children: new List<Widget>.generate(100, (int index) { + return new Container( + height: 50.0, + color: const Color(0xFFFFFFFF), + ); + }), + ), + ), + ); + expect(tester.getTopLeft(find.byType(ListWheelScrollView)), const Offset(0.0, 0.0)); + // Still fills standard test screen size. + expect(tester.getBottomRight(find.byType(ListWheelScrollView)), const Offset(800.0, 600.0)); + }); + + testWidgets("ListWheelScrollView children can't be bigger than itemExtent", (WidgetTester tester) async { + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new ListWheelScrollView( + itemExtent: 50.0, + children: <Widget>[ + const SizedBox( + height: 200.0, + width: 200.0, + child: const Center( + child: const Text('blah'), + ), + ), + ], + ), + ), + ); + expect(tester.getSize(find.byType(SizedBox)), const Size(200.0, 50.0)); + expect(find.text('blah'), findsOneWidget); + }); + }); + + group('pre-transform viewport', () { + testWidgets('ListWheelScrollView starts and ends from the middle', (WidgetTester tester) async { + final ScrollController controller = new ScrollController(); + final List<int> paintedChildren = <int>[]; + + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new ListWheelScrollView( + controller: controller, + itemExtent: 100.0, + children: new List<Widget>.generate(100, (int index) { + return new CustomPaint( + painter: new TestCallbackPainter(onPaint: () { + paintedChildren.add(index); + }), + ); + }), + ), + ), + ); + + // Screen is 600px tall and the first item starts at 250px. The first 4 + // children are visible. + expect(paintedChildren, <int>[0, 1, 2, 3]); + + controller.jumpTo(1000.0); + paintedChildren.clear(); + + await tester.pump(); + // Item number 10 is now in the middle of the screen at 250px. 9, 8, 7 are + // visible before it and 11, 12, 13 are visible after it. + expect(paintedChildren, <int>[7, 8, 9, 10, 11, 12, 13]); + + // Move to the last item. + controller.jumpTo(9900.0); + paintedChildren.clear(); + + await tester.pump(); + // Item 99 is in the middle at 250px. + expect(paintedChildren, <int>[96, 97, 98, 99]); + }); + + testWidgets('A child gets painted as soon as its first pixel is in the viewport', (WidgetTester tester) async { + final ScrollController controller = new ScrollController(initialScrollOffset: 50.0); + final List<int> paintedChildren = <int>[]; + + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new ListWheelScrollView( + controller: controller, + itemExtent: 100.0, + children: new List<Widget>.generate(10, (int index) { + return new CustomPaint( + painter: new TestCallbackPainter(onPaint: () { + paintedChildren.add(index); + }), + ); + }), + ), + ), + ); + + // Screen is 600px tall and the first item starts at 200px. The first 4 + // children are visible. + expect(paintedChildren, <int>[0, 1, 2, 3]); + + paintedChildren.clear(); + // Move down by 1px. + await tester.drag(find.byType(ListWheelScrollView), const Offset(0.0, -1.0)); + await tester.pump(); + + // Now the first pixel of item 5 enters the viewport. + expect(paintedChildren, <int>[0, 1, 2, 3, 4]); + }); + + testWidgets('A child is no longer painted after its last pixel leaves the viewport', (WidgetTester tester) async { + final ScrollController controller = new ScrollController(initialScrollOffset: 250.0); + final List<int> paintedChildren = <int>[]; + + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new ListWheelScrollView( + controller: controller, + itemExtent: 100.0, + children: new List<Widget>.generate(10, (int index) { + return new CustomPaint( + painter: new TestCallbackPainter(onPaint: () { + paintedChildren.add(index); + }), + ); + }), + ), + ), + ); + + // The first item is at 0px and the 600px screen is full in the + // **untransformed plane's viewport painting coordinates** + expect(paintedChildren, <int>[0, 1, 2, 3, 4, 5]); + + paintedChildren.clear(); + // Go down another 99px. + controller.jumpTo(349.0); + await tester.pump(); + + // One more item now visible with the last pixel of 0 showing. + expect(paintedChildren, <int>[0, 1, 2, 3, 4, 5, 6]); + + paintedChildren.clear(); + // Go down one more pixel. + controller.jumpTo(350.0); + await tester.pump(); + + // Item 0 no longer visible. + expect(paintedChildren, <int>[1, 2, 3, 4, 5, 6]); + }); + }); + + group('viewport transformation', () { + testWidgets('Default middle transform', (WidgetTester tester) async { + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new ListWheelScrollView( + itemExtent: 100.0, + children: <Widget>[ + new Container( + width: 200.0, + child: const Center( + child: const Text('blah'), + ), + ), + ], + ), + ), + ); + + final RenderListWheelViewport viewport = tester.firstRenderObject(find.byType(Container)).parent; + expect(viewport, paints..transform( + matrix4: equals(<dynamic>[ + 1.0, 0.0, 0.0, 0.0, + 0.0, 1.0, 0.0, 0.0, + -1.2 /* origin centering multiplied */, -0.9/* origin centering multiplied*/, 1.0, -0.003 /* inverse of perspective */, + moreOrLessEquals(0.0), moreOrLessEquals(0.0), 0.0, moreOrLessEquals(1.0), + ]), + )); + }); + + testWidgets('Scrolling, diameterRatio, perspective all changes matrix', (WidgetTester tester) async { + final ScrollController controller = new ScrollController(initialScrollOffset: 200.0); + + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new ListWheelScrollView( + controller: controller, + itemExtent: 100.0, + children: <Widget>[ + new Container( + width: 200.0, + child: const Center( + child: const Text('blah'), + ), + ), + ], + ), + ), + ); + + final RenderListWheelViewport viewport = tester.firstRenderObject(find.byType(Container)).parent; + expect(viewport, paints..transform( + matrix4: equals(<dynamic>[ + 1.0, 0.0, 0.0, 0.0, + moreOrLessEquals(-0.41042417199080244), moreOrLessEquals(0.6318744917928065), moreOrLessEquals(0.3420201433256687), moreOrLessEquals(-0.0010260604299770061), + moreOrLessEquals(-1.12763114494309), moreOrLessEquals(-1.1877435020329863), moreOrLessEquals(0.9396926207859084), moreOrLessEquals(-0.0028190778623577253), + moreOrLessEquals(166.54856463138663), moreOrLessEquals(-62.20844875763376), moreOrLessEquals(-138.79047052615562), moreOrLessEquals(1.4163714115784667), + ]), + )); + + // Increase diameter. + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new ListWheelScrollView( + controller: controller, + diameterRatio: 3.0, + itemExtent: 100.0, + children: <Widget>[ + new Container( + width: 200.0, + child: const Center( + child: const Text('blah'), + ), + ), + ], + ), + ), + ); + + expect(viewport, paints..transform( + matrix4: equals(<dynamic>[ + 1.0, 0.0, 0.0, 0.0, + moreOrLessEquals(-0.26954971336161726), moreOrLessEquals(0.7722830529455648), moreOrLessEquals(0.22462476113468105), moreOrLessEquals(-0.0006738742834040432), + moreOrLessEquals(-1.1693344055601331), moreOrLessEquals(-1.101625565304781), moreOrLessEquals(0.9744453379667777), moreOrLessEquals(-0.002923336013900333), + moreOrLessEquals(108.46394900436536), moreOrLessEquals(-113.14792465797223), moreOrLessEquals(-90.38662417030434), moreOrLessEquals(1.2711598725109134), + ]), + )); + + // Decrease perspective. + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new ListWheelScrollView( + controller: controller, + perspective: 0.0001, + itemExtent: 100.0, + children: <Widget>[ + new Container( + width: 200.0, + child: const Center( + child: const Text('blah'), + ), + ), + ], + ), + ), + ); + + expect(viewport, paints..transform( + matrix4: equals(<dynamic>[ + 1.0, 0.0, 0.0, 0.0, + moreOrLessEquals(-0.01368080573302675), moreOrLessEquals(0.9294320164861384), moreOrLessEquals(0.3420201433256687), moreOrLessEquals(-0.000034202014332566874), + moreOrLessEquals(-0.03758770483143634), moreOrLessEquals(-0.370210921949246), moreOrLessEquals(0.9396926207859084), moreOrLessEquals(-0.00009396926207859085), + moreOrLessEquals(5.551618821046304), moreOrLessEquals(-182.95615811538906), moreOrLessEquals(-138.79047052615562), moreOrLessEquals(1.0138790470526158), + ]), + )); + + // Scroll a bit. + controller.jumpTo(300.0); + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new ListWheelScrollView( + controller: controller, + itemExtent: 100.0, + children: <Widget>[ + new Container( + width: 200.0, + child: const Center( + child: const Text('blah'), + ), + ), + ], + ), + ), + ); + + expect(viewport, paints..transform( + matrix4: equals(<dynamic>[ + 1.0, 0.0, 0.0, 0.0, + -0.6, moreOrLessEquals(0.41602540378443875), moreOrLessEquals(0.5), moreOrLessEquals(-0.0015), + moreOrLessEquals(-1.0392304845413265), moreOrLessEquals(-1.2794228634059948), moreOrLessEquals(0.8660254037844387), moreOrLessEquals(-0.0025980762113533163), + moreOrLessEquals(276.46170927520404), moreOrLessEquals(-52.46133917892857), moreOrLessEquals(-230.38475772933677), moreOrLessEquals(1.69115427318801), + ]), + )); + }); + }); + + group('scroll notifications', () { + testWidgets('no onSelectedItemChanged callback on first build', (WidgetTester tester) async { + bool itemChangeCalled = false; + final ValueChanged<int> onItemChange = (_) { itemChangeCalled = true; }; + + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new ListWheelScrollView( + itemExtent: 100.0, + onSelectedItemChanged: onItemChange, + children: <Widget>[ + new Container( + width: 200.0, + child: const Center( + child: const Text('blah'), + ), + ), + ], + ), + ), + ); + + expect(itemChangeCalled, false); + }); + + testWidgets('onSelectedItemChanged when a new item is closest to center', (WidgetTester tester) async { + final List<int> selectedItems = <int>[]; + + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new ListWheelScrollView( + itemExtent: 100.0, + onSelectedItemChanged: (int index) { selectedItems.add(index); }, + children: new List<Widget>.generate(10, (int index) { + return const Placeholder(); + }), + ), + ), + ); + + final TestGesture scrollGesture = await tester.startGesture(const Offset(10.0, 10.0)); + // Item 0 is still closest to the center. No updates. + await scrollGesture.moveBy(const Offset(0.0, -49.0)); + expect(selectedItems.isEmpty, true); + + // Now item 1 is closest to the center. + await scrollGesture.moveBy(const Offset(0.0, -1.0)); + expect(selectedItems, <int>[1]); + + // Now item 1 is still closest to the center for another full itemExtent (100px). + await scrollGesture.moveBy(const Offset(0.0, -99.0)); + expect(selectedItems, <int>[1]); + + await scrollGesture.moveBy(const Offset(0.0, -1.0)); + expect(selectedItems, <int>[1, 2]); + + // Going back triggers previous item indices. + await scrollGesture.moveBy(const Offset(0.0, 50.0)); + expect(selectedItems, <int>[1, 2, 1]); + }); + + testWidgets('onSelectedItemChanged reports only in valid range', (WidgetTester tester) async { + final List<int> selectedItems = <int>[]; + + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new ListWheelScrollView( + itemExtent: 100.0, + onSelectedItemChanged: (int index) { selectedItems.add(index); }, + // So item 0 is at 0 and item 9 is at 900 in the scrollable range. + children: new List<Widget>.generate(10, (int index) { + return const Placeholder(); + }), + ), + ), + ); + + final TestGesture scrollGesture = await tester.startGesture(const Offset(10.0, 10.0)); + + // First move back past the beginning. + await scrollGesture.moveBy(const Offset(0.0, 70.0)); + + for (double verticalOffset = 0.0; verticalOffset > -2000.0; verticalOffset -= 10.0) { + // Then gradually move down by a total vertical extent much higher than + // the scrollable extent. + await scrollGesture.moveTo(new Offset(0.0, verticalOffset)); + } + + // The list should only cover the list of valid items. Item 0 would not + // be included because the current item never left the 0 index until it + // went to 1. + expect(selectedItems, <int>[1, 2, 3, 4, 5, 6, 7, 8, 9]); + }); + }); + + group('scroll controller', () { + testWidgets('initialItem', (WidgetTester tester) async { + final FixedExtentScrollController controller = new FixedExtentScrollController(initialItem: 10); + final List<int> paintedChildren = <int>[]; + + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new ListWheelScrollView( + controller: controller, + itemExtent: 100.0, + children: new List<Widget>.generate(100, (int index) { + return new CustomPaint( + painter: new TestCallbackPainter(onPaint: () { + paintedChildren.add(index); + }), + ); + }), + ), + ), + ); + + // Screen is 600px tall. Item 10 is in the center and each item is 100px tall. + expect(paintedChildren, <int>[7, 8, 9, 10, 11, 12, 13]); + expect(controller.selectedItem, 10); + }); + + testWidgets('controller jump', (WidgetTester tester) async { + final FixedExtentScrollController controller = new FixedExtentScrollController(initialItem: 10); + final List<int> paintedChildren = <int>[]; + + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new ListWheelScrollView( + controller: controller, + itemExtent: 100.0, + children: new List<Widget>.generate(100, (int index) { + return new CustomPaint( + painter: new TestCallbackPainter(onPaint: () { + paintedChildren.add(index); + }), + ); + }), + ), + ), + ); + + // Screen is 600px tall. Item 10 is in the center and each item is 100px tall. + expect(paintedChildren, <int>[7, 8, 9, 10, 11, 12, 13]); + + paintedChildren.clear(); + controller.jumpToItem(0); + await tester.pump(); + + expect(paintedChildren, <int>[0, 1, 2, 3]); + expect(controller.selectedItem, 0); + }); + + testWidgets('onSelectedItemChanged and controller are in sync', (WidgetTester tester) async { + final List<int> selectedItems = <int>[]; + final FixedExtentScrollController controller = new FixedExtentScrollController(initialItem: 10); + + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new ListWheelScrollView( + controller: controller, + itemExtent: 100.0, + onSelectedItemChanged: (int index) { selectedItems.add(index); }, + children: new List<Widget>.generate(100, (int index) { + return const Placeholder(); + }), + ), + ), + ); + + final TestGesture scrollGesture = await tester.startGesture(const Offset(10.0, 10.0)); + await scrollGesture.moveBy(const Offset(0.0, -49.0)); + await tester.pump(); + expect(selectedItems.isEmpty, true); + expect(controller.selectedItem, 10); + + await scrollGesture.moveBy(const Offset(0.0, -1.0)); + await tester.pump(); + expect(selectedItems, <int>[11]); + expect(controller.selectedItem, 11); + + await scrollGesture.moveBy(const Offset(0.0, 70.0)); + await tester.pump(); + expect(selectedItems, <int>[11, 10]); + expect(controller.selectedItem, 10); + }); + + testWidgets('controller hot swappable', (WidgetTester tester) async { + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new ListWheelScrollView( + itemExtent: 100.0, + children: new List<Widget>.generate(100, (int index) { + return const Placeholder(); + }), + ), + ), + ); + + // Item 5 is now selected. + await tester.drag(find.byType(ListWheelScrollView), const Offset(0.0, -500.0)); + await tester.pump(); + + final FixedExtentScrollController newController = + new FixedExtentScrollController(initialItem: 30); + + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new ListWheelScrollView( + controller: newController, + itemExtent: 100.0, + children: new List<Widget>.generate(100, (int index) { + return const Placeholder(); + }), + ), + ), + ); + + // initialItem doesn't do anything since the scroll position was already + // created. + expect(newController.selectedItem, 5); + + newController.jumpToItem(50); + expect(newController.selectedItem, 50); + expect(newController.position.pixels, 5000.0); + + // Now remove the controller + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new ListWheelScrollView( + itemExtent: 100.0, + children: new List<Widget>.generate(100, (int index) { + return const Placeholder(); + }), + ), + ), + ); + + // Internally, that same controller is still attached and still at the + // same place. + expect(newController.selectedItem, 50); + }); + }); + + group('physics', () { + testWidgets('fling velocities too low snaps back to the same item', (WidgetTester tester) async { + final FixedExtentScrollController controller = new FixedExtentScrollController(initialItem: 40); + final List<double> scrolledPositions = <double>[]; + + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new NotificationListener<ScrollNotification>( + onNotification: (ScrollNotification notification) { + if (notification is ScrollUpdateNotification) { + scrolledPositions.add(notification.metrics.pixels); + } + }, + child: new ListWheelScrollView( + controller: controller, + physics: const FixedExtentScrollPhysics(), + itemExtent: 1000.0, + children: new List<Widget>.generate(100, (int index) { + return const Placeholder(); }), - ); - }), + ), + ), ), - ), - ); + ); - // Screen is 600px tall and the first item starts at 250px. The first 4 - // children are visible. - expect(paintedChildren, <int>[0, 1, 2, 3]); + await tester.fling( + find.byType(ListWheelScrollView), + const Offset(0.0, -50.0), + 800.0, + ); - controller.jumpTo(1000.0); - paintedChildren.clear(); + // At this moment, the ballistics is started but 50px is still inside the + // initial item. + expect(controller.selectedItem, 40); + // A tester.fling creates and pumps 50 pointer events. + expect(scrolledPositions.length, 50); + expect(scrolledPositions.last, moreOrLessEquals(40 * 1000.0 + 50.0, epsilon: 0.2)); - await tester.pump(); - // Item number 10 is now in the middle of the screen at 250px. 9, 8, 7 are - // visible before it and 11, 12, 13 are visible after it. - expect(paintedChildren, <int>[7, 8, 9, 10, 11, 12, 13]); + // Let the spring back simulation finish. + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); - // Move to the last item. - controller.jumpTo(9900.0); - paintedChildren.clear(); + // The simulation actually did stuff after start ballistics. + expect(scrolledPositions.length, greaterThan(50)); + // Though it still lands back to the same item with the same scroll offset. + expect(controller.selectedItem, 40); + expect(scrolledPositions.last, moreOrLessEquals(40 * 1000.0, epsilon: 0.2)); + }); - await tester.pump(); - // Item 99 is in the middle at 250px. - expect(paintedChildren, <int>[96, 97, 98, 99]); - }); + testWidgets('high fling velocities lands exactly on items', (WidgetTester tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - testWidgets('A child gets painted as soon as its first pixel is in the viewport', (WidgetTester tester) async { - final ScrollController controller = new ScrollController(initialScrollOffset: 50.0); - final List<int> paintedChildren = <int>[]; + final FixedExtentScrollController controller = new FixedExtentScrollController(initialItem: 40); + final List<double> scrolledPositions = <double>[]; - await tester.pumpWidget( - new Directionality( - textDirection: TextDirection.ltr, - child: new ListWheelScrollView( - controller: controller, - itemExtent: 100.0, - children: new List<Widget>.generate(10, (int index) { - return new CustomPaint( - painter: new TestCallbackPainter(onPaint: () { - paintedChildren.add(index); + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new NotificationListener<ScrollNotification>( + onNotification: (ScrollNotification notification) { + if (notification is ScrollUpdateNotification) { + scrolledPositions.add(notification.metrics.pixels); + } + }, + child: new ListWheelScrollView( + controller: controller, + physics: const FixedExtentScrollPhysics(), + itemExtent: 100.0, + children: new List<Widget>.generate(100, (int index) { + return const Placeholder(); }), - ); - }), - ), - ), - ); - - // Screen is 600px tall and the first item starts at 200px. The first 4 - // children are visible. - expect(paintedChildren, <int>[0, 1, 2, 3]); - - paintedChildren.clear(); - // Move down by 1px. - await tester.drag(find.byType(ListWheelScrollView), const Offset(0.0, -1.0)); - await tester.pump(); - - // Now the first pixel of item 5 enters the viewport. - expect(paintedChildren, <int>[0, 1, 2, 3, 4]); - }); - - testWidgets('A child is no longer painted after its last pixel leaves the viewport', (WidgetTester tester) async { - final ScrollController controller = new ScrollController(initialScrollOffset: 250.0); - final List<int> paintedChildren = <int>[]; - - await tester.pumpWidget( - new Directionality( - textDirection: TextDirection.ltr, - child: new ListWheelScrollView( - controller: controller, - itemExtent: 100.0, - children: new List<Widget>.generate(10, (int index) { - return new CustomPaint( - painter: new TestCallbackPainter(onPaint: () { - paintedChildren.add(index); - }), - ); - }), - ), - ), - ); - - // The first item is at 0px and the 600px screen is full in the - // **untransformed plane's viewport painting coordinates** - expect(paintedChildren, <int>[0, 1, 2, 3, 4, 5]); - - paintedChildren.clear(); - // Go down another 99px. - controller.jumpTo(349.0); - await tester.pump(); - - // One more item now visible with the last pixel of 0 showing. - expect(paintedChildren, <int>[0, 1, 2, 3, 4, 5, 6]); - - paintedChildren.clear(); - // Go down one more pixel. - controller.jumpTo(350.0); - await tester.pump(); - - // Item 0 no longer visible. - expect(paintedChildren, <int>[1, 2, 3, 4, 5, 6]); - }); - - testWidgets('Default middle transform', (WidgetTester tester) async { - await tester.pumpWidget( - new Directionality( - textDirection: TextDirection.ltr, - child: new ListWheelScrollView( - itemExtent: 100.0, - children: <Widget>[ - new Container( - width: 200.0, - child: const Center( - child: const Text('blah'), - ), ), - ], + ), ), - ), - ); + ); - final RenderListWheelViewport viewport = tester.firstRenderObject(find.byType(Container)).parent; - expect(viewport, paints..transform( - matrix4: equals(<dynamic>[ - 1.0, 0.0, 0.0, 0.0, - 0.0, 1.0, 0.0, 0.0, - -1.2 /* origin centering multiplied */, -0.9/* origin centering multiplied*/, 1.0, -0.003 /* inverse of perspective */, - moreOrLessEquals(0.0), moreOrLessEquals(0.0), 0.0, moreOrLessEquals(1.0), - ]), - )); - }); + await tester.fling( + find.byType(ListWheelScrollView), + // High and random numbers that's unlikely to land on exact multiples of 100. + const Offset(0.0, -567.0), + 678.0, + ); - testWidgets('Scrolling, diameterRatio, perspective all changes matrix', (WidgetTester tester) async { - final ScrollController controller = new ScrollController(initialScrollOffset: 200.0); + // After the drag, 40 + 567px should be on the 46th item. + expect(controller.selectedItem, 46); + // A tester.fling creates and pumps 50 pointer events. + expect(scrolledPositions.length, 50); + expect(scrolledPositions.last, moreOrLessEquals(40 * 100.0 + 567.0, epsilon: 0.2)); - await tester.pumpWidget( - new Directionality( - textDirection: TextDirection.ltr, - child: new ListWheelScrollView( - controller: controller, - itemExtent: 100.0, - children: <Widget>[ - new Container( - width: 200.0, - child: const Center( - child: const Text('blah'), - ), - ), - ], - ), - ), - ); + // Let the spring back simulation finish. + await tester.pumpAndSettle(); - final RenderListWheelViewport viewport = tester.firstRenderObject(find.byType(Container)).parent; - expect(viewport, paints..transform( - matrix4: equals(<dynamic>[ - 1.0, 0.0, 0.0, 0.0, - moreOrLessEquals(-0.41042417199080244), moreOrLessEquals(0.6318744917928065), moreOrLessEquals(0.3420201433256687), moreOrLessEquals(-0.0010260604299770061), - moreOrLessEquals(-1.12763114494309), moreOrLessEquals(-1.1877435020329863), moreOrLessEquals(0.9396926207859084), moreOrLessEquals(-0.0028190778623577253), - moreOrLessEquals(166.54856463138663), moreOrLessEquals(-62.20844875763376), moreOrLessEquals(-138.79047052615562), moreOrLessEquals(1.4163714115784667), - ]), - )); + // The simulation actually did stuff after start ballistics. + expect(scrolledPositions.length, greaterThan(50)); + // Lands on 49. + expect(controller.selectedItem, 49); + // More importantly, lands tightly on 49. + expect(scrolledPositions.last, moreOrLessEquals(49 * 100.0, epsilon: 0.2)); - // Increase diameter. - await tester.pumpWidget( - new Directionality( - textDirection: TextDirection.ltr, - child: new ListWheelScrollView( - controller: controller, - diameterRatio: 3.0, - itemExtent: 100.0, - children: <Widget>[ - new Container( - width: 200.0, - child: const Center( - child: const Text('blah'), - ), - ), - ], - ), - ), - ); - - expect(viewport, paints..transform( - matrix4: equals(<dynamic>[ - 1.0, 0.0, 0.0, 0.0, - moreOrLessEquals(-0.26954971336161726), moreOrLessEquals(0.7722830529455648), moreOrLessEquals(0.22462476113468105), moreOrLessEquals(-0.0006738742834040432), - moreOrLessEquals(-1.1693344055601331), moreOrLessEquals(-1.101625565304781), moreOrLessEquals(0.9744453379667777), moreOrLessEquals(-0.002923336013900333), - moreOrLessEquals(108.46394900436536), moreOrLessEquals(-113.14792465797223), moreOrLessEquals(-90.38662417030434), moreOrLessEquals(1.2711598725109134), - ]), - )); - - // Decrease perspective. - await tester.pumpWidget( - new Directionality( - textDirection: TextDirection.ltr, - child: new ListWheelScrollView( - controller: controller, - perspective: 0.0001, - itemExtent: 100.0, - children: <Widget>[ - new Container( - width: 200.0, - child: const Center( - child: const Text('blah'), - ), - ), - ], - ), - ), - ); - - expect(viewport, paints..transform( - matrix4: equals(<dynamic>[ - 1.0, 0.0, 0.0, 0.0, - moreOrLessEquals(-0.01368080573302675), moreOrLessEquals(0.9294320164861384), moreOrLessEquals(0.3420201433256687), moreOrLessEquals(-0.000034202014332566874), - moreOrLessEquals(-0.03758770483143634), moreOrLessEquals(-0.370210921949246), moreOrLessEquals(0.9396926207859084), moreOrLessEquals(-0.00009396926207859085), - moreOrLessEquals(5.551618821046304), moreOrLessEquals(-182.95615811538906), moreOrLessEquals(-138.79047052615562), moreOrLessEquals(1.0138790470526158), - ]), - )); - - // Scroll a bit. - controller.jumpTo(300.0); - await tester.pumpWidget( - new Directionality( - textDirection: TextDirection.ltr, - child: new ListWheelScrollView( - controller: controller, - itemExtent: 100.0, - children: <Widget>[ - new Container( - width: 200.0, - child: const Center( - child: const Text('blah'), - ), - ), - ], - ), - ) - ); - - expect(viewport, paints..transform( - matrix4: equals(<dynamic>[ - 1.0, 0.0, 0.0, 0.0, - -0.6, moreOrLessEquals(0.41602540378443875), moreOrLessEquals(0.5), moreOrLessEquals(-0.0015), - moreOrLessEquals(-1.0392304845413265), moreOrLessEquals(-1.2794228634059948), moreOrLessEquals(0.8660254037844387), moreOrLessEquals(-0.0025980762113533163), - moreOrLessEquals(276.46170927520404), moreOrLessEquals(-52.46133917892857), moreOrLessEquals(-230.38475772933677), moreOrLessEquals(1.69115427318801), - ]), - )); + debugDefaultTargetPlatformOverride = null; + }); }); }