Unverified Commit e64d44ca authored by xster's avatar xster Committed by GitHub

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
parent de023bc6
......@@ -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';
......
// 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<RenderBox> { }
/// 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<RenderBox, ListWheelParentData>,
RenderBoxContainerDefaultsMixin<RenderBox, ListWheelParentData>
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<RenderBox> 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();
}
}
// 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<Widget> 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<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;
}
}
......@@ -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';
......
......@@ -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, <dynamic>[matrix4]));
}
@override
void translate({ double x, double y }) {
_predicates.add(new _FunctionPaintPredicate(#translate, <dynamic>[x, y]));
......
......@@ -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) { }
}
......
// 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: <Widget>[],
);
fail('Expected failure with negative diameterRatio');
} on AssertionError catch (exception) {
expect(exception.message, contains("You can't set a diameterRatio of 0"));
}
});
testWidgets('ListWheelScrollView needs positive item extent', (WidgetTester tester) async {
expect(
() {
new ListWheelScrollView(
itemExtent: null,
children: <Widget>[new Container()],
);
},
throwsAssertionError,
);
});
testWidgets("ListWheelScrollView takes parent's size with small children", (WidgetTester tester) async {
await tester.pumpWidget(
new Directionality(
textDirection: TextDirection.ltr,
child: new ListWheelScrollView(
// Inner children smaller than the outer window.
itemExtent: 50.0,
children: <Widget>[
new Container(
height: 50.0,
color: const Color(0xFFFFFFFF),
),
],
),
),
);
expect(tester.getTopLeft(find.byType(ListWheelScrollView)), const Offset(0.0, 0.0));
// Standard test screen size.
expect(tester.getBottomRight(find.byType(ListWheelScrollView)), const Offset(800.0, 600.0));
});
testWidgets("ListWheelScrollView takes parent's size with large children", (WidgetTester tester) async {
await tester.pumpWidget(
new Directionality(
textDirection: TextDirection.ltr,
child: new ListWheelScrollView(
// Inner children 5000.0px.
itemExtent: 50.0,
children: new List<Widget>.generate(100, (int index) {
return new Container(
height: 50.0,
color: const Color(0xFFFFFFFF),
);
}),
),
)
);
expect(tester.getTopLeft(find.byType(ListWheelScrollView)), const Offset(0.0, 0.0));
// Still fills standard test screen size.
expect(tester.getBottomRight(find.byType(ListWheelScrollView)), const Offset(800.0, 600.0));
});
testWidgets("ListWheelScrollView children can't be bigger than itemExtent", (WidgetTester tester) async {
await tester.pumpWidget(
new Directionality(
textDirection: TextDirection.ltr,
child: new ListWheelScrollView(
itemExtent: 50.0,
children: <Widget>[
const SizedBox(
height: 200.0,
width: 200.0,
child: const Center(
child: const Text('blah'),
),
),
],
),
),
);
expect(tester.getSize(find.byType(SizedBox)), const Size(200.0, 50.0));
expect(find.text('blah'), findsOneWidget);
});
testWidgets('ListWheelScrollView can have zero child', (WidgetTester tester) async {
await tester.pumpWidget(
new Directionality(
textDirection: TextDirection.ltr,
child: new ListWheelScrollView(
itemExtent: 50.0,
children: <Widget>[],
),
),
);
expect(tester.getSize(find.byType(ListWheelScrollView)), const Size(800.0, 600.0));
});
testWidgets('ListWheelScrollView starts and ends from the middle', (WidgetTester tester) async {
final ScrollController controller = new ScrollController();
final List<int> paintedChildren = <int>[];
await tester.pumpWidget(
new Directionality(
textDirection: TextDirection.ltr,
child: new ListWheelScrollView(
controller: controller,
itemExtent: 100.0,
children: new List<Widget>.generate(100, (int index) {
return new CustomPaint(
painter: new TestCallbackPainter(onPaint: () {
paintedChildren.add(index);
}),
);
}),
),
),
);
// Screen is 600px tall and the first item starts at 250px. The first 4
// children are visible.
expect(paintedChildren, <int>[0, 1, 2, 3]);
controller.jumpTo(1000.0);
paintedChildren.clear();
await tester.pump();
// Item number 10 is now in the middle of the screen at 250px. 9, 8, 7 are
// visible before it and 11, 12, 13 are visible after it.
expect(paintedChildren, <int>[7, 8, 9, 10, 11, 12, 13]);
// Move to the last item.
controller.jumpTo(9900.0);
paintedChildren.clear();
await tester.pump();
// Item 99 is in the middle at 250px.
expect(paintedChildren, <int>[96, 97, 98, 99]);
});
testWidgets('A child gets painted as soon as its first pixel is in the viewport', (WidgetTester tester) async {
final ScrollController controller = new ScrollController(initialScrollOffset: 50.0);
final List<int> paintedChildren = <int>[];
await tester.pumpWidget(
new Directionality(
textDirection: TextDirection.ltr,
child: new ListWheelScrollView(
controller: controller,
itemExtent: 100.0,
children: new List<Widget>.generate(10, (int index) {
return new CustomPaint(
painter: new TestCallbackPainter(onPaint: () {
paintedChildren.add(index);
}),
);
}),
),
),
);
// Screen is 600px tall and the first item starts at 200px. The first 4
// children are visible.
expect(paintedChildren, <int>[0, 1, 2, 3]);
paintedChildren.clear();
// Move down by 1px.
await tester.drag(find.byType(ListWheelScrollView), const Offset(0.0, -1.0));
await tester.pump();
// Now the first pixel of item 5 enters the viewport.
expect(paintedChildren, <int>[0, 1, 2, 3, 4]);
});
testWidgets('A child is no longer painted after its last pixel leaves the viewport', (WidgetTester tester) async {
final ScrollController controller = new ScrollController(initialScrollOffset: 250.0);
final List<int> paintedChildren = <int>[];
await tester.pumpWidget(
new Directionality(
textDirection: TextDirection.ltr,
child: new ListWheelScrollView(
controller: controller,
itemExtent: 100.0,
children: new List<Widget>.generate(10, (int index) {
return new CustomPaint(
painter: new TestCallbackPainter(onPaint: () {
paintedChildren.add(index);
}),
);
}),
),
),
);
// The first item is at 0px and the 600px screen is full in the
// **untransformed plane's viewport painting coordinates**
expect(paintedChildren, <int>[0, 1, 2, 3, 4, 5]);
paintedChildren.clear();
// Go down another 99px.
controller.jumpTo(349.0);
await tester.pump();
// One more item now visible with the last pixel of 0 showing.
expect(paintedChildren, <int>[0, 1, 2, 3, 4, 5, 6]);
paintedChildren.clear();
// Go down one more pixel.
controller.jumpTo(350.0);
await tester.pump();
// Item 0 no longer visible.
expect(paintedChildren, <int>[1, 2, 3, 4, 5, 6]);
});
testWidgets('Default middle transform', (WidgetTester tester) async {
await tester.pumpWidget(
new Directionality(
textDirection: TextDirection.ltr,
child: new ListWheelScrollView(
itemExtent: 100.0,
children: <Widget>[
new Container(
width: 200.0,
child: const Center(
child: const Text('blah'),
),
),
],
),
),
);
final RenderListWheelViewport viewport = tester.firstRenderObject(find.byType(Container)).parent;
expect(viewport, paints..transform(
matrix4: equals(<dynamic>[
1.0, 0.0, 0.0, 0.0,
0.0, 1.0, 0.0, 0.0,
-1.2 /* origin centering multiplied */, -0.9/* origin centering multiplied*/, 1.0, -0.003 /* inverse of perspective */,
moreOrLessEquals(0.0), moreOrLessEquals(0.0), 0.0, moreOrLessEquals(1.0),
]),
));
});
testWidgets('Scrolling, diameterRatio, perspective all changes matrix', (WidgetTester tester) async {
final ScrollController controller = new ScrollController(initialScrollOffset: 200.0);
await tester.pumpWidget(
new Directionality(
textDirection: TextDirection.ltr,
child: new ListWheelScrollView(
controller: controller,
itemExtent: 100.0,
children: <Widget>[
new Container(
width: 200.0,
child: const Center(
child: const Text('blah'),
),
),
],
),
),
);
final RenderListWheelViewport viewport = tester.firstRenderObject(find.byType(Container)).parent;
expect(viewport, paints..transform(
matrix4: equals(<dynamic>[
1.0, 0.0, 0.0, 0.0,
moreOrLessEquals(-0.41042417199080244), moreOrLessEquals(0.6318744917928065), moreOrLessEquals(0.3420201433256687), moreOrLessEquals(-0.0010260604299770061),
moreOrLessEquals(-1.12763114494309), moreOrLessEquals(-1.1877435020329863), moreOrLessEquals(0.9396926207859084), moreOrLessEquals(-0.0028190778623577253),
moreOrLessEquals(166.54856463138663), moreOrLessEquals(-62.20844875763376), moreOrLessEquals(-138.79047052615562), moreOrLessEquals(1.4163714115784667),
]),
));
// Increase diameter.
await tester.pumpWidget(
new Directionality(
textDirection: TextDirection.ltr,
child: new ListWheelScrollView(
controller: controller,
diameterRatio: 3.0,
itemExtent: 100.0,
children: <Widget>[
new Container(
width: 200.0,
child: const Center(
child: const Text('blah'),
),
),
],
),
),
);
expect(viewport, paints..transform(
matrix4: equals(<dynamic>[
1.0, 0.0, 0.0, 0.0,
moreOrLessEquals(-0.26954971336161726), moreOrLessEquals(0.7722830529455648), moreOrLessEquals(0.22462476113468105), moreOrLessEquals(-0.0006738742834040432),
moreOrLessEquals(-1.1693344055601331), moreOrLessEquals(-1.101625565304781), moreOrLessEquals(0.9744453379667777), moreOrLessEquals(-0.002923336013900333),
moreOrLessEquals(108.46394900436536), moreOrLessEquals(-113.14792465797223), moreOrLessEquals(-90.38662417030434), moreOrLessEquals(1.2711598725109134),
]),
));
// Decrease perspective.
await tester.pumpWidget(
new Directionality(
textDirection: TextDirection.ltr,
child: new ListWheelScrollView(
controller: controller,
perspective: 0.0001,
itemExtent: 100.0,
children: <Widget>[
new Container(
width: 200.0,
child: const Center(
child: const Text('blah'),
),
),
],
),
),
);
expect(viewport, paints..transform(
matrix4: equals(<dynamic>[
1.0, 0.0, 0.0, 0.0,
moreOrLessEquals(-0.01368080573302675), moreOrLessEquals(0.9294320164861384), moreOrLessEquals(0.3420201433256687), moreOrLessEquals(-0.000034202014332566874),
moreOrLessEquals(-0.03758770483143634), moreOrLessEquals(-0.370210921949246), moreOrLessEquals(0.9396926207859084), moreOrLessEquals(-0.00009396926207859085),
moreOrLessEquals(5.551618821046304), moreOrLessEquals(-182.95615811538906), moreOrLessEquals(-138.79047052615562), moreOrLessEquals(1.0138790470526158),
]),
));
// Scroll a bit.
controller.jumpTo(300.0);
await tester.pumpWidget(
new Directionality(
textDirection: TextDirection.ltr,
child: new ListWheelScrollView(
controller: controller,
itemExtent: 100.0,
children: <Widget>[
new Container(
width: 200.0,
child: const Center(
child: const Text('blah'),
),
),
],
),
)
);
expect(viewport, paints..transform(
matrix4: equals(<dynamic>[
1.0, 0.0, 0.0, 0.0,
-0.6, moreOrLessEquals(0.41602540378443875), moreOrLessEquals(0.5), moreOrLessEquals(-0.0015),
moreOrLessEquals(-1.0392304845413265), moreOrLessEquals(-1.2794228634059948), moreOrLessEquals(0.8660254037844387), moreOrLessEquals(-0.0025980762113533163),
moreOrLessEquals(276.46170927520404), moreOrLessEquals(-52.46133917892857), moreOrLessEquals(-230.38475772933677), moreOrLessEquals(1.69115427318801),
]),
));
});
}
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