blob: 227a5901eaf580971bedeaa6c434456fd4fba82c [file] [log] [blame]
// 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/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 an immutable snapshot of values associated with a
/// [ListWheelScrollView].
FixedExtentMetrics({
@required double minScrollExtent,
@required double maxScrollExtent,
@required double pixels,
@required double viewportDimension,
@required AxisDirection axisDirection,
@required this.itemIndex,
}) : super(
minScrollExtent: minScrollExtent,
maxScrollExtent: maxScrollExtent,
pixels: pixels,
viewportDimension: viewportDimension,
axisDirection: axisDirection,
);
@override
FixedExtentMetrics copyWith({
double minScrollExtent,
double maxScrollExtent,
double pixels,
double viewportDimension,
AxisDirection axisDirection,
int itemIndex,
}) {
return new FixedExtentMetrics(
minScrollExtent: minScrollExtent ?? this.minScrollExtent,
maxScrollExtent: maxScrollExtent ?? this.maxScrollExtent,
pixels: pixels ?? this.pixels,
viewportDimension: viewportDimension ?? this.viewportDimension,
axisDirection: axisDirection ?? this.axisDirection,
itemIndex: itemIndex ?? this.itemIndex,
);
}
/// 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 implements FixedExtentMetrics {
_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);
@override
int get itemIndex {
return _getItemFromOffset(
offset: pixels,
itemExtent: itemExtent,
minScrollExtent: minScrollExtent,
maxScrollExtent: maxScrollExtent,
);
}
@override
FixedExtentMetrics copyWith({
double minScrollExtent,
double maxScrollExtent,
double pixels,
double viewportDimension,
AxisDirection axisDirection,
int itemIndex,
}) {
return new FixedExtentMetrics(
minScrollExtent: minScrollExtent ?? this.minScrollExtent,
maxScrollExtent: maxScrollExtent ?? this.maxScrollExtent,
pixels: pixels ?? this.pixels,
viewportDimension: viewportDimension ?? this.viewportDimension,
axisDirection: axisDirection ?? this.axisDirection,
itemIndex: itemIndex ?? this.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
/// children must be the same size along the scrolling axis.
///
/// When the list is at the zero scroll offset, the first child is aligned with
/// the middle of the viewport. When the list is at the final scroll offset,
/// the last child is aligned with the middle of the viewport
///
/// The children are rendered as if rotating on a wheel instead of scrolling on
/// a plane.
class ListWheelScrollView extends StatefulWidget {
/// Creates a box in which children are scrolled on a wheel.
const ListWheelScrollView({
Key key,
this.controller,
this.physics,
this.diameterRatio: RenderListWheelViewport.defaultDiameterRatio,
this.perspective: RenderListWheelViewport.defaultPerspective,
@required this.itemExtent,
this.onSelectedItemChanged,
this.clipToSize: true,
this.renderChildrenOutsideViewport: false,
@required this.children,
}) : assert(diameterRatio != null),
assert(diameterRatio > 0.0, RenderListWheelViewport.diameterRatioZeroMessage),
assert(perspective != null),
assert(perspective > 0),
assert(perspective <= 0.01, RenderListWheelViewport.perspectiveTooHighMessage),
assert(itemExtent != null),
assert(itemExtent > 0),
assert(clipToSize != null),
assert(renderChildrenOutsideViewport != null),
assert(
!renderChildrenOutsideViewport || !clipToSize,
RenderListWheelViewport.clipToSizeAndRenderChildrenOutsideViewportConflict,
),
super(key: key);
/// Typically a [FixedExtentScrollController] used to control the current item.
///
/// 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.
///
/// For example, determines how the scroll view continues to animate after the
/// user stops dragging the scroll view.
///
/// Defaults to matching platform conventions.
final ScrollPhysics physics;
/// {@macro flutter.rendering.wheelList.diameterRatio}
final double diameterRatio;
/// {@macro flutter.rendering.wheelList.perspective}
final double perspective;
/// Size of each child in the main axis. Must not be null and must be
/// 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;
/// {@macro flutter.rendering.wheelList.renderChildrenOutsideViewport}
final bool renderChildrenOutsideViewport;
/// List of children to scroll on top of the cylinder.
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 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,
);
},
),
);
}
}
/// A viewport showing a subset of children on a wheel.
///
/// Typically used with [ListWheelScrollView], this viewport is similar to
/// [Viewport] in that it shows a subset of children in a scrollable based
/// on the scrolling offset and the childrens' dimensions. But uses
/// [RenderListWheelViewport] to display the children on a wheel.
///
/// See also:
///
/// * [ListWheelScrollView], widget that combines this viewport with a scrollable.
/// * [RenderListWheelViewport], the render object that renders the children
/// on a wheel.
class ListWheelViewport extends MultiChildRenderObjectWidget {
/// Create a viewport where children are rendered onto a wheel.
///
/// 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,
this.perspective: RenderListWheelViewport.defaultPerspective,
@required this.itemExtent,
this.clipToSize: true,
this.renderChildrenOutsideViewport: false,
@required this.offset,
List<Widget> children,
}) : assert(offset != null),
assert(diameterRatio != null),
assert(diameterRatio > 0, RenderListWheelViewport.diameterRatioZeroMessage),
assert(perspective != null),
assert(perspective > 0),
assert(perspective <= 0.01, RenderListWheelViewport.perspectiveTooHighMessage),
assert(itemExtent != null),
assert(itemExtent > 0),
assert(clipToSize != null),
assert(renderChildrenOutsideViewport != null),
assert(
!renderChildrenOutsideViewport || !clipToSize,
RenderListWheelViewport.clipToSizeAndRenderChildrenOutsideViewportConflict,
),
super(key: key, children: children);
/// {@macro flutter.rendering.wheelList.diameterRatio}
final double diameterRatio;
/// {@macro flutter.rendering.wheelList.perspective}
final double perspective;
/// {@macro flutter.rendering.wheelList.itemExtent}
final double itemExtent;
/// {@macro flutter.rendering.wheelList.clipToSize}
final bool clipToSize;
/// {@macro flutter.rendering.wheelList.renderChildrenOutsideViewport}
final bool renderChildrenOutsideViewport;
/// [ViewportOffset] object describing the content that should be visible
/// in the viewport.
final ViewportOffset offset;
@override
RenderListWheelViewport createRenderObject(BuildContext context) {
return new RenderListWheelViewport(
diameterRatio: diameterRatio,
perspective: perspective,
itemExtent: itemExtent,
clipToSize: clipToSize,
renderChildrenOutsideViewport: renderChildrenOutsideViewport,
offset: offset,
);
}
@override
void updateRenderObject(BuildContext context, RenderListWheelViewport renderObject) {
renderObject
..diameterRatio = diameterRatio
..perspective = perspective
..itemExtent = itemExtent
..clipToSize = clipToSize
..renderChildrenOutsideViewport = renderChildrenOutsideViewport
..offset = offset;
}
}