From e64d44ca78499712dfc0dab5ce074f79f03f5225 Mon Sep 17 00:00:00 2001 From: xster Date: Mon, 22 Jan 2018 12:02:32 -0800 Subject: [PATCH] CupertinoPicker part 1 - create a generic ListWheelScrollView (#13783) * Create ListWheelScrollView * fix missing doc * tweak docs a bit * fix imports * Add some tests * review * review and transform tests * fix test * repatch lost https://github.com/flutter/flutter/commit/977701cf695ec106a52dde9f3659a7b132826748 * review * Review --- packages/flutter/lib/rendering.dart | 1 + .../src/rendering/list_wheel_viewport.dart | 636 ++++++++++++++++++ .../src/widgets/list_wheel_scroll_view.dart | 189 ++++++ packages/flutter/lib/widgets.dart | 1 + .../flutter/test/rendering/mock_canvas.dart | 20 + .../test/rendering/recording_canvas.dart | 8 + .../widgets/list_wheel_scroll_view_test.dart | 380 +++++++++++ 7 files changed, 1235 insertions(+) create mode 100644 packages/flutter/lib/src/rendering/list_wheel_viewport.dart create mode 100644 packages/flutter/lib/src/widgets/list_wheel_scroll_view.dart create mode 100644 packages/flutter/test/widgets/list_wheel_scroll_view_test.dart diff --git a/packages/flutter/lib/rendering.dart b/packages/flutter/lib/rendering.dart index 979ea124362..049e9627b42 100644 --- a/packages/flutter/lib/rendering.dart +++ b/packages/flutter/lib/rendering.dart @@ -44,6 +44,7 @@ export 'src/rendering/flow.dart'; export 'src/rendering/image.dart'; export 'src/rendering/layer.dart'; export 'src/rendering/list_body.dart'; +export 'src/rendering/list_wheel_viewport.dart'; export 'src/rendering/object.dart'; export 'src/rendering/paragraph.dart'; export 'src/rendering/performance_overlay.dart'; diff --git a/packages/flutter/lib/src/rendering/list_wheel_viewport.dart b/packages/flutter/lib/src/rendering/list_wheel_viewport.dart new file mode 100644 index 00000000000..9ee55c81ea8 --- /dev/null +++ b/packages/flutter/lib/src/rendering/list_wheel_viewport.dart @@ -0,0 +1,636 @@ +// 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:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:vector_math/vector_math_64.dart' show Matrix4; + +import 'box.dart'; +import 'object.dart'; +import 'viewport.dart'; +import 'viewport_offset.dart'; + +typedef double _ChildSizingFunction(RenderBox child); + +/// [ParentData] for use with [RenderListWheelViewport]. +class ListWheelParentData extends ContainerBoxParentData { } + +/// Render, onto a wheel, a bigger sequential set of objects inside this viewport. +/// +/// Takes a scrollable set of fixed sized [RenderBox]es and renders them +/// sequentially from top down on a vertical scrolling axis. +/// +/// It starts with the first scrollable item in the center of the main axis +/// and ends with the last scrollable item in the center of the main axis. This +/// is in contrast to typical lists that start with the first scrollable item +/// at the start of the main axis and ends with the last scrollable item at the +/// end of the main axis. +/// +/// Instead of rendering its children on a flat plane, it renders them +/// as if each child is broken into its own plane and that plane is +/// perpendicularly fixed onto a cylinder which rotates along the scrolling +/// axis. +/// +/// This class works in 3 coordinate systems: +/// +/// 1- The **scrollable layout coordinates**. This coordinate system is used to +/// communicate with [ViewportOffset] and describes its children's abstract +/// offset from the beginning of the scrollable list at (0.0, 0.0). +/// +/// The list is scrollable from the start of the first child item to the +/// start of the last child item. +/// +/// Children's layout coordinates don't change as the viewport scrolls. +/// +/// 2- The **untransformed plane's viewport painting coordinates**. Children are +/// not painted in this coordinate system. It's an abstract intermediary used +/// before transforming into the next cylindrical coordinate system. +/// +/// This system is the **scrollable layout coordinates** translated by the +/// scroll offset such that (0.0, 0.0) is the top left corner of the +/// viewport. +/// +/// Because the viewport is centered at the scrollable list's scroll offset +/// instead of starting at the scroll offset, there are paintable children +/// ~1/2 viewport length before and after the scroll offset instead of ~1 +/// viewport length after the scroll offset. +/// +/// Children's visibility inclusion in the viewport is determined in this +/// system regardless of the cylinder's properties such as [diameterRatio] +/// or [perspective]. In other words, a 100px long viewport will always +/// paint 10-11 visible 10px children if there are enough children in the +/// viewport. +/// +/// 3- The **transformed cylindrical space viewport painting coordinates**. +/// Children from system 2 get their positions transformed into a cylindrical +/// projection matrix instead of its cartesian offset with respect to the +/// scroll offset. +/// +/// Children in this coordinate system are painted. +/// +/// The wheel's size and the maximum and minimum visible angles are both +/// controlled by [diameterRatio]. Children visible in the **untransformed +/// plane's viewport painting coordinates**'s viewport will be radially +/// evenly laid out between the maximum and minimum angles determined by +/// intersecting the viewport's main axis length with a cylinder whose +/// diameter is [diameterRatio] times longer, as long as those angles are +/// between -pi/2 and pi/2. +/// +/// For example, if [diameterRatio] is 2.0 and this [RenderListWheelViewport] +/// is 100.0px in the main axis, then the diameter is 200.0. And children +/// will be evenly laid out between that cylinder's -arcsin(1/2) and +/// arcsin(1/2) angles. +/// +/// The cylinder's 0 degree side is always centered in the +/// [RenderListWheelViewport]. The transformation from **untransformed +/// plane's viewport painting coordinates** is also done such that the child +/// in the center of that plane will be mostly untransformed with children +/// above and below it being transformed more as the angle increases. +class RenderListWheelViewport + extends RenderBox + with ContainerRenderObjectMixin, + RenderBoxContainerDefaultsMixin + implements RenderAbstractViewport { + /// Creates a [RenderListWheelViewport] which renders children on a wheel. + /// + /// All arguments must not be null. Optional arguments have reasonable defaults. + RenderListWheelViewport({ + @required ViewportOffset offset, + double diameterRatio: defaultDiameterRatio, + double perspective: defaultPerspective, + @required double itemExtent, + bool clipToSize: true, + bool renderChildrenOutsideViewport: false, + List children, + }) : assert(offset != null), + assert(diameterRatio != null), + assert(diameterRatio > 0, diameterRatioZeroMessage), + assert(perspective != null), + assert(perspective > 0), + assert(perspective <= 0.01, perspectiveTooHighMessage), + assert(itemExtent != null), + assert(itemExtent > 0), + assert(clipToSize != null), + assert(renderChildrenOutsideViewport != null), + assert( + !renderChildrenOutsideViewport || !clipToSize, + clipToSizeAndRenderChildrenOutsideViewportConflict, + ), + _offset = offset, + _diameterRatio = diameterRatio, + _perspective = perspective, + _itemExtent = itemExtent, + _clipToSize = clipToSize, + _renderChildrenOutsideViewport = renderChildrenOutsideViewport { + addAll(children); + } + + /// An arbitrary but aesthetically reasonable default value for [diameterRatio]. + static const double defaultDiameterRatio = 2.0; + + /// Ar arbitrary but aesthetically reasonable default value for [perspective]. + static const double defaultPerspective = 0.003; + + /// An error message to show when the provided [diameterRatio] is zero. + static const String diameterRatioZeroMessage = "You can't set a diameterRatio " + 'of 0. It would imply a cylinder of 0 in diameter in which case nothing ' + 'will be drawn.'; + + /// An error message to show when the [perspective] value is too high. + static const String perspectiveTooHighMessage = 'A perspective too high will ' + 'be clipped in the z-axis and therefore not renderable. Value must be ' + 'between 0 and 0.01.'; + + /// An error message to show when [clipToSize] and [renderChildrenOutsideViewport] + /// are set to conflicting values. + static const String clipToSizeAndRenderChildrenOutsideViewportConflict = + 'Cannot renderChildrenOutsideViewport and clipToSize since children ' + 'rendered outside will be clipped anyway.'; + + /// The associated ViewportOffset object for the viewport describing the part + /// of the content inside that's visible. + /// + /// The [ViewportOffset.pixels] value determines the scroll offset that the + /// viewport uses to select which part of its content to display. As the user + /// scrolls the viewport, this value changes, which changes the content that + /// is displayed. + /// + /// Must not be null. + ViewportOffset get offset => _offset; + ViewportOffset _offset; + set offset(ViewportOffset value) { + assert(value != null); + if (value == _offset) + return; + if (attached) + _offset.removeListener(_hasScrolled); + _offset = value; + if (attached) + _offset.addListener(_hasScrolled); + markNeedsLayout(); + } + + /// {@template flutter.rendering.wheelList.diameterRatio} + /// A ratio between the diameter of the cylinder and the viewport's size + /// in the main axis. + /// + /// A value of 1 means the cylinder has the same diameter as the viewport's + /// size. + /// + /// A value smaller than 1 means items at the edges of the cylinder are + /// entirely contained inside the viewport. + /// + /// A value larger than 1 means angles less than ±[pi] / 2 from the + /// center of the cylinder are visible. + /// + /// The same number of children will be visible in the viewport regardless of + /// the [diameterRatio]. The number of children visible is based on the + /// viewport's length along the main axis divided by the children's + /// [itemExtent]. Then the children are evenly distributed along the visible + /// angles up to ±[pi] / 2. + /// + /// Just as it's impossible to stretch a paper to cover the an entire + /// half of a cylinder's surface where the cylinder has the same diameter + /// as the paper's length, choosing a [diameterRatio] smaller than [pi] + /// will leave same gaps between the children. + /// + /// Defaults to an arbitrary but aesthetically reasonable number of 2.0. + /// + /// Must not be null and must be positive. + /// {@endtemplate} + double get diameterRatio => _diameterRatio; + double _diameterRatio; + set diameterRatio(double value) { + assert(value != null); + assert( + value > 0, + diameterRatioZeroMessage, + ); + if (value == _diameterRatio) + return; + _diameterRatio = value; + _hasScrolled(); + } + + /// {@template flutter.rendering.wheelList.perspective} + /// Perspective of the cylindrical projection. + /// + /// A number between 0 and 0.01 where 0 means looking at the cylinder from + /// infinitely far with an infinitely small field of view and 1 means looking + /// at the cylinder from infinitely close with an infinitely large field of + /// view (which cannot be rendered). + /// + /// Defaults to an arbitrary but aesthetically reasonable number of 0.003. + /// A larger number brings the vanishing point closer and a smaller number + /// pushes the vanishing point further. + /// + /// Must not be null and must be positive. + /// {@endtemplate} + double get perspective => _perspective; + double _perspective; + set perspective(double value) { + assert(value != null); + assert(value > 0); + assert( + value <= 0.01, + perspectiveTooHighMessage, + ); + if (value == _perspective) + return; + _perspective = value; + _hasScrolled(); + } + + /// {@template flutter.rendering.wheelList.itemExtent} + /// The size of the children along the main axis. Children [RenderBox]es will + /// be given the [BoxConstraints] of this exact size. + /// + /// Must not be null and must be positive. + /// {@endtemplate} + double get itemExtent => _itemExtent; + double _itemExtent; + set itemExtent(double value) { + assert(value != null); + assert(value > 0); + if (value == _itemExtent) + return; + _itemExtent = value; + markNeedsLayout(); + } + + /// {@template flutter.rendering.wheelList.clipToSize} + /// Whether to clip painted children to the inside of this viewport. + /// + /// Defaults to [true]. Must not be null. + /// + /// If this is false and [renderChildrenOutsideViewport] is false, the + /// first and last children may be painted partly outside of this scroll view. + /// {@endtemplate} + bool get clipToSize => _clipToSize; + bool _clipToSize; + set clipToSize(bool value) { + assert(value != null); + assert( + !renderChildrenOutsideViewport || !clipToSize, + clipToSizeAndRenderChildrenOutsideViewportConflict, + ); + if (value == _clipToSize) + return; + _clipToSize = value; + _hasScrolled(); + } + + /// {@template flutter.rendering.wheelList.renderChildrenOutsideViewport} + /// Whether to paint children inside the viewport only. + /// + /// If false, every child will be painted. However the [Scrollable] is still + /// the size of the viewport and detects gestures inside only. + /// + /// Defaults to [false]. Must not be null. Cannot be true if [clipToSize] + /// is also true since children outside the viewport will be clipped, and + /// therefore cannot render children outside the viewport. + /// {@endtemplate} + bool get renderChildrenOutsideViewport => _renderChildrenOutsideViewport; + bool _renderChildrenOutsideViewport; + set renderChildrenOutsideViewport(bool value) { + assert(value != null); + assert( + !renderChildrenOutsideViewport || !clipToSize, + clipToSizeAndRenderChildrenOutsideViewportConflict, + ); + if (value == _renderChildrenOutsideViewport) + return; + _renderChildrenOutsideViewport = value; + _hasScrolled(); + } + + void _hasScrolled() { + markNeedsPaint(); + markNeedsSemanticsUpdate(); + } + + @override + void setupParentData(RenderObject child) { + if (child.parentData is! ListWheelParentData) + child.parentData = new ListWheelParentData(); + } + + @override + void attach(PipelineOwner owner) { + super.attach(owner); + _offset.addListener(_hasScrolled); + } + + @override + void detach() { + _offset.removeListener(_hasScrolled); + super.detach(); + } + + @override + bool get isRepaintBoundary => true; + + /// Main axis length in the untransformed plane. + double get _viewportExtent { + assert(hasSize); + return size.height; + } + + /// Main axis scroll extent in the **scrollable layout coordinates** that puts + /// the first item in the center. + double get _minScrollExtent { + assert(hasSize); + return 0.0; + } + + /// Main axis scroll extent in the **scrollable layout coordinates** that puts + /// the last item in the center. + double get _maxScrollExtent { + assert(hasSize); + if (!(childCount > 0)) + return 0.0; + + return math.max(0.0, (childCount - 1) * _itemExtent); + } + + /// Scroll extent distance in the untransformed plane between the center + /// position in the viewport and the top position in the viewport. + /// + /// It's also the distance in the untransformed plane that children's painting + /// is offset by with respect to those children's [BoxParentData.offset]. + double get _topScrollMarginExtent { + assert(hasSize); + // Consider adding alignment options other than center. + return _minScrollExtent - size.height / 2.0 + _itemExtent / 2.0; + } + + /// Transforms a **scrollable layout coordinates**' y position to the + /// **untransformed plane's viewport painting coordinates**' y position given + /// the current scroll offset. + double _getUntransformedPaintingCoordinateY(double layoutCoordinateY) { + return layoutCoordinateY - _topScrollMarginExtent - offset.pixels; + } + + /// Children with offsets larger than this value in the **scrollable layout + /// coordinates** can be painted. + double get _firstVisibleLayoutOffset { + assert(hasSize); + if (_renderChildrenOutsideViewport) + return double.negativeInfinity; + return _minScrollExtent - size.height / 2.0 - _itemExtent / 2.0 + offset.pixels; + } + + /// Children with offsets smaller than this value in the **scrollable layout + /// coordinates** can be painted. + double get _lastVisibleLayoutOffset { + assert(hasSize); + if (_renderChildrenOutsideViewport) + return double.infinity; + return _minScrollExtent + size.height / 2.0 + _itemExtent / 2.0 + offset.pixels; + } + + /// Given the _diameterRatio, return the largest absolute angle of the item + /// at the edge of the portion of the visible cylinder. + /// + /// For a _diameterRatio of 1 or less than 1 (i.e. the viewport is bigger + /// than the cylinder diameter), this value reaches and clips at pi / 2. + /// + /// When the center of children passes this angle, they are no longer painted + /// if [renderChildrenOutsideViewport] is false. + double get _maxVisibleRadian { + if (_diameterRatio < 1.0) + return math.pi / 2.0; + return math.asin(1.0 / _diameterRatio); + } + + double _getIntrinsicCrossAxis(_ChildSizingFunction childSize) { + double extent = 0.0; + RenderBox child = firstChild; + while (child != null) { + extent = math.max(extent, childSize(child)); + child = childAfter(child); + } + return extent; + } + + @override + double computeMinIntrinsicWidth(double height) { + return _getIntrinsicCrossAxis( + (RenderBox child) => child.getMinIntrinsicWidth(height) + ); + } + + @override + double computeMaxIntrinsicWidth(double height) { + return _getIntrinsicCrossAxis( + (RenderBox child) => child.getMaxIntrinsicWidth(height) + ); + } + + @override + double computeMinIntrinsicHeight(double width) { + if (childCount > 0) + return childCount * _itemExtent; + return 0.0; + } + + @override + double computeMaxIntrinsicHeight(double width) { + if (childCount > 0) + return childCount * _itemExtent; + return 0.0; + } + + @override + bool get sizedByParent => true; + + @override + void performResize() { + size = constraints.biggest; + } + + @override + void performLayout() { + double currentOffset = 0.0; + RenderBox child = firstChild; + final BoxConstraints innerConstraints = + constraints.copyWith( + minHeight: _itemExtent, + maxHeight: _itemExtent, + minWidth: 0.0, + ); + while (child != null) { + child.layout(innerConstraints, parentUsesSize: true); + final ListWheelParentData childParentData = child.parentData; + // Centers the child in the cross axis. Consider making it configurable. + final double crossPosition = size.width / 2.0 - child.size.width / 2.0; + childParentData.offset = new Offset(crossPosition, currentOffset); + currentOffset += _itemExtent; + child = childAfter(child); + } + + offset.applyViewportDimension(_viewportExtent); + offset.applyContentDimensions(_minScrollExtent, _maxScrollExtent); + } + + bool _shouldClipAtCurrentOffset() { + final double highestUntransformedPaintY = + _getUntransformedPaintingCoordinateY(0.0); + return highestUntransformedPaintY < 0.0 + || size.height < highestUntransformedPaintY + _maxScrollExtent + _itemExtent; + } + + @override + void paint(PaintingContext context, Offset offset) { + if (childCount > 0) { + if (_clipToSize && _shouldClipAtCurrentOffset()) { + context.pushClipRect( + needsCompositing, + offset, + Offset.zero & size, + _paintVisibleChildren, + ); + } else { + _paintVisibleChildren(context, offset); + } + } + } + + /// Visits all the children until one is partially visible in the viewport. + RenderBox _getFirstVisibleChild() { + assert(childCount > 0); + final double firstVisibleLayoutOffset = _firstVisibleLayoutOffset; + + RenderBox child = firstChild; + ListWheelParentData childParentData = child.parentData; + + while (childParentData != null + && childParentData.offset.dy <= firstVisibleLayoutOffset) { + child = childAfter(child); + childParentData = child?.parentData; + } + + return child; + } + + /// Paints all children visible in the current viewport. + void _paintVisibleChildren(PaintingContext context, Offset offset) { + assert(childCount > 0); + final double lastVisibleLayoutOffset = _lastVisibleLayoutOffset; + + RenderBox childToPaint = _getFirstVisibleChild(); + ListWheelParentData childParentData = childToPaint?.parentData; + + while (childParentData != null + && childParentData.offset.dy < lastVisibleLayoutOffset) { + _paintTransformedChild(childToPaint, context, offset, childParentData.offset); + childToPaint = childAfter(childToPaint); + childParentData = childToPaint?.parentData; + } + } + + /// Takes in a child with a **scrollable layout offset** and paints it in the + /// **transformed cylindrical space viewport painting coordinates**. + void _paintTransformedChild( + RenderBox child, + PaintingContext context, + Offset offset, + Offset layoutOffset, + ) { + final Offset untransformedPaintingCoordinates = offset + + new Offset( + layoutOffset.dx, + _getUntransformedPaintingCoordinateY(layoutOffset.dy) + ); + + // Get child's center as a fraction of the viewport's height. + final double fractionalY = + (untransformedPaintingCoordinates.dy + _itemExtent / 2.0) / size.height; + final double angle = -(fractionalY - 0.5) * 2.0 * _maxVisibleRadian; + // Don't paint the backside of the cylinder when + // renderChildrenOutsideViewport is true. Otherwise, only children within + // suitable angles (via _first/lastVisibleLayoutOffset) reach the paint + // phase. + if (angle > math.pi / 2.0 || angle < -math.pi / 2.0) + return; + + final Matrix4 transform = MatrixUtils.createCylindricalProjectionTransform( + radius: size.height * _diameterRatio / 2.0, + angle: angle, + perspective: _perspective, + ); + + context.pushTransform( + needsCompositing, + offset, + _centerOriginTransform(transform), + // Pre-transform painting function. + (PaintingContext context, Offset offset) { + context.paintChild( + child, + offset + new Offset( + untransformedPaintingCoordinates.dx, + // Paint everything in the center (e.g. angle = 0), then transform. + -_topScrollMarginExtent, + ), + ); + }, + ); + } + + /// Apply incoming transformation with the transformation's origin at the + /// viewport's center. + Matrix4 _centerOriginTransform(Matrix4 originalMatrix) { + final Matrix4 result = new Matrix4.identity(); + final Offset centerOriginTranslation = Alignment.center.alongSize(size); + result.translate(centerOriginTranslation.dx, centerOriginTranslation.dy); + result.multiply(originalMatrix); + result.translate(-centerOriginTranslation.dx, -centerOriginTranslation.dy); + return result; + } + + @override + void applyPaintTransform(RenderBox child, Matrix4 transform) { + transform.translate(0.0, _getUntransformedPaintingCoordinateY(0.0)); + } + + @override + Rect describeApproximatePaintClip(RenderObject child) { + if (child != null && _shouldClipAtCurrentOffset()) { + return Offset.zero & size; + } + return null; + } + + @override + bool hitTestChildren(HitTestResult result, { Offset position }) { + return false; + } + + @override + double getOffsetToReveal(RenderObject target, double alignment) { + final ListWheelParentData parentData = target.parentData; + final double centerPosition = parentData.offset.dy; + + if (alignment < 0.5) { + return centerPosition + _topScrollMarginExtent * alignment * 2.0; + } else if (alignment > 0.5) { + return centerPosition - _topScrollMarginExtent * (alignment - 0.5) * 2.0; + } else { + return centerPosition; + } + } + + @override + void showOnScreen([RenderObject child]) { + if (child != null) { + // Shows the child in the selected/center position. + offset.jumpTo(getOffsetToReveal(child, 0.5)); + } + + // Make sure the viewport itself is on screen. + super.showOnScreen(); + } +} diff --git a/packages/flutter/lib/src/widgets/list_wheel_scroll_view.dart b/packages/flutter/lib/src/widgets/list_wheel_scroll_view.dart new file mode 100644 index 00000000000..39f253405ab --- /dev/null +++ b/packages/flutter/lib/src/widgets/list_wheel_scroll_view.dart @@ -0,0 +1,189 @@ +// Copyright 2017 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; + +import 'framework.dart'; +import 'scroll_controller.dart'; +import 'scroll_physics.dart'; +import 'scrollable.dart'; + +/// 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 StatelessWidget { + /// 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.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); + + /// An object that can be used to control the position to which this scroll + /// view is scrolled. + /// + /// A [ScrollController] serves several purposes. It can be used to control + /// the initial scroll position (see [ScrollController.initialScrollOffset]). + /// It can be used to control whether the scroll view should automatically + /// save and restore its scroll position in the [PageStorage] (see + /// [ScrollController.keepScrollOffset]). It can be used to read the current + /// scroll position (see [ScrollController.offset]), or change it (see + /// [ScrollController.animateTo]). + 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; + + /// {@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 children; + + @override + Widget build(BuildContext context) { + return new Scrollable( + controller: controller, + physics: physics, + viewportBuilder: (BuildContext context, ViewportOffset offset) { + return new ListWheelViewport( + diameterRatio: diameterRatio, + perspective: perspective, + itemExtent: itemExtent, + clipToSize: clipToSize, + renderChildrenOutsideViewport: renderChildrenOutsideViewport, + offset: offset, + children: children, + ); + }, + ); + } +} + +/// 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 { + ListWheelViewport({ + Key key, + this.diameterRatio: RenderListWheelViewport.defaultDiameterRatio, + this.perspective: RenderListWheelViewport.defaultPerspective, + @required this.itemExtent, + this.clipToSize: true, + this.renderChildrenOutsideViewport: false, + @required this.offset, + List 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; + } +} diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index 7f6d9652f15..b7a793f8755 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -45,6 +45,7 @@ export 'src/widgets/image.dart'; export 'src/widgets/image_icon.dart'; export 'src/widgets/implicit_animations.dart'; export 'src/widgets/layout_builder.dart'; +export 'src/widgets/list_wheel_scroll_view.dart'; export 'src/widgets/localizations.dart'; export 'src/widgets/media_query.dart'; export 'src/widgets/modal_barrier.dart'; diff --git a/packages/flutter/test/rendering/mock_canvas.dart b/packages/flutter/test/rendering/mock_canvas.dart index 8d94fd37166..394888b26f4 100644 --- a/packages/flutter/test/rendering/mock_canvas.dart +++ b/packages/flutter/test/rendering/mock_canvas.dart @@ -74,6 +74,21 @@ typedef void _CanvasPainterFunction(Canvas canvas); /// Patterns are subset matches, meaning that any calls not described by the /// pattern are ignored. This allows, for instance, transforms to be skipped. abstract class PaintPattern { + /// Indicates that a transform is expected next. + /// + /// Calls are skipped until a call to [Canvas.transform] is found. The call's + /// arguments are compared to those provided here. If any fail to match, or if + /// no call to [Canvas.transform] is found, then the matcher fails. + /// + /// Dynamic so matchers can be more easily passed in. + /// + /// The `matrix4` argument is dynamic so it can be either a [Matcher], or a + /// [Float64List] of [double]s. If it is a [Float64List] of [double]s then + /// each value in the matrix must match in the expected matrix. A deep + /// matching [Matcher] such as [equals] can be used to test each value in the + /// matrix with utilities such as [moreOrLessEquals]. + void transform({ dynamic matrix4 }); + /// Indicates that a translation transform is expected next. /// /// Calls are skipped until a call to [Canvas.translate] is found. The call's @@ -574,6 +589,11 @@ class _TestRecordingCanvasPaintsAssertionMatcher extends Matcher { class _TestRecordingCanvasPatternMatcher extends _TestRecordingCanvasMatcher implements PaintPattern { final List<_PaintPredicate> _predicates = <_PaintPredicate>[]; + @override + void transform({ dynamic matrix4 }) { + _predicates.add(new _FunctionPaintPredicate(#transform, [matrix4])); + } + @override void translate({ double x, double y }) { _predicates.add(new _FunctionPaintPredicate(#translate, [x, y])); diff --git a/packages/flutter/test/rendering/recording_canvas.dart b/packages/flutter/test/rendering/recording_canvas.dart index 78024ebbe65..7aa62aa1fad 100644 --- a/packages/flutter/test/rendering/recording_canvas.dart +++ b/packages/flutter/test/rendering/recording_canvas.dart @@ -110,6 +110,14 @@ class TestRecordingPaintingContext implements PaintingContext { canvas.restore(); } + @override + void pushTransform(bool needsCompositing, Offset offset, Matrix4 transform, PaintingContextCallback painter) { + canvas.save(); + canvas.transform(transform.storage); + painter(this, offset); + canvas.restore(); + } + @override void noSuchMethod(Invocation invocation) { } } diff --git a/packages/flutter/test/widgets/list_wheel_scroll_view_test.dart b/packages/flutter/test/widgets/list_wheel_scroll_view_test.dart new file mode 100644 index 00000000000..afa08c03a22 --- /dev/null +++ b/packages/flutter/test/widgets/list_wheel_scroll_view_test.dart @@ -0,0 +1,380 @@ +// 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_test/flutter_test.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import '../rendering/mock_canvas.dart'; +import '../rendering/rendering_tester.dart'; + +void main() { + testWidgets('ListWheelScrollView needs positive diameter ratio', (WidgetTester tester) async { + try { + new ListWheelScrollView( + diameterRatio: -2.0, + itemExtent: 20.0, + children: [], + ); + fail('Expected failure with negative diameterRatio'); + } on AssertionError catch (exception) { + expect(exception.message, contains("You can't set a diameterRatio of 0")); + } + }); + + testWidgets('ListWheelScrollView needs positive item extent', (WidgetTester tester) async { + expect( + () { + new ListWheelScrollView( + itemExtent: null, + children: [new Container()], + ); + }, + throwsAssertionError, + ); + }); + + testWidgets("ListWheelScrollView takes parent's size with small children", (WidgetTester tester) async { + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new ListWheelScrollView( + // Inner children smaller than the outer window. + itemExtent: 50.0, + children: [ + new Container( + height: 50.0, + color: const Color(0xFFFFFFFF), + ), + ], + ), + ), + ); + expect(tester.getTopLeft(find.byType(ListWheelScrollView)), const Offset(0.0, 0.0)); + // Standard test screen size. + expect(tester.getBottomRight(find.byType(ListWheelScrollView)), const Offset(800.0, 600.0)); + }); + + testWidgets("ListWheelScrollView takes parent's size with large children", (WidgetTester tester) async { + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new ListWheelScrollView( + // Inner children 5000.0px. + itemExtent: 50.0, + children: new List.generate(100, (int index) { + return new Container( + height: 50.0, + color: const Color(0xFFFFFFFF), + ); + }), + ), + ) + ); + expect(tester.getTopLeft(find.byType(ListWheelScrollView)), const Offset(0.0, 0.0)); + // Still fills standard test screen size. + expect(tester.getBottomRight(find.byType(ListWheelScrollView)), const Offset(800.0, 600.0)); + }); + + testWidgets("ListWheelScrollView children can't be bigger than itemExtent", (WidgetTester tester) async { + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new ListWheelScrollView( + itemExtent: 50.0, + children: [ + const SizedBox( + height: 200.0, + width: 200.0, + child: const Center( + child: const Text('blah'), + ), + ), + ], + ), + ), + ); + expect(tester.getSize(find.byType(SizedBox)), const Size(200.0, 50.0)); + expect(find.text('blah'), findsOneWidget); + }); + + testWidgets('ListWheelScrollView can have zero child', (WidgetTester tester) async { + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new ListWheelScrollView( + itemExtent: 50.0, + children: [], + ), + ), + ); + expect(tester.getSize(find.byType(ListWheelScrollView)), const Size(800.0, 600.0)); + }); + + testWidgets('ListWheelScrollView starts and ends from the middle', (WidgetTester tester) async { + final ScrollController controller = new ScrollController(); + final List paintedChildren = []; + + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new ListWheelScrollView( + controller: controller, + itemExtent: 100.0, + children: new List.generate(100, (int index) { + return new CustomPaint( + painter: new TestCallbackPainter(onPaint: () { + paintedChildren.add(index); + }), + ); + }), + ), + ), + ); + + // Screen is 600px tall and the first item starts at 250px. The first 4 + // children are visible. + expect(paintedChildren, [0, 1, 2, 3]); + + controller.jumpTo(1000.0); + paintedChildren.clear(); + + await tester.pump(); + // Item number 10 is now in the middle of the screen at 250px. 9, 8, 7 are + // visible before it and 11, 12, 13 are visible after it. + expect(paintedChildren, [7, 8, 9, 10, 11, 12, 13]); + + // Move to the last item. + controller.jumpTo(9900.0); + paintedChildren.clear(); + + await tester.pump(); + // Item 99 is in the middle at 250px. + expect(paintedChildren, [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 paintedChildren = []; + + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new ListWheelScrollView( + controller: controller, + itemExtent: 100.0, + children: new List.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, [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, [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 paintedChildren = []; + + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new ListWheelScrollView( + controller: controller, + itemExtent: 100.0, + children: new List.generate(10, (int index) { + return new CustomPaint( + painter: new TestCallbackPainter(onPaint: () { + paintedChildren.add(index); + }), + ); + }), + ), + ), + ); + + // The first item is at 0px and the 600px screen is full in the + // **untransformed plane's viewport painting coordinates** + expect(paintedChildren, [0, 1, 2, 3, 4, 5]); + + paintedChildren.clear(); + // Go down another 99px. + controller.jumpTo(349.0); + await tester.pump(); + + // One more item now visible with the last pixel of 0 showing. + expect(paintedChildren, [0, 1, 2, 3, 4, 5, 6]); + + paintedChildren.clear(); + // Go down one more pixel. + controller.jumpTo(350.0); + await tester.pump(); + + // Item 0 no longer visible. + expect(paintedChildren, [1, 2, 3, 4, 5, 6]); + }); + + testWidgets('Default middle transform', (WidgetTester tester) async { + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new ListWheelScrollView( + itemExtent: 100.0, + children: [ + 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([ + 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: [ + 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([ + 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: [ + new Container( + width: 200.0, + child: const Center( + child: const Text('blah'), + ), + ), + ], + ), + ), + ); + + expect(viewport, paints..transform( + matrix4: equals([ + 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: [ + new Container( + width: 200.0, + child: const Center( + child: const Text('blah'), + ), + ), + ], + ), + ), + ); + + expect(viewport, paints..transform( + matrix4: equals([ + 1.0, 0.0, 0.0, 0.0, + moreOrLessEquals(-0.01368080573302675), moreOrLessEquals(0.9294320164861384), moreOrLessEquals(0.3420201433256687), moreOrLessEquals(-0.000034202014332566874), + moreOrLessEquals(-0.03758770483143634), moreOrLessEquals(-0.370210921949246), moreOrLessEquals(0.9396926207859084), moreOrLessEquals(-0.00009396926207859085), + moreOrLessEquals(5.551618821046304), moreOrLessEquals(-182.95615811538906), moreOrLessEquals(-138.79047052615562), moreOrLessEquals(1.0138790470526158), + ]), + )); + + // Scroll a bit. + controller.jumpTo(300.0); + await tester.pumpWidget( + new Directionality( + textDirection: TextDirection.ltr, + child: new ListWheelScrollView( + controller: controller, + itemExtent: 100.0, + children: [ + new Container( + width: 200.0, + child: const Center( + child: const Text('blah'), + ), + ), + ], + ), + ) + ); + + expect(viewport, paints..transform( + matrix4: equals([ + 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), + ]), + )); + }); +}