1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import 'dart:math' as math;
6
7import 'package:flutter/animation.dart';
8import 'package:flutter/foundation.dart';
9import 'package:vector_math/vector_math_64.dart' show Matrix4;
10
11import 'box.dart';
12import 'layer.dart';
13import 'object.dart';
14import 'proxy_box.dart';
15import 'viewport.dart';
16import 'viewport_offset.dart';
17
18typedef _ChildSizingFunction = double Function(RenderBox child);
19
20/// A delegate used by [RenderListWheelViewport] to manage its children.
21///
22/// [RenderListWheelViewport] during layout will ask the delegate to create
23/// children that are visible in the viewport and remove those that are not.
24abstract class ListWheelChildManager {
25 /// The maximum number of children that can be provided to
26 /// [RenderListWheelViewport].
27 ///
28 /// If non-null, the children will have index in the range
29 /// `[0, childCount - 1]`.
30 ///
31 /// If null, then there's no explicit limits to the range of the children
32 /// except that it has to be contiguous. If [childExistsAt] for a certain
33 /// index returns false, that index is already past the limit.
34 int? get childCount;
35
36 /// Checks whether the delegate is able to provide a child widget at the given
37 /// index.
38 ///
39 /// This function is not about whether the child at the given index is
40 /// attached to the [RenderListWheelViewport] or not.
41 bool childExistsAt(int index);
42
43 /// Creates a new child at the given index and updates it to the child list
44 /// of [RenderListWheelViewport]. If no child corresponds to `index`, then do
45 /// nothing.
46 ///
47 /// It is possible to create children with negative indices.
48 void createChild(int index, { required RenderBox? after });
49
50 /// Removes the child element corresponding with the given RenderBox.
51 void removeChild(RenderBox child);
52}
53
54/// [ParentData] for use with [RenderListWheelViewport].
55class ListWheelParentData extends ContainerBoxParentData<RenderBox> {
56 /// Index of this child in its parent's child list.
57 ///
58 /// This must be maintained by the [ListWheelChildManager].
59 int? index;
60
61 /// Transform applied to this child during painting.
62 ///
63 /// Can be used to find the local bounds of this child in the viewport,
64 /// and then use it, for example, in hit testing.
65 ///
66 /// May be null if child was laid out, but not painted
67 /// by the parent, but normally this shouldn't happen,
68 /// because [RenderListWheelViewport] paints all of the
69 /// children it has laid out.
70 Matrix4? transform;
71}
72
73/// Render, onto a wheel, a bigger sequential set of objects inside this viewport.
74///
75/// Takes a scrollable set of fixed sized [RenderBox]es and renders them
76/// sequentially from top down on a vertical scrolling axis.
77///
78/// It starts with the first scrollable item in the center of the main axis
79/// and ends with the last scrollable item in the center of the main axis. This
80/// is in contrast to typical lists that start with the first scrollable item
81/// at the start of the main axis and ends with the last scrollable item at the
82/// end of the main axis.
83///
84/// Instead of rendering its children on a flat plane, it renders them
85/// as if each child is broken into its own plane and that plane is
86/// perpendicularly fixed onto a cylinder which rotates along the scrolling
87/// axis.
88///
89/// This class works in 3 coordinate systems:
90///
91/// 1. The **scrollable layout coordinates**. This coordinate system is used to
92/// communicate with [ViewportOffset] and describes its children's abstract
93/// offset from the beginning of the scrollable list at (0.0, 0.0).
94///
95/// The list is scrollable from the start of the first child item to the
96/// start of the last child item.
97///
98/// Children's layout coordinates don't change as the viewport scrolls.
99///
100/// 2. The **untransformed plane's viewport painting coordinates**. Children are
101/// not painted in this coordinate system. It's an abstract intermediary used
102/// before transforming into the next cylindrical coordinate system.
103///
104/// This system is the **scrollable layout coordinates** translated by the
105/// scroll offset such that (0.0, 0.0) is the top left corner of the
106/// viewport.
107///
108/// Because the viewport is centered at the scrollable list's scroll offset
109/// instead of starting at the scroll offset, there are paintable children
110/// ~1/2 viewport length before and after the scroll offset instead of ~1
111/// viewport length after the scroll offset.
112///
113/// Children's visibility inclusion in the viewport is determined in this
114/// system regardless of the cylinder's properties such as [diameterRatio]
115/// or [perspective]. In other words, a 100px long viewport will always
116/// paint 10-11 visible 10px children if there are enough children in the
117/// viewport.
118///
119/// 3. The **transformed cylindrical space viewport painting coordinates**.
120/// Children from system 2 get their positions transformed into a cylindrical
121/// projection matrix instead of its Cartesian offset with respect to the
122/// scroll offset.
123///
124/// Children in this coordinate system are painted.
125///
126/// The wheel's size and the maximum and minimum visible angles are both
127/// controlled by [diameterRatio]. Children visible in the **untransformed
128/// plane's viewport painting coordinates**'s viewport will be radially
129/// evenly laid out between the maximum and minimum angles determined by
130/// intersecting the viewport's main axis length with a cylinder whose
131/// diameter is [diameterRatio] times longer, as long as those angles are
132/// between -pi/2 and pi/2.
133///
134/// For example, if [diameterRatio] is 2.0 and this [RenderListWheelViewport]
135/// is 100.0px in the main axis, then the diameter is 200.0. And children
136/// will be evenly laid out between that cylinder's -arcsin(1/2) and
137/// arcsin(1/2) angles.
138///
139/// The cylinder's 0 degree side is always centered in the
140/// [RenderListWheelViewport]. The transformation from **untransformed
141/// plane's viewport painting coordinates** is also done such that the child
142/// in the center of that plane will be mostly untransformed with children
143/// above and below it being transformed more as the angle increases.
144class RenderListWheelViewport
145 extends RenderBox
146 with ContainerRenderObjectMixin<RenderBox, ListWheelParentData>
147 implements RenderAbstractViewport {
148 /// Creates a [RenderListWheelViewport] which renders children on a wheel.
149 ///
150 /// Optional arguments have reasonable defaults.
151 RenderListWheelViewport({
152 required this.childManager,
153 required ViewportOffset offset,
154 double diameterRatio = defaultDiameterRatio,
155 double perspective = defaultPerspective,
156 double offAxisFraction = 0,
157 bool useMagnifier = false,
158 double magnification = 1,
159 double overAndUnderCenterOpacity = 1,
160 required double itemExtent,
161 double squeeze = 1,
162 bool renderChildrenOutsideViewport = false,
163 Clip clipBehavior = Clip.none,
164 List<RenderBox>? children,
165 }) : assert(diameterRatio > 0, diameterRatioZeroMessage),
166 assert(perspective > 0),
167 assert(perspective <= 0.01, perspectiveTooHighMessage),
168 assert(magnification > 0),
169 assert(overAndUnderCenterOpacity >= 0 && overAndUnderCenterOpacity <= 1),
170 assert(squeeze > 0),
171 assert(itemExtent > 0),
172 assert(
173 !renderChildrenOutsideViewport || clipBehavior == Clip.none,
174 clipBehaviorAndRenderChildrenOutsideViewportConflict,
175 ),
176 _offset = offset,
177 _diameterRatio = diameterRatio,
178 _perspective = perspective,
179 _offAxisFraction = offAxisFraction,
180 _useMagnifier = useMagnifier,
181 _magnification = magnification,
182 _overAndUnderCenterOpacity = overAndUnderCenterOpacity,
183 _itemExtent = itemExtent,
184 _squeeze = squeeze,
185 _renderChildrenOutsideViewport = renderChildrenOutsideViewport,
186 _clipBehavior = clipBehavior {
187 addAll(children);
188 }
189
190 /// An arbitrary but aesthetically reasonable default value for [diameterRatio].
191 static const double defaultDiameterRatio = 2.0;
192
193 /// An arbitrary but aesthetically reasonable default value for [perspective].
194 static const double defaultPerspective = 0.003;
195
196 /// An error message to show when the provided [diameterRatio] is zero.
197 static const String diameterRatioZeroMessage = "You can't set a diameterRatio "
198 'of 0 or of a negative number. It would imply a cylinder of 0 in diameter '
199 'in which case nothing will be drawn.';
200
201 /// An error message to show when the [perspective] value is too high.
202 static const String perspectiveTooHighMessage = 'A perspective too high will '
203 'be clipped in the z-axis and therefore not renderable. Value must be '
204 'between 0 and 0.01.';
205
206 /// An error message to show when [clipBehavior] and [renderChildrenOutsideViewport]
207 /// are set to conflicting values.
208 static const String clipBehaviorAndRenderChildrenOutsideViewportConflict =
209 'Cannot renderChildrenOutsideViewport and clip since children '
210 'rendered outside will be clipped anyway.';
211
212 /// The delegate that manages the children of this object.
213 ///
214 /// This delegate must maintain the [ListWheelParentData.index] value.
215 final ListWheelChildManager childManager;
216
217 /// The associated ViewportOffset object for the viewport describing the part
218 /// of the content inside that's visible.
219 ///
220 /// The [ViewportOffset.pixels] value determines the scroll offset that the
221 /// viewport uses to select which part of its content to display. As the user
222 /// scrolls the viewport, this value changes, which changes the content that
223 /// is displayed.
224 ViewportOffset get offset => _offset;
225 ViewportOffset _offset;
226 set offset(ViewportOffset value) {
227 if (value == _offset) {
228 return;
229 }
230 if (attached) {
231 _offset.removeListener(_hasScrolled);
232 }
233 _offset = value;
234 if (attached) {
235 _offset.addListener(_hasScrolled);
236 }
237 markNeedsLayout();
238 }
239
240 /// {@template flutter.rendering.RenderListWheelViewport.diameterRatio}
241 /// A ratio between the diameter of the cylinder and the viewport's size
242 /// in the main axis.
243 ///
244 /// A value of 1 means the cylinder has the same diameter as the viewport's
245 /// size.
246 ///
247 /// A value smaller than 1 means items at the edges of the cylinder are
248 /// entirely contained inside the viewport.
249 ///
250 /// A value larger than 1 means angles less than ±[pi] / 2 from the
251 /// center of the cylinder are visible.
252 ///
253 /// The same number of children will be visible in the viewport regardless of
254 /// the [diameterRatio]. The number of children visible is based on the
255 /// viewport's length along the main axis divided by the children's
256 /// [itemExtent]. Then the children are evenly distributed along the visible
257 /// angles up to ±[pi] / 2.
258 ///
259 /// Just as it's impossible to stretch a paper to cover the an entire
260 /// half of a cylinder's surface where the cylinder has the same diameter
261 /// as the paper's length, choosing a [diameterRatio] smaller than [pi]
262 /// will leave same gaps between the children.
263 ///
264 /// Defaults to an arbitrary but aesthetically reasonable number of 2.0.
265 ///
266 /// Must be a positive number.
267 /// {@endtemplate}
268 double get diameterRatio => _diameterRatio;
269 double _diameterRatio;
270 set diameterRatio(double value) {
271 assert(
272 value > 0,
273 diameterRatioZeroMessage,
274 );
275 if (value == _diameterRatio) {
276 return;
277 }
278 _diameterRatio = value;
279 markNeedsPaint();
280 markNeedsSemanticsUpdate();
281 }
282
283 /// {@template flutter.rendering.RenderListWheelViewport.perspective}
284 /// Perspective of the cylindrical projection.
285 ///
286 /// A number between 0 and 0.01 where 0 means looking at the cylinder from
287 /// infinitely far with an infinitely small field of view and 1 means looking
288 /// at the cylinder from infinitely close with an infinitely large field of
289 /// view (which cannot be rendered).
290 ///
291 /// Defaults to an arbitrary but aesthetically reasonable number of 0.003.
292 /// A larger number brings the vanishing point closer and a smaller number
293 /// pushes the vanishing point further.
294 ///
295 /// Must be a positive number.
296 /// {@endtemplate}
297 double get perspective => _perspective;
298 double _perspective;
299 set perspective(double value) {
300 assert(value > 0);
301 assert(
302 value <= 0.01,
303 perspectiveTooHighMessage,
304 );
305 if (value == _perspective) {
306 return;
307 }
308 _perspective = value;
309 markNeedsPaint();
310 markNeedsSemanticsUpdate();
311 }
312
313 /// {@template flutter.rendering.RenderListWheelViewport.offAxisFraction}
314 /// How much the wheel is horizontally off-center, as a fraction of its width.
315
316 /// This property creates the visual effect of looking at a vertical wheel from
317 /// its side where its vanishing points at the edge curves to one side instead
318 /// of looking at the wheel head-on.
319 ///
320 /// The value is horizontal distance between the wheel's center and the vertical
321 /// vanishing line at the edges of the wheel, represented as a fraction of the
322 /// wheel's width.
323 ///
324 /// The value `0.0` means the wheel is looked at head-on and its vanishing
325 /// line runs through the center of the wheel. Negative values means moving
326 /// the wheel to the left of the observer, thus the edges curve to the right.
327 /// Positive values means moving the wheel to the right of the observer,
328 /// thus the edges curve to the left.
329 ///
330 /// The visual effect causes the wheel's edges to curve rather than moving
331 /// the center. So a value of `0.5` means the edges' vanishing line will touch
332 /// the wheel's size's left edge.
333 ///
334 /// Defaults to `0.0`, which means looking at the wheel head-on.
335 /// The visual effect can be unaesthetic if this value is too far from the
336 /// range `[-0.5, 0.5]`.
337 /// {@endtemplate}
338 double get offAxisFraction => _offAxisFraction;
339 double _offAxisFraction = 0.0;
340 set offAxisFraction(double value) {
341 if (value == _offAxisFraction) {
342 return;
343 }
344 _offAxisFraction = value;
345 markNeedsPaint();
346 }
347
348 /// {@template flutter.rendering.RenderListWheelViewport.useMagnifier}
349 /// Whether to use the magnifier for the center item of the wheel.
350 /// {@endtemplate}
351 bool get useMagnifier => _useMagnifier;
352 bool _useMagnifier = false;
353 set useMagnifier(bool value) {
354 if (value == _useMagnifier) {
355 return;
356 }
357 _useMagnifier = value;
358 markNeedsPaint();
359 }
360 /// {@template flutter.rendering.RenderListWheelViewport.magnification}
361 /// The zoomed-in rate of the magnifier, if it is used.
362 ///
363 /// The default value is 1.0, which will not change anything.
364 /// If the value is > 1.0, the center item will be zoomed in by that rate, and
365 /// it will also be rendered as flat, not cylindrical like the rest of the list.
366 /// The item will be zoomed out if magnification < 1.0.
367 ///
368 /// Must be positive.
369 /// {@endtemplate}
370 double get magnification => _magnification;
371 double _magnification = 1.0;
372 set magnification(double value) {
373 assert(value > 0);
374 if (value == _magnification) {
375 return;
376 }
377 _magnification = value;
378 markNeedsPaint();
379 }
380
381 /// {@template flutter.rendering.RenderListWheelViewport.overAndUnderCenterOpacity}
382 /// The opacity value that will be applied to the wheel that appears below and
383 /// above the magnifier.
384 ///
385 /// The default value is 1.0, which will not change anything.
386 ///
387 /// Must be greater than or equal to 0, and less than or equal to 1.
388 /// {@endtemplate}
389 double get overAndUnderCenterOpacity => _overAndUnderCenterOpacity;
390 double _overAndUnderCenterOpacity = 1.0;
391 set overAndUnderCenterOpacity(double value) {
392 assert(value >= 0 && value <= 1);
393 if (value == _overAndUnderCenterOpacity) {
394 return;
395 }
396 _overAndUnderCenterOpacity = value;
397 markNeedsPaint();
398 }
399
400 /// {@template flutter.rendering.RenderListWheelViewport.itemExtent}
401 /// The size of the children along the main axis. Children [RenderBox]es will
402 /// be given the [BoxConstraints] of this exact size.
403 ///
404 /// Must be a positive number.
405 /// {@endtemplate}
406 double get itemExtent => _itemExtent;
407 double _itemExtent;
408 set itemExtent(double value) {
409 assert(value > 0);
410 if (value == _itemExtent) {
411 return;
412 }
413 _itemExtent = value;
414 markNeedsLayout();
415 }
416
417
418 /// {@template flutter.rendering.RenderListWheelViewport.squeeze}
419 /// The angular compactness of the children on the wheel.
420 ///
421 /// This denotes a ratio of the number of children on the wheel vs the number
422 /// of children that would fit on a flat list of equivalent size, assuming
423 /// [diameterRatio] of 1.
424 ///
425 /// For instance, if this RenderListWheelViewport has a height of 100px and
426 /// [itemExtent] is 20px, 5 items would fit on an equivalent flat list.
427 /// With a [squeeze] of 1, 5 items would also be shown in the
428 /// RenderListWheelViewport. With a [squeeze] of 2, 10 items would be shown
429 /// in the RenderListWheelViewport.
430 ///
431 /// Changing this value will change the number of children built and shown
432 /// inside the wheel.
433 ///
434 /// Must be a positive number.
435 /// {@endtemplate}
436 ///
437 /// Defaults to 1.
438 double get squeeze => _squeeze;
439 double _squeeze;
440 set squeeze(double value) {
441 assert(value > 0);
442 if (value == _squeeze) {
443 return;
444 }
445 _squeeze = value;
446 markNeedsLayout();
447 markNeedsSemanticsUpdate();
448 }
449
450 /// {@template flutter.rendering.RenderListWheelViewport.renderChildrenOutsideViewport}
451 /// Whether to paint children inside the viewport only.
452 ///
453 /// If false, every child will be painted. However the [Scrollable] is still
454 /// the size of the viewport and detects gestures inside only.
455 ///
456 /// Defaults to false. Cannot be true if [clipBehavior] is not [Clip.none]
457 /// since children outside the viewport will be clipped, and therefore cannot
458 /// render children outside the viewport.
459 /// {@endtemplate}
460 bool get renderChildrenOutsideViewport => _renderChildrenOutsideViewport;
461 bool _renderChildrenOutsideViewport;
462 set renderChildrenOutsideViewport(bool value) {
463 assert(
464 !renderChildrenOutsideViewport || clipBehavior == Clip.none,
465 clipBehaviorAndRenderChildrenOutsideViewportConflict,
466 );
467 if (value == _renderChildrenOutsideViewport) {
468 return;
469 }
470 _renderChildrenOutsideViewport = value;
471 markNeedsLayout();
472 markNeedsSemanticsUpdate();
473 }
474
475 /// {@macro flutter.material.Material.clipBehavior}
476 ///
477 /// Defaults to [Clip.hardEdge].
478 Clip get clipBehavior => _clipBehavior;
479 Clip _clipBehavior = Clip.hardEdge;
480 set clipBehavior(Clip value) {
481 if (value != _clipBehavior) {
482 _clipBehavior = value;
483 markNeedsPaint();
484 markNeedsSemanticsUpdate();
485 }
486 }
487
488 void _hasScrolled() {
489 markNeedsLayout();
490 markNeedsSemanticsUpdate();
491 }
492
493 @override
494 void setupParentData(RenderObject child) {
495 if (child.parentData is! ListWheelParentData) {
496 child.parentData = ListWheelParentData();
497 }
498 }
499
500 @override
501 void attach(PipelineOwner owner) {
502 super.attach(owner);
503 _offset.addListener(_hasScrolled);
504 }
505
506 @override
507 void detach() {
508 _offset.removeListener(_hasScrolled);
509 super.detach();
510 }
511
512 @override
513 bool get isRepaintBoundary => true;
514
515 /// Main axis length in the untransformed plane.
516 double get _viewportExtent {
517 assert(hasSize);
518 return size.height;
519 }
520
521 /// Main axis scroll extent in the **scrollable layout coordinates** that puts
522 /// the first item in the center.
523 double get _minEstimatedScrollExtent {
524 assert(hasSize);
525 if (childManager.childCount == null) {
526 return double.negativeInfinity;
527 }
528 return 0.0;
529 }
530
531 /// Main axis scroll extent in the **scrollable layout coordinates** that puts
532 /// the last item in the center.
533 double get _maxEstimatedScrollExtent {
534 assert(hasSize);
535 if (childManager.childCount == null) {
536 return double.infinity;
537 }
538
539 return math.max(0.0, (childManager.childCount! - 1) * _itemExtent);
540 }
541
542 /// Scroll extent distance in the untransformed plane between the center
543 /// position in the viewport and the top position in the viewport.
544 ///
545 /// It's also the distance in the untransformed plane that children's painting
546 /// is offset by with respect to those children's [BoxParentData.offset].
547 double get _topScrollMarginExtent {
548 assert(hasSize);
549 // Consider adding alignment options other than center.
550 return -size.height / 2.0 + _itemExtent / 2.0;
551 }
552
553 /// Transforms a **scrollable layout coordinates**' y position to the
554 /// **untransformed plane's viewport painting coordinates**' y position given
555 /// the current scroll offset.
556 double _getUntransformedPaintingCoordinateY(double layoutCoordinateY) {
557 return layoutCoordinateY - _topScrollMarginExtent - offset.pixels;
558 }
559
560 /// Given the _diameterRatio, return the largest absolute angle of the item
561 /// at the edge of the portion of the visible cylinder.
562 ///
563 /// For a _diameterRatio of 1 or less than 1 (i.e. the viewport is bigger
564 /// than the cylinder diameter), this value reaches and clips at pi / 2.
565 ///
566 /// When the center of children passes this angle, they are no longer painted
567 /// if [renderChildrenOutsideViewport] is false.
568 double get _maxVisibleRadian {
569 if (_diameterRatio < 1.0) {
570 return math.pi / 2.0;
571 }
572 return math.asin(1.0 / _diameterRatio);
573 }
574
575 double _getIntrinsicCrossAxis(_ChildSizingFunction childSize) {
576 double extent = 0.0;
577 RenderBox? child = firstChild;
578 while (child != null) {
579 extent = math.max(extent, childSize(child));
580 child = childAfter(child);
581 }
582 return extent;
583 }
584
585 @override
586 double computeMinIntrinsicWidth(double height) {
587 return _getIntrinsicCrossAxis(
588 (RenderBox child) => child.getMinIntrinsicWidth(height),
589 );
590 }
591
592 @override
593 double computeMaxIntrinsicWidth(double height) {
594 return _getIntrinsicCrossAxis(
595 (RenderBox child) => child.getMaxIntrinsicWidth(height),
596 );
597 }
598
599 @override
600 double computeMinIntrinsicHeight(double width) {
601 if (childManager.childCount == null) {
602 return 0.0;
603 }
604 return childManager.childCount! * _itemExtent;
605 }
606
607 @override
608 double computeMaxIntrinsicHeight(double width) {
609 if (childManager.childCount == null) {
610 return 0.0;
611 }
612 return childManager.childCount! * _itemExtent;
613 }
614
615 @override
616 bool get sizedByParent => true;
617
618 @override
619 @protected
620 Size computeDryLayout(covariant BoxConstraints constraints) {
621 return constraints.biggest;
622 }
623
624 /// Gets the index of a child by looking at its [parentData].
625 ///
626 /// This relies on the [childManager] maintaining [ListWheelParentData.index].
627 int indexOf(RenderBox child) {
628 final ListWheelParentData childParentData = child.parentData! as ListWheelParentData;
629 assert(childParentData.index != null);
630 return childParentData.index!;
631 }
632
633 /// Returns the index of the child at the given offset.
634 int scrollOffsetToIndex(double scrollOffset) => (scrollOffset / itemExtent).floor();
635
636 /// Returns the scroll offset of the child with the given index.
637 double indexToScrollOffset(int index) => index * itemExtent;
638
639 void _createChild(int index, { RenderBox? after }) {
640 invokeLayoutCallback<BoxConstraints>((BoxConstraints constraints) {
641 assert(constraints == this.constraints);
642 childManager.createChild(index, after: after);
643 });
644 }
645
646 void _destroyChild(RenderBox child) {
647 invokeLayoutCallback<BoxConstraints>((BoxConstraints constraints) {
648 assert(constraints == this.constraints);
649 childManager.removeChild(child);
650 });
651 }
652
653 void _layoutChild(RenderBox child, BoxConstraints constraints, int index) {
654 child.layout(constraints, parentUsesSize: true);
655 final ListWheelParentData childParentData = child.parentData! as ListWheelParentData;
656 // Centers the child horizontally.
657 final double crossPosition = size.width / 2.0 - child.size.width / 2.0;
658 childParentData.offset = Offset(crossPosition, indexToScrollOffset(index));
659 }
660
661 /// Performs layout based on how [childManager] provides children.
662 ///
663 /// From the current scroll offset, the minimum index and maximum index that
664 /// is visible in the viewport can be calculated. The index range of the
665 /// currently active children can also be acquired by looking directly at
666 /// the current child list. This function has to modify the current index
667 /// range to match the target index range by removing children that are no
668 /// longer visible and creating those that are visible but not yet provided
669 /// by [childManager].
670 @override
671 void performLayout() {
672 offset.applyViewportDimension(_viewportExtent);
673 // Apply the content dimensions first if it has exact dimensions in case it
674 // changes the scroll offset which determines what should be shown. Such as
675 // if the child count decrease, we should correct the pixels first, otherwise,
676 // it may be shown blank null children.
677 if (childManager.childCount != null) {
678 offset.applyContentDimensions(_minEstimatedScrollExtent, _maxEstimatedScrollExtent);
679 }
680
681 // The height, in pixel, that children will be visible and might be laid out
682 // and painted.
683 double visibleHeight = size.height * _squeeze;
684 // If renderChildrenOutsideViewport is true, we spawn extra children by
685 // doubling the visibility range, those that are in the backside of the
686 // cylinder won't be painted anyway.
687 if (renderChildrenOutsideViewport) {
688 visibleHeight *= 2;
689 }
690
691 final double firstVisibleOffset =
692 offset.pixels + _itemExtent / 2 - visibleHeight / 2;
693 final double lastVisibleOffset = firstVisibleOffset + visibleHeight;
694
695 // The index range that we want to spawn children. We find indexes that
696 // are in the interval [firstVisibleOffset, lastVisibleOffset).
697 int targetFirstIndex = scrollOffsetToIndex(firstVisibleOffset);
698 int targetLastIndex = scrollOffsetToIndex(lastVisibleOffset);
699 // Because we exclude lastVisibleOffset, if there's a new child starting at
700 // that offset, it is removed.
701 if (targetLastIndex * _itemExtent == lastVisibleOffset) {
702 targetLastIndex--;
703 }
704
705 // Validates the target index range.
706 while (!childManager.childExistsAt(targetFirstIndex) && targetFirstIndex <= targetLastIndex) {
707 targetFirstIndex++;
708 }
709 while (!childManager.childExistsAt(targetLastIndex) && targetFirstIndex <= targetLastIndex) {
710 targetLastIndex--;
711 }
712
713 // If it turns out there's no children to layout, we remove old children and
714 // return.
715 if (targetFirstIndex > targetLastIndex) {
716 while (firstChild != null) {
717 _destroyChild(firstChild!);
718 }
719 return;
720 }
721
722 // Now there are 2 cases:
723 // - The target index range and our current index range have intersection:
724 // We shorten and extend our current child list so that the two lists
725 // match. Most of the time we are in this case.
726 // - The target list and our current child list have no intersection:
727 // We first remove all children and then add one child from the target
728 // list => this case becomes the other case.
729
730 // Case when there is no intersection.
731 if (childCount > 0 &&
732 (indexOf(firstChild!) > targetLastIndex || indexOf(lastChild!) < targetFirstIndex)) {
733 while (firstChild != null) {
734 _destroyChild(firstChild!);
735 }
736 }
737
738 final BoxConstraints childConstraints = constraints.copyWith(
739 minHeight: _itemExtent,
740 maxHeight: _itemExtent,
741 minWidth: 0.0,
742 );
743 // If there is no child at this stage, we add the first one that is in
744 // target range.
745 if (childCount == 0) {
746 _createChild(targetFirstIndex);
747 _layoutChild(firstChild!, childConstraints, targetFirstIndex);
748 }
749
750 int currentFirstIndex = indexOf(firstChild!);
751 int currentLastIndex = indexOf(lastChild!);
752
753 // Remove all unnecessary children by shortening the current child list, in
754 // both directions.
755 while (currentFirstIndex < targetFirstIndex) {
756 _destroyChild(firstChild!);
757 currentFirstIndex++;
758 }
759 while (currentLastIndex > targetLastIndex) {
760 _destroyChild(lastChild!);
761 currentLastIndex--;
762 }
763
764 // Relayout all active children.
765 RenderBox? child = firstChild;
766 int index = currentFirstIndex;
767 while (child != null) {
768 _layoutChild(child, childConstraints, index++);
769 child = childAfter(child);
770 }
771
772 // Spawning new children that are actually visible but not in child list yet.
773 while (currentFirstIndex > targetFirstIndex) {
774 _createChild(currentFirstIndex - 1);
775 _layoutChild(firstChild!, childConstraints, --currentFirstIndex);
776 }
777 while (currentLastIndex < targetLastIndex) {
778 _createChild(currentLastIndex + 1, after: lastChild);
779 _layoutChild(lastChild!, childConstraints, ++currentLastIndex);
780 }
781
782 // Applying content dimensions bases on how the childManager builds widgets:
783 // if it is available to provide a child just out of target range, then
784 // we don't know whether there's a limit yet, and set the dimension to the
785 // estimated value. Otherwise, we set the dimension limited to our target
786 // range.
787 final double minScrollExtent = childManager.childExistsAt(targetFirstIndex - 1)
788 ? _minEstimatedScrollExtent
789 : indexToScrollOffset(targetFirstIndex);
790 final double maxScrollExtent = childManager.childExistsAt(targetLastIndex + 1)
791 ? _maxEstimatedScrollExtent
792 : indexToScrollOffset(targetLastIndex);
793 offset.applyContentDimensions(minScrollExtent, maxScrollExtent);
794 }
795
796 bool _shouldClipAtCurrentOffset() {
797 final double highestUntransformedPaintY =
798 _getUntransformedPaintingCoordinateY(0.0);
799 return highestUntransformedPaintY < 0.0
800 || size.height < highestUntransformedPaintY + _maxEstimatedScrollExtent + _itemExtent;
801 }
802
803 @override
804 void paint(PaintingContext context, Offset offset) {
805 if (childCount > 0) {
806 if (_shouldClipAtCurrentOffset() && clipBehavior != Clip.none) {
807 _clipRectLayer.layer = context.pushClipRect(
808 needsCompositing,
809 offset,
810 Offset.zero & size,
811 _paintVisibleChildren,
812 clipBehavior: clipBehavior,
813 oldLayer: _clipRectLayer.layer,
814 );
815 } else {
816 _clipRectLayer.layer = null;
817 _paintVisibleChildren(context, offset);
818 }
819 }
820 }
821
822 final LayerHandle<ClipRectLayer> _clipRectLayer = LayerHandle<ClipRectLayer>();
823
824 @override
825 void dispose() {
826 _clipRectLayer.layer = null;
827 _childOpacityLayerHandler.layer = null;
828 super.dispose();
829 }
830 final LayerHandle<OpacityLayer> _childOpacityLayerHandler = LayerHandle<OpacityLayer>();
831 /// Paints all children visible in the current viewport.
832 void _paintVisibleChildren(PaintingContext context, Offset offset) {
833 // The magnifier cannot be turned off if the opacity is less than 1.0.
834 if (overAndUnderCenterOpacity >= 1) {
835 _paintAllChildren(context, offset);
836 return;
837 }
838
839 // In order to reduce the number of opacity layers, we first paint all
840 // partially opaque children, then finally paint the fully opaque children.
841 _childOpacityLayerHandler.layer = context.pushOpacity(offset, (overAndUnderCenterOpacity * 255).round(), (PaintingContext context, Offset offset) {
842 _paintAllChildren(context, offset, center: false);
843 });
844 _paintAllChildren(context, offset, center: true);
845 }
846
847 void _paintAllChildren(PaintingContext context, Offset offset, { bool? center }) {
848 RenderBox? childToPaint = firstChild;
849 while (childToPaint != null) {
850 final ListWheelParentData childParentData = childToPaint.parentData! as ListWheelParentData;
851 _paintTransformedChild(childToPaint, context, offset, childParentData.offset, center: center);
852 childToPaint = childAfter(childToPaint);
853 }
854 }
855
856 // Takes in a child with a **scrollable layout offset** and paints it in the
857 // **transformed cylindrical space viewport painting coordinates**.
858 //
859 // The value of `center` is passed through to _paintChildWithMagnifier only
860 // if the magnifier is enabled and/or opacity is < 1.0.
861 void _paintTransformedChild(
862 RenderBox child,
863 PaintingContext context,
864 Offset offset,
865 Offset layoutOffset, {
866 required bool? center,
867 }) {
868 final Offset untransformedPaintingCoordinates = offset
869 + Offset(
870 layoutOffset.dx,
871 _getUntransformedPaintingCoordinateY(layoutOffset.dy),
872 );
873
874 // Get child's center as a fraction of the viewport's height.
875 final double fractionalY =
876 (untransformedPaintingCoordinates.dy + _itemExtent / 2.0) / size.height;
877 final double angle = -(fractionalY - 0.5) * 2.0 * _maxVisibleRadian / squeeze;
878 // Don't paint the backside of the cylinder when
879 // renderChildrenOutsideViewport is true. Otherwise, only children within
880 // suitable angles (via _first/lastVisibleLayoutOffset) reach the paint
881 // phase.
882 if (angle > math.pi / 2.0 || angle < -math.pi / 2.0) {
883 return;
884 }
885
886 final Matrix4 transform = MatrixUtils.createCylindricalProjectionTransform(
887 radius: size.height * _diameterRatio / 2.0,
888 angle: angle,
889 perspective: _perspective,
890 );
891
892 // Offset that helps painting everything in the center (e.g. angle = 0).
893 final Offset offsetToCenter = Offset(
894 untransformedPaintingCoordinates.dx,
895 -_topScrollMarginExtent,
896 );
897
898 final bool shouldApplyOffCenterDim = overAndUnderCenterOpacity < 1;
899 if (useMagnifier || shouldApplyOffCenterDim) {
900 _paintChildWithMagnifier(context, offset, child, transform, offsetToCenter, untransformedPaintingCoordinates, center: center);
901 } else {
902 assert(center == null);
903 _paintChildCylindrically(context, offset, child, transform, offsetToCenter);
904 }
905 }
906
907 // Paint child with the magnifier active - the child will be rendered
908 // differently if it intersects with the magnifier.
909 //
910 // `center` controls how items that partially intersect the center magnifier
911 // are rendered. If `center` is false, items are only painted cynlindrically.
912 // If `center` is true, only the clipped magnifier items are painted.
913 // If `center` is null, partially intersecting items are painted both as the
914 // magnifier and cynlidrical item, while non-intersecting items are painted
915 // only cylindrically.
916 //
917 // This property is used to lift the opacity that would be applied to each
918 // cylindrical item into a single layer, reducing the rendering cost of the
919 // pickers which use this viewport.
920 void _paintChildWithMagnifier(
921 PaintingContext context,
922 Offset offset,
923 RenderBox child,
924 Matrix4 cylindricalTransform,
925 Offset offsetToCenter,
926 Offset untransformedPaintingCoordinates, {
927 required bool? center,
928 }) {
929 final double magnifierTopLinePosition =
930 size.height / 2 - _itemExtent * _magnification / 2;
931 final double magnifierBottomLinePosition =
932 size.height / 2 + _itemExtent * _magnification / 2;
933
934 final bool isAfterMagnifierTopLine = untransformedPaintingCoordinates.dy
935 >= magnifierTopLinePosition - _itemExtent * _magnification;
936 final bool isBeforeMagnifierBottomLine = untransformedPaintingCoordinates.dy
937 <= magnifierBottomLinePosition;
938
939 final Rect centerRect = Rect.fromLTWH(
940 0.0,
941 magnifierTopLinePosition,
942 size.width,
943 _itemExtent * _magnification,
944 );
945 final Rect topHalfRect = Rect.fromLTWH(
946 0.0,
947 0.0,
948 size.width,
949 magnifierTopLinePosition,
950 );
951 final Rect bottomHalfRect = Rect.fromLTWH(
952 0.0,
953 magnifierBottomLinePosition,
954 size.width,
955 magnifierTopLinePosition,
956 );
957 // Some part of the child is in the center magnifier.
958 final bool inCenter = isAfterMagnifierTopLine && isBeforeMagnifierBottomLine;
959
960 if ((center == null || center) && inCenter) {
961 // Clipping the part in the center.
962 context.pushClipRect(
963 needsCompositing,
964 offset,
965 centerRect,
966 (PaintingContext context, Offset offset) {
967 context.pushTransform(
968 needsCompositing,
969 offset,
970 _magnifyTransform(),
971 (PaintingContext context, Offset offset) {
972 context.paintChild(child, offset + untransformedPaintingCoordinates);
973 },
974 );
975 },
976 );
977 }
978
979 // Clipping the part in either the top-half or bottom-half of the wheel.
980 if ((center == null || !center) && inCenter) {
981 context.pushClipRect(
982 needsCompositing,
983 offset,
984 untransformedPaintingCoordinates.dy <= magnifierTopLinePosition
985 ? topHalfRect
986 : bottomHalfRect,
987 (PaintingContext context, Offset offset) {
988 _paintChildCylindrically(
989 context,
990 offset,
991 child,
992 cylindricalTransform,
993 offsetToCenter,
994 );
995 },
996 );
997 }
998
999 if ((center == null || !center) && !inCenter) {
1000 _paintChildCylindrically(
1001 context,
1002 offset,
1003 child,
1004 cylindricalTransform,
1005 offsetToCenter,
1006 );
1007 }
1008 }
1009
1010 // / Paint the child cylindrically at given offset.
1011 void _paintChildCylindrically(
1012 PaintingContext context,
1013 Offset offset,
1014 RenderBox child,
1015 Matrix4 cylindricalTransform,
1016 Offset offsetToCenter,
1017 ) {
1018 final Offset paintOriginOffset = offset + offsetToCenter;
1019
1020 // Paint child cylindrically, without [overAndUnderCenterOpacity].
1021 void painter(PaintingContext context, Offset offset) {
1022 context.paintChild(
1023 child,
1024 // Paint everything in the center (e.g. angle = 0), then transform.
1025 paintOriginOffset,
1026 );
1027 }
1028
1029 context.pushTransform(
1030 needsCompositing,
1031 offset,
1032 _centerOriginTransform(cylindricalTransform),
1033 // Pre-transform painting function.
1034 painter,
1035 );
1036
1037 final ListWheelParentData childParentData = child.parentData! as ListWheelParentData;
1038 // Save the final transform that accounts both for the offset and cylindrical transform.
1039 final Matrix4 transform = _centerOriginTransform(cylindricalTransform)
1040 ..translate(paintOriginOffset.dx, paintOriginOffset.dy);
1041 childParentData.transform = transform;
1042 }
1043
1044 /// Return the Matrix4 transformation that would zoom in content in the
1045 /// magnified area.
1046 Matrix4 _magnifyTransform() {
1047 final Matrix4 magnify = Matrix4.identity();
1048 magnify.translate(size.width * (-_offAxisFraction + 0.5), size.height / 2);
1049 magnify.scale(_magnification, _magnification, _magnification);
1050 magnify.translate(-size.width * (-_offAxisFraction + 0.5), -size.height / 2);
1051 return magnify;
1052 }
1053
1054 /// Apply incoming transformation with the transformation's origin at the
1055 /// viewport's center or horizontally off to the side based on offAxisFraction.
1056 Matrix4 _centerOriginTransform(Matrix4 originalMatrix) {
1057 final Matrix4 result = Matrix4.identity();
1058 final Offset centerOriginTranslation = Alignment.center.alongSize(size);
1059 result.translate(
1060 centerOriginTranslation.dx * (-_offAxisFraction * 2 + 1),
1061 centerOriginTranslation.dy,
1062 );
1063 result.multiply(originalMatrix);
1064 result.translate(
1065 -centerOriginTranslation.dx * (-_offAxisFraction * 2 + 1),
1066 -centerOriginTranslation.dy,
1067 );
1068 return result;
1069 }
1070
1071 static bool _debugAssertValidHitTestOffsets(String context, Offset offset1, Offset offset2) {
1072 if (offset1 != offset2) {
1073 throw FlutterError("$context - hit test expected values didn't match: $offset1 != $offset2");
1074 }
1075 return true;
1076 }
1077
1078 @override
1079 void applyPaintTransform(RenderBox child, Matrix4 transform) {
1080 final ListWheelParentData parentData = child.parentData! as ListWheelParentData;
1081 final Matrix4? paintTransform = parentData.transform;
1082 if (paintTransform != null) {
1083 transform.multiply(paintTransform);
1084 }
1085 }
1086
1087 @override
1088 Rect? describeApproximatePaintClip(RenderObject child) {
1089 if (_shouldClipAtCurrentOffset()) {
1090 return Offset.zero & size;
1091 }
1092 return null;
1093 }
1094
1095 @override
1096 bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
1097 RenderBox? child = lastChild;
1098 while (child != null) {
1099 final ListWheelParentData childParentData = child.parentData! as ListWheelParentData;
1100 final Matrix4? transform = childParentData.transform;
1101 // Skip not painted children
1102 if (transform != null) {
1103 final bool isHit = result.addWithPaintTransform(
1104 transform: transform,
1105 position: position,
1106 hitTest: (BoxHitTestResult result, Offset transformed) {
1107 assert(() {
1108 final Matrix4? inverted = Matrix4.tryInvert(PointerEvent.removePerspectiveTransform(transform));
1109 if (inverted == null) {
1110 return _debugAssertValidHitTestOffsets('Null inverted transform', transformed, position);
1111 }
1112 return _debugAssertValidHitTestOffsets('MatrixUtils.transformPoint', transformed, MatrixUtils.transformPoint(inverted, position));
1113 }());
1114 return child!.hitTest(result, position: transformed);
1115 },
1116 );
1117 if (isHit) {
1118 return true;
1119 }
1120 }
1121 child = childParentData.previousSibling;
1122 }
1123 return false;
1124 }
1125
1126 @override
1127 RevealedOffset getOffsetToReveal(
1128 RenderObject target,
1129 double alignment, {
1130 Rect? rect,
1131 Axis? axis, // Unused, only Axis.vertical supported by this viewport.
1132 }) {
1133 // `target` is only fully revealed when in the selected/center position. Therefore,
1134 // this method always returns the offset that shows `target` in the center position,
1135 // which is the same offset for all `alignment` values.
1136 rect ??= target.paintBounds;
1137
1138 // `child` will be the last RenderObject before the viewport when walking up from `target`.
1139 RenderObject child = target;
1140 while (child.parent != this) {
1141 child = child.parent!;
1142 }
1143
1144 final ListWheelParentData parentData = child.parentData! as ListWheelParentData;
1145 final double targetOffset = parentData.offset.dy; // the so-called "centerPosition"
1146
1147 final Matrix4 transform = target.getTransformTo(child);
1148 final Rect bounds = MatrixUtils.transformRect(transform, rect);
1149 final Rect targetRect = bounds.translate(0.0, (size.height - itemExtent) / 2);
1150
1151 return RevealedOffset(offset: targetOffset, rect: targetRect);
1152 }
1153
1154 @override
1155 void showOnScreen({
1156 RenderObject? descendant,
1157 Rect? rect,
1158 Duration duration = Duration.zero,
1159 Curve curve = Curves.ease,
1160 }) {
1161 if (descendant != null) {
1162 // Shows the descendant in the selected/center position.
1163 final RevealedOffset revealedOffset = getOffsetToReveal(descendant, 0.5, rect: rect);
1164 if (duration == Duration.zero) {
1165 offset.jumpTo(revealedOffset.offset);
1166 } else {
1167 offset.animateTo(revealedOffset.offset, duration: duration, curve: curve);
1168 }
1169 rect = revealedOffset.rect;
1170 }
1171
1172 super.showOnScreen(
1173 rect: rect,
1174 duration: duration,
1175 curve: curve,
1176 );
1177 }
1178}
1179