Unverified Commit 667f4785 authored by xster's avatar xster Committed by GitHub

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
parent b6c6e365
...@@ -6,5 +6,6 @@ export 'cupertino_activity_indicator_demo.dart'; ...@@ -6,5 +6,6 @@ export 'cupertino_activity_indicator_demo.dart';
export 'cupertino_buttons_demo.dart'; export 'cupertino_buttons_demo.dart';
export 'cupertino_dialog_demo.dart'; export 'cupertino_dialog_demo.dart';
export 'cupertino_navigation_demo.dart'; export 'cupertino_navigation_demo.dart';
export 'cupertino_picker_demo.dart';
export 'cupertino_slider_demo.dart'; export 'cupertino_slider_demo.dart';
export 'cupertino_switch_demo.dart'; export 'cupertino_switch_demo.dart';
// 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(),
),
],
),
),
),
);
}
}
...@@ -306,6 +306,13 @@ List<GalleryItem> _buildGalleryItems() { ...@@ -306,6 +306,13 @@ List<GalleryItem> _buildGalleryItems() {
routeName: CupertinoNavigationDemo.routeName, routeName: CupertinoNavigationDemo.routeName,
buildRoute: (BuildContext context) => new CupertinoNavigationDemo(), 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( new GalleryItem(
title: 'Sliders', title: 'Sliders',
subtitle: 'Cupertino styled sliders', subtitle: 'Cupertino styled sliders',
......
...@@ -71,6 +71,7 @@ const List<Demo> demos = const <Demo>[ ...@@ -71,6 +71,7 @@ const List<Demo> demos = const <Demo>[
const Demo('Buttons'), const Demo('Buttons'),
const Demo('Dialogs'), const Demo('Dialogs'),
const Demo('Navigation'), const Demo('Navigation'),
const Demo('Pickers'),
const Demo('Sliders'), const Demo('Sliders'),
const Demo('Switches'), const Demo('Switches'),
......
...@@ -15,6 +15,7 @@ export 'src/cupertino/dialog.dart'; ...@@ -15,6 +15,7 @@ export 'src/cupertino/dialog.dart';
export 'src/cupertino/icons.dart'; export 'src/cupertino/icons.dart';
export 'src/cupertino/nav_bar.dart'; export 'src/cupertino/nav_bar.dart';
export 'src/cupertino/page_scaffold.dart'; export 'src/cupertino/page_scaffold.dart';
export 'src/cupertino/picker.dart';
export 'src/cupertino/route.dart'; export 'src/cupertino/route.dart';
export 'src/cupertino/scrollbar.dart'; export 'src/cupertino/scrollbar.dart';
export 'src/cupertino/slider.dart'; export 'src/cupertino/slider.dart';
......
...@@ -37,11 +37,11 @@ class CupertinoColors { ...@@ -37,11 +37,11 @@ class CupertinoColors {
/// Used in iOS 10 for light background fills such as the chat bubble background. /// Used in iOS 10 for light background fills such as the chat bubble background.
static const Color lightBackgroundGray = const Color(0xFFE5E5EA); static const Color lightBackgroundGray = const Color(0xFFE5E5EA);
/// Used in iOS 10 for unselected selectables such as tab bar items in their /// Used in iOS 11 for unselected selectables such as tab bar items in their
/// inactive state. /// inactive state or de-emphasized subtitles and details text.
/// ///
/// Not the same gray as disabled buttons etc. /// 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 /// Used for iOS 10 for destructive actions such as the delete actions in
/// table view cells and dialogs. /// table view cells and dialogs.
......
// 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(),
],
),
);
}
}
...@@ -35,7 +35,7 @@ class ListWheelParentData extends ContainerBoxParentData<RenderBox> { } ...@@ -35,7 +35,7 @@ class ListWheelParentData extends ContainerBoxParentData<RenderBox> { }
/// ///
/// This class works in 3 coordinate systems: /// 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 /// communicate with [ViewportOffset] and describes its children's abstract
/// offset from the beginning of the scrollable list at (0.0, 0.0). /// offset from the beginning of the scrollable list at (0.0, 0.0).
/// ///
...@@ -44,7 +44,7 @@ class ListWheelParentData extends ContainerBoxParentData<RenderBox> { } ...@@ -44,7 +44,7 @@ class ListWheelParentData extends ContainerBoxParentData<RenderBox> { }
/// ///
/// Children's layout coordinates don't change as the viewport scrolls. /// 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 /// not painted in this coordinate system. It's an abstract intermediary used
/// before transforming into the next cylindrical coordinate system. /// before transforming into the next cylindrical coordinate system.
/// ///
...@@ -63,7 +63,7 @@ class ListWheelParentData extends ContainerBoxParentData<RenderBox> { } ...@@ -63,7 +63,7 @@ class ListWheelParentData extends ContainerBoxParentData<RenderBox> { }
/// paint 10-11 visible 10px children if there are enough children in the /// paint 10-11 visible 10px children if there are enough children in the
/// viewport. /// 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 /// Children from system 2 get their positions transformed into a cylindrical
/// projection matrix instead of its cartesian offset with respect to the /// projection matrix instead of its cartesian offset with respect to the
/// scroll offset. /// scroll offset.
...@@ -130,13 +130,13 @@ class RenderListWheelViewport ...@@ -130,13 +130,13 @@ class RenderListWheelViewport
/// An arbitrary but aesthetically reasonable default value for [diameterRatio]. /// An arbitrary but aesthetically reasonable default value for [diameterRatio].
static const double defaultDiameterRatio = 2.0; 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; static const double defaultPerspective = 0.003;
/// An error message to show when the provided [diameterRatio] is zero. /// An error message to show when the provided [diameterRatio] is zero.
static const String diameterRatioZeroMessage = "You can't set a diameterRatio " 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 ' 'of 0 or of a negative number. It would imply a cylinder of 0 in diameter '
'will be drawn.'; 'in which case nothing will be drawn.';
/// An error message to show when the [perspective] value is too high. /// An error message to show when the [perspective] value is too high.
static const String perspectiveTooHighMessage = 'A perspective too high will ' static const String perspectiveTooHighMessage = 'A perspective too high will '
...@@ -563,7 +563,9 @@ class RenderListWheelViewport ...@@ -563,7 +563,9 @@ class RenderListWheelViewport
); );
context.pushTransform( context.pushTransform(
needsCompositing, // Text with TransformLayers and no cullRects currently have an issue rendering
// https://github.com/flutter/flutter/issues/14224.
false,
offset, offset,
_centerOriginTransform(transform), _centerOriginTransform(transform),
// Pre-transform painting function. // Pre-transform painting function.
...@@ -591,9 +593,12 @@ class RenderListWheelViewport ...@@ -591,9 +593,12 @@ class RenderListWheelViewport
return result; return result;
} }
/// This returns the matrices relative to the **untransformed plane's viewport
/// painting coordinates** system.
@override @override
void applyPaintTransform(RenderBox child, Matrix4 transform) { 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 @override
......
// Copyright 2017 The Chromium Authors. All rights reserved. // Copyright 2017 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // 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/foundation.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'framework.dart'; import 'framework.dart';
import 'notification_listener.dart';
import 'scroll_context.dart';
import 'scroll_controller.dart'; import 'scroll_controller.dart';
import 'scroll_metrics.dart';
import 'scroll_notification.dart';
import 'scroll_physics.dart'; import 'scroll_physics.dart';
import 'scroll_position.dart';
import 'scroll_position_with_single_context.dart';
import 'scrollable.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. /// A box in which children on a wheel can be scrolled.
/// ///
/// This widget is similar to a [ListView] but with the restriction that all /// This widget is similar to a [ListView] but with the restriction that all
...@@ -21,7 +344,7 @@ import 'scrollable.dart'; ...@@ -21,7 +344,7 @@ import 'scrollable.dart';
/// ///
/// The children are rendered as if rotating on a wheel instead of scrolling on /// The children are rendered as if rotating on a wheel instead of scrolling on
/// a plane. /// a plane.
class ListWheelScrollView extends StatelessWidget { class ListWheelScrollView extends StatefulWidget {
/// Creates a box in which children are scrolled on a wheel. /// Creates a box in which children are scrolled on a wheel.
const ListWheelScrollView({ const ListWheelScrollView({
Key key, Key key,
...@@ -30,6 +353,7 @@ class ListWheelScrollView extends StatelessWidget { ...@@ -30,6 +353,7 @@ class ListWheelScrollView extends StatelessWidget {
this.diameterRatio: RenderListWheelViewport.defaultDiameterRatio, this.diameterRatio: RenderListWheelViewport.defaultDiameterRatio,
this.perspective: RenderListWheelViewport.defaultPerspective, this.perspective: RenderListWheelViewport.defaultPerspective,
@required this.itemExtent, @required this.itemExtent,
this.onSelectedItemChanged,
this.clipToSize: true, this.clipToSize: true,
this.renderChildrenOutsideViewport: false, this.renderChildrenOutsideViewport: false,
@required this.children, @required this.children,
...@@ -48,16 +372,21 @@ class ListWheelScrollView extends StatelessWidget { ...@@ -48,16 +372,21 @@ class ListWheelScrollView extends StatelessWidget {
), ),
super(key: key); super(key: key);
/// An object that can be used to control the position to which this scroll /// Typically a [FixedExtentScrollController] used to control the current item.
/// view is scrolled. ///
/// 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.
/// ///
/// A [ScrollController] serves several purposes. It can be used to control /// If a [ScrollController] is used instead of [FixedExtentScrollController],
/// the initial scroll position (see [ScrollController.initialScrollOffset]). /// [ScrollNotification.metrics] will no longer provide [FixedExtentMetrics]
/// It can be used to control whether the scroll view should automatically /// to indicate the current item index and [onSelectedItemChanged] will not
/// save and restore its scroll position in the [PageStorage] (see /// work.
/// [ScrollController.keepScrollOffset]). It can be used to read the current ///
/// scroll position (see [ScrollController.offset]), or change it (see /// To read the current selected item only when the value changes, use
/// [ScrollController.animateTo]). /// [onSelectedItemChanged].
final ScrollController controller; final ScrollController controller;
/// How the scroll view should respond to user input. /// How the scroll view should respond to user input.
...@@ -78,6 +407,9 @@ class ListWheelScrollView extends StatelessWidget { ...@@ -78,6 +407,9 @@ class ListWheelScrollView extends StatelessWidget {
/// positive. /// positive.
final double itemExtent; final double itemExtent;
/// On optional listener that's called when the centered item changes.
final ValueChanged<int> onSelectedItemChanged;
/// {@macro flutter.rendering.wheelList.clipToSize} /// {@macro flutter.rendering.wheelList.clipToSize}
final bool clipToSize; final bool clipToSize;
...@@ -87,22 +419,69 @@ class ListWheelScrollView extends StatelessWidget { ...@@ -87,22 +419,69 @@ class ListWheelScrollView extends StatelessWidget {
/// List of children to scroll on top of the cylinder. /// List of children to scroll on top of the cylinder.
final List<Widget> children; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return new Scrollable( return new NotificationListener<ScrollNotification>(
controller: controller, onNotification: (ScrollNotification notification) {
physics: physics, if (notification.depth == 0
viewportBuilder: (BuildContext context, ViewportOffset offset) { && widget.onSelectedItemChanged != null
return new ListWheelViewport( && notification is ScrollUpdateNotification
diameterRatio: diameterRatio, && notification.metrics is FixedExtentMetrics) {
perspective: perspective, final FixedExtentMetrics metrics = notification.metrics;
itemExtent: itemExtent, final int currentItemIndex = metrics.itemIndex;
clipToSize: clipToSize, if (currentItemIndex != _lastReportedItemIndex) {
renderChildrenOutsideViewport: renderChildrenOutsideViewport, _lastReportedItemIndex = currentItemIndex;
offset: offset, widget.onSelectedItemChanged(currentItemIndex);
children: children, }
); }
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 @@ class ListWheelScrollView extends StatelessWidget { ...@@ -120,6 +499,20 @@ class ListWheelScrollView extends StatelessWidget {
/// * [RenderListWheelViewport], the render object that renders the children /// * [RenderListWheelViewport], the render object that renders the children
/// on a wheel. /// on a wheel.
class ListWheelViewport extends MultiChildRenderObjectWidget { 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({ ListWheelViewport({
Key key, Key key,
this.diameterRatio: RenderListWheelViewport.defaultDiameterRatio, this.diameterRatio: RenderListWheelViewport.defaultDiameterRatio,
......
...@@ -99,7 +99,8 @@ class PageController extends ScrollController { ...@@ -99,7 +99,8 @@ class PageController extends ScrollController {
); );
assert( assert(
positions.length == 1, 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; final _PagePosition position = this.position;
return position.page; return position.page;
......
// 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;
});
});
}
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
// found in the LICENSE file. // found in the LICENSE file.
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
...@@ -10,371 +11,735 @@ import '../rendering/mock_canvas.dart'; ...@@ -10,371 +11,735 @@ import '../rendering/mock_canvas.dart';
import '../rendering/rendering_tester.dart'; import '../rendering/rendering_tester.dart';
void main() { void main() {
testWidgets('ListWheelScrollView needs positive diameter ratio', (WidgetTester tester) async { group('construction check', () {
try { testWidgets('ListWheelScrollView needs positive diameter ratio', (WidgetTester tester) async {
new ListWheelScrollView( try {
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( new ListWheelScrollView(
itemExtent: null, diameterRatio: -2.0,
children: <Widget>[new Container()], itemExtent: 20.0,
children: <Widget>[],
); );
}, fail('Expected failure with negative diameterRatio');
throwsAssertionError, } on AssertionError catch (exception) {
); expect(exception.message, contains("You can't set a diameterRatio of 0"));
}); }
});
testWidgets("ListWheelScrollView takes parent's size with small children", (WidgetTester tester) async {
await tester.pumpWidget( testWidgets('ListWheelScrollView needs positive item extent', (WidgetTester tester) async {
new Directionality( expect(
textDirection: TextDirection.ltr, () {
child: new ListWheelScrollView( new ListWheelScrollView(
// Inner children smaller than the outer window. itemExtent: null,
itemExtent: 50.0, children: <Widget>[new Container()],
children: <Widget>[ );
new Container( },
height: 50.0, throwsAssertionError,
color: const Color(0xFFFFFFFF), );
), });
],
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.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 { group('layout', () {
await tester.pumpWidget( testWidgets("ListWheelScrollView takes parent's size with small children", (WidgetTester tester) async {
new Directionality( await tester.pumpWidget(
textDirection: TextDirection.ltr, new Directionality(
child: new ListWheelScrollView( textDirection: TextDirection.ltr,
// Inner children 5000.0px. child: new ListWheelScrollView(
itemExtent: 50.0, // Inner children smaller than the outer window.
children: new List<Widget>.generate(100, (int index) { itemExtent: 50.0,
return new Container( children: <Widget>[
height: 50.0, new Container(
color: const Color(0xFFFFFFFF), height: 50.0,
); color: const Color(0xFFFFFFFF),
}), ),
],
),
), ),
) );
); expect(tester.getTopLeft(find.byType(ListWheelScrollView)), const Offset(0.0, 0.0));
expect(tester.getTopLeft(find.byType(ListWheelScrollView)), const Offset(0.0, 0.0)); // Standard test screen size.
// Still fills standard test screen size. expect(tester.getBottomRight(find.byType(ListWheelScrollView)), const Offset(800.0, 600.0));
expect(tester.getBottomRight(find.byType(ListWheelScrollView)), const Offset(800.0, 600.0)); });
});
testWidgets("ListWheelScrollView takes parent's size with large children", (WidgetTester tester) async {
testWidgets("ListWheelScrollView children can't be bigger than itemExtent", (WidgetTester tester) async { await tester.pumpWidget(
await tester.pumpWidget( new Directionality(
new Directionality( textDirection: TextDirection.ltr,
textDirection: TextDirection.ltr, child: new ListWheelScrollView(
child: new ListWheelScrollView( // Inner children 5000.0px.
itemExtent: 50.0, itemExtent: 50.0,
children: <Widget>[ children: new List<Widget>.generate(100, (int index) {
const SizedBox( return new Container(
height: 200.0, height: 50.0,
width: 200.0, color: const Color(0xFFFFFFFF),
child: const Center( );
child: const Text('blah'), }),
),
),
);
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(tester.getSize(find.byType(SizedBox)), const Size(200.0, 50.0)); expect(find.text('blah'), findsOneWidget);
expect(find.text('blah'), findsOneWidget); });
}); });
testWidgets('ListWheelScrollView can have zero child', (WidgetTester tester) async { group('pre-transform viewport', () {
await tester.pumpWidget( testWidgets('ListWheelScrollView starts and ends from the middle', (WidgetTester tester) async {
new Directionality( final ScrollController controller = new ScrollController();
textDirection: TextDirection.ltr, final List<int> paintedChildren = <int>[];
child: new ListWheelScrollView(
itemExtent: 50.0, await tester.pumpWidget(
children: <Widget>[], 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);
}),
);
}),
),
), ),
), );
);
expect(tester.getSize(find.byType(ListWheelScrollView)), const Size(800.0, 600.0));
});
testWidgets('ListWheelScrollView starts and ends from the middle', (WidgetTester tester) async { // Screen is 600px tall and the first item starts at 250px. The first 4
final ScrollController controller = new ScrollController(); // children are visible.
final List<int> paintedChildren = <int>[]; expect(paintedChildren, <int>[0, 1, 2, 3]);
await tester.pumpWidget( controller.jumpTo(1000.0);
new Directionality( paintedChildren.clear();
textDirection: TextDirection.ltr,
child: new ListWheelScrollView( await tester.pump();
controller: controller, // Item number 10 is now in the middle of the screen at 250px. 9, 8, 7 are
itemExtent: 100.0, // visible before it and 11, 12, 13 are visible after it.
children: new List<Widget>.generate(100, (int index) { expect(paintedChildren, <int>[7, 8, 9, 10, 11, 12, 13]);
return new CustomPaint(
painter: new TestCallbackPainter(onPaint: () { // Move to the last item.
paintedChildren.add(index); 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);
}),
);
}),
),
), ),
), );
);
// Screen is 600px tall and the first item starts at 250px. The first 4 // The first item is at 0px and the 600px screen is full in the
// children are visible. // **untransformed plane's viewport painting coordinates**
expect(paintedChildren, <int>[0, 1, 2, 3]); expect(paintedChildren, <int>[0, 1, 2, 3, 4, 5]);
controller.jumpTo(1000.0); paintedChildren.clear();
paintedChildren.clear(); // Go down another 99px.
controller.jumpTo(349.0);
await tester.pump();
await tester.pump(); // One more item now visible with the last pixel of 0 showing.
// Item number 10 is now in the middle of the screen at 250px. 9, 8, 7 are expect(paintedChildren, <int>[0, 1, 2, 3, 4, 5, 6]);
// 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. paintedChildren.clear();
controller.jumpTo(9900.0); // Go down one more pixel.
paintedChildren.clear(); controller.jumpTo(350.0);
await tester.pump();
await tester.pump(); // Item 0 no longer visible.
// Item 99 is in the middle at 250px. expect(paintedChildren, <int>[1, 2, 3, 4, 5, 6]);
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 { group('viewport transformation', () {
final ScrollController controller = new ScrollController(initialScrollOffset: 50.0); testWidgets('Default middle transform', (WidgetTester tester) async {
final List<int> paintedChildren = <int>[]; await tester.pumpWidget(
new Directionality(
await tester.pumpWidget( textDirection: TextDirection.ltr,
new Directionality( child: new ListWheelScrollView(
textDirection: TextDirection.ltr, itemExtent: 100.0,
child: new ListWheelScrollView( children: <Widget>[
controller: controller, new Container(
itemExtent: 100.0, width: 200.0,
children: new List<Widget>.generate(10, (int index) { child: const Center(
return new CustomPaint( child: const Text('blah'),
painter: new TestCallbackPainter(onPaint: () { ),
paintedChildren.add(index); ),
}), ],
); ),
}), ),
);
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'),
),
),
],
),
), ),
), );
);
// Screen is 600px tall and the first item starts at 200px. The first 4 expect(viewport, paints..transform(
// children are visible. matrix4: equals(<dynamic>[
expect(paintedChildren, <int>[0, 1, 2, 3]); 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'),
),
),
],
),
),
);
paintedChildren.clear(); expect(viewport, paints..transform(
// Move down by 1px. matrix4: equals(<dynamic>[
await tester.drag(find.byType(ListWheelScrollView), const Offset(0.0, -1.0)); 1.0, 0.0, 0.0, 0.0,
await tester.pump(); 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'),
),
),
],
),
),
);
// Now the first pixel of item 5 enters the viewport. expect(viewport, paints..transform(
expect(paintedChildren, <int>[0, 1, 2, 3, 4]); 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),
]),
));
});
}); });
testWidgets('A child is no longer painted after its last pixel leaves the viewport', (WidgetTester tester) async { group('scroll notifications', () {
final ScrollController controller = new ScrollController(initialScrollOffset: 250.0); testWidgets('no onSelectedItemChanged callback on first build', (WidgetTester tester) async {
final List<int> paintedChildren = <int>[]; bool itemChangeCalled = false;
final ValueChanged<int> onItemChange = (_) { itemChangeCalled = true; };
await tester.pumpWidget(
new Directionality( await tester.pumpWidget(
textDirection: TextDirection.ltr, new Directionality(
child: new ListWheelScrollView( textDirection: TextDirection.ltr,
controller: controller, child: new ListWheelScrollView(
itemExtent: 100.0, itemExtent: 100.0,
children: new List<Widget>.generate(10, (int index) { onSelectedItemChanged: onItemChange,
return new CustomPaint( children: <Widget>[
painter: new TestCallbackPainter(onPaint: () { new Container(
paintedChildren.add(index); 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();
}),
),
),
);
// The first item is at 0px and the 600px screen is full in the final TestGesture scrollGesture = await tester.startGesture(const Offset(10.0, 10.0));
// **untransformed plane's viewport painting coordinates** // Item 0 is still closest to the center. No updates.
expect(paintedChildren, <int>[0, 1, 2, 3, 4, 5]); 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();
}),
),
),
);
paintedChildren.clear(); final TestGesture scrollGesture = await tester.startGesture(const Offset(10.0, 10.0));
// Go down another 99px.
controller.jumpTo(349.0);
await tester.pump();
// One more item now visible with the last pixel of 0 showing. // First move back past the beginning.
expect(paintedChildren, <int>[0, 1, 2, 3, 4, 5, 6]); await scrollGesture.moveBy(const Offset(0.0, 70.0));
paintedChildren.clear(); for (double verticalOffset = 0.0; verticalOffset > -2000.0; verticalOffset -= 10.0) {
// Go down one more pixel. // Then gradually move down by a total vertical extent much higher than
controller.jumpTo(350.0); // the scrollable extent.
await tester.pump(); await scrollGesture.moveTo(new Offset(0.0, verticalOffset));
}
// Item 0 no longer visible. // The list should only cover the list of valid items. Item 0 would not
expect(paintedChildren, <int>[1, 2, 3, 4, 5, 6]); // 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]);
});
}); });
testWidgets('Default middle transform', (WidgetTester tester) async { group('scroll controller', () {
await tester.pumpWidget( testWidgets('initialItem', (WidgetTester tester) async {
new Directionality( final FixedExtentScrollController controller = new FixedExtentScrollController(initialItem: 10);
textDirection: TextDirection.ltr, final List<int> paintedChildren = <int>[];
child: new ListWheelScrollView(
itemExtent: 100.0, await tester.pumpWidget(
children: <Widget>[ new Directionality(
new Container( textDirection: TextDirection.ltr,
width: 200.0, child: new ListWheelScrollView(
child: const Center( controller: controller,
child: const Text('blah'), itemExtent: 100.0,
), children: new List<Widget>.generate(100, (int index) {
), return new CustomPaint(
], painter: new TestCallbackPainter(onPaint: () {
paintedChildren.add(index);
}),
);
}),
),
), ),
), );
);
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 { // Screen is 600px tall. Item 10 is in the center and each item is 100px tall.
final ScrollController controller = new ScrollController(initialScrollOffset: 200.0); expect(paintedChildren, <int>[7, 8, 9, 10, 11, 12, 13]);
expect(controller.selectedItem, 10);
await tester.pumpWidget( });
new Directionality(
textDirection: TextDirection.ltr, testWidgets('controller jump', (WidgetTester tester) async {
child: new ListWheelScrollView( final FixedExtentScrollController controller = new FixedExtentScrollController(initialItem: 10);
controller: controller, final List<int> paintedChildren = <int>[];
itemExtent: 100.0,
children: <Widget>[ await tester.pumpWidget(
new Container( new Directionality(
width: 200.0, textDirection: TextDirection.ltr,
child: const Center( child: new ListWheelScrollView(
child: const Text('blah'), 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.
final RenderListWheelViewport viewport = tester.firstRenderObject(find.byType(Container)).parent; expect(paintedChildren, <int>[7, 8, 9, 10, 11, 12, 13]);
expect(viewport, paints..transform(
matrix4: equals(<dynamic>[ paintedChildren.clear();
1.0, 0.0, 0.0, 0.0, controller.jumpToItem(0);
moreOrLessEquals(-0.41042417199080244), moreOrLessEquals(0.6318744917928065), moreOrLessEquals(0.3420201433256687), moreOrLessEquals(-0.0010260604299770061), await tester.pump();
moreOrLessEquals(-1.12763114494309), moreOrLessEquals(-1.1877435020329863), moreOrLessEquals(0.9396926207859084), moreOrLessEquals(-0.0028190778623577253),
moreOrLessEquals(166.54856463138663), moreOrLessEquals(-62.20844875763376), moreOrLessEquals(-138.79047052615562), moreOrLessEquals(1.4163714115784667), expect(paintedChildren, <int>[0, 1, 2, 3]);
]), expect(controller.selectedItem, 0);
)); });
// Increase diameter. testWidgets('onSelectedItemChanged and controller are in sync', (WidgetTester tester) async {
await tester.pumpWidget( final List<int> selectedItems = <int>[];
new Directionality( final FixedExtentScrollController controller = new FixedExtentScrollController(initialItem: 10);
textDirection: TextDirection.ltr,
child: new ListWheelScrollView( await tester.pumpWidget(
controller: controller, new Directionality(
diameterRatio: 3.0, textDirection: TextDirection.ltr,
itemExtent: 100.0, child: new ListWheelScrollView(
children: <Widget>[ controller: controller,
new Container( itemExtent: 100.0,
width: 200.0, onSelectedItemChanged: (int index) { selectedItems.add(index); },
child: const Center( children: new List<Widget>.generate(100, (int index) {
child: const Text('blah'), return const Placeholder();
), }),
), ),
],
), ),
), );
);
final TestGesture scrollGesture = await tester.startGesture(const Offset(10.0, 10.0));
expect(viewport, paints..transform( await scrollGesture.moveBy(const Offset(0.0, -49.0));
matrix4: equals(<dynamic>[ await tester.pump();
1.0, 0.0, 0.0, 0.0, expect(selectedItems.isEmpty, true);
moreOrLessEquals(-0.26954971336161726), moreOrLessEquals(0.7722830529455648), moreOrLessEquals(0.22462476113468105), moreOrLessEquals(-0.0006738742834040432), expect(controller.selectedItem, 10);
moreOrLessEquals(-1.1693344055601331), moreOrLessEquals(-1.101625565304781), moreOrLessEquals(0.9744453379667777), moreOrLessEquals(-0.002923336013900333),
moreOrLessEquals(108.46394900436536), moreOrLessEquals(-113.14792465797223), moreOrLessEquals(-90.38662417030434), moreOrLessEquals(1.2711598725109134), await scrollGesture.moveBy(const Offset(0.0, -1.0));
]), await tester.pump();
)); expect(selectedItems, <int>[11]);
expect(controller.selectedItem, 11);
// Decrease perspective.
await tester.pumpWidget( await scrollGesture.moveBy(const Offset(0.0, 70.0));
new Directionality( await tester.pump();
textDirection: TextDirection.ltr, expect(selectedItems, <int>[11, 10]);
child: new ListWheelScrollView( expect(controller.selectedItem, 10);
controller: controller, });
perspective: 0.0001,
itemExtent: 100.0, testWidgets('controller hot swappable', (WidgetTester tester) async {
children: <Widget>[ await tester.pumpWidget(
new Container( new Directionality(
width: 200.0, textDirection: TextDirection.ltr,
child: const Center( child: new ListWheelScrollView(
child: const Text('blah'), 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();
}),
), ),
], ),
), ),
), );
);
await tester.fling(
expect(viewport, paints..transform( find.byType(ListWheelScrollView),
matrix4: equals(<dynamic>[ const Offset(0.0, -50.0),
1.0, 0.0, 0.0, 0.0, 800.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), // 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.
// Scroll a bit. expect(scrolledPositions.length, 50);
controller.jumpTo(300.0); expect(scrolledPositions.last, moreOrLessEquals(40 * 1000.0 + 50.0, epsilon: 0.2));
await tester.pumpWidget(
new Directionality( // Let the spring back simulation finish.
textDirection: TextDirection.ltr, await tester.pump();
child: new ListWheelScrollView( await tester.pump(const Duration(seconds: 1));
controller: controller,
itemExtent: 100.0, // The simulation actually did stuff after start ballistics.
children: <Widget>[ expect(scrolledPositions.length, greaterThan(50));
new Container( // Though it still lands back to the same item with the same scroll offset.
width: 200.0, expect(controller.selectedItem, 40);
child: const Center( expect(scrolledPositions.last, moreOrLessEquals(40 * 1000.0, epsilon: 0.2));
child: const Text('blah'), });
),
testWidgets('high fling velocities lands exactly on items', (WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
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: 100.0,
children: new List<Widget>.generate(100, (int index) {
return const Placeholder();
}),
), ),
], ),
), ),
) );
);
await tester.fling(
expect(viewport, paints..transform( find.byType(ListWheelScrollView),
matrix4: equals(<dynamic>[ // High and random numbers that's unlikely to land on exact multiples of 100.
1.0, 0.0, 0.0, 0.0, const Offset(0.0, -567.0),
-0.6, moreOrLessEquals(0.41602540378443875), moreOrLessEquals(0.5), moreOrLessEquals(-0.0015), 678.0,
moreOrLessEquals(-1.0392304845413265), moreOrLessEquals(-1.2794228634059948), moreOrLessEquals(0.8660254037844387), moreOrLessEquals(-0.0025980762113533163), );
moreOrLessEquals(276.46170927520404), moreOrLessEquals(-52.46133917892857), moreOrLessEquals(-230.38475772933677), moreOrLessEquals(1.69115427318801),
]), // 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));
// Let the spring back simulation finish.
await tester.pumpAndSettle();
// 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));
debugDefaultTargetPlatformOverride = null;
});
}); });
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment