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