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:collection';
6import 'dart:math' as math;
7
8import 'package:flutter/physics.dart';
9import 'package:flutter/rendering.dart';
10
11import 'basic.dart';
12import 'framework.dart';
13import 'notification_listener.dart';
14import 'scroll_configuration.dart';
15import 'scroll_context.dart';
16import 'scroll_controller.dart';
17import 'scroll_metrics.dart';
18import 'scroll_notification.dart';
19import 'scroll_physics.dart';
20import 'scroll_position.dart';
21import 'scroll_position_with_single_context.dart';
22import 'scrollable.dart';
23
24/// A delegate that supplies children for [ListWheelScrollView].
25///
26/// [ListWheelScrollView] lazily constructs its children during layout to avoid
27/// creating more children than are visible through the [Viewport]. This
28/// delegate is responsible for providing children to [ListWheelScrollView]
29/// during that stage.
30///
31/// See also:
32///
33/// * [ListWheelChildListDelegate], a delegate that supplies children using an
34/// explicit list.
35/// * [ListWheelChildLoopingListDelegate], a delegate that supplies infinite
36/// children by looping an explicit list.
37/// * [ListWheelChildBuilderDelegate], a delegate that supplies children using
38/// a builder callback.
39abstract class ListWheelChildDelegate {
40 /// Return the child at the given index. If the child at the given
41 /// index does not exist, return null.
42 Widget? build(BuildContext context, int index);
43
44 /// Returns an estimate of the number of children this delegate will build.
45 int? get estimatedChildCount;
46
47 /// Returns the true index for a child built at a given index. Defaults to
48 /// the given index, however if the delegate is [ListWheelChildLoopingListDelegate],
49 /// this value is the index of the true element that the delegate is looping to.
50 ///
51 ///
52 /// Example: [ListWheelChildLoopingListDelegate] is built by looping a list of
53 /// length 8. Then, trueIndexOf(10) = 2 and trueIndexOf(-5) = 3.
54 int trueIndexOf(int index) => index;
55
56 /// Called to check whether this and the old delegate are actually 'different',
57 /// so that the caller can decide to rebuild or not.
58 bool shouldRebuild(covariant ListWheelChildDelegate oldDelegate);
59}
60
61/// A delegate that supplies children for [ListWheelScrollView] using an
62/// explicit list.
63///
64/// [ListWheelScrollView] lazily constructs its children to avoid creating more
65/// children than are visible through the [Viewport]. This delegate provides
66/// children using an explicit list, which is convenient but reduces the benefit
67/// of building children lazily.
68///
69/// In general building all the widgets in advance is not efficient. It is
70/// better to create a delegate that builds them on demand using
71/// [ListWheelChildBuilderDelegate] or by subclassing [ListWheelChildDelegate]
72/// directly.
73///
74/// This class is provided for the cases where either the list of children is
75/// known well in advance (ideally the children are themselves compile-time
76/// constants, for example), and therefore will not be built each time the
77/// delegate itself is created, or the list is small, such that it's likely
78/// always visible (and thus there is nothing to be gained by building it on
79/// demand). For example, the body of a dialog box might fit both of these
80/// conditions.
81class ListWheelChildListDelegate extends ListWheelChildDelegate {
82 /// Constructs the delegate from a concrete list of children.
83 ListWheelChildListDelegate({required this.children});
84
85 /// The list containing all children that can be supplied.
86 final List<Widget> children;
87
88 @override
89 int get estimatedChildCount => children.length;
90
91 @override
92 Widget? build(BuildContext context, int index) {
93 if (index < 0 || index >= children.length) {
94 return null;
95 }
96 return IndexedSemantics(index: index, child: children[index]);
97 }
98
99 @override
100 bool shouldRebuild(covariant ListWheelChildListDelegate oldDelegate) {
101 return children != oldDelegate.children;
102 }
103}
104
105/// A delegate that supplies infinite children for [ListWheelScrollView] by
106/// looping an explicit list.
107///
108/// [ListWheelScrollView] lazily constructs its children to avoid creating more
109/// children than are visible through the [Viewport]. This delegate provides
110/// children using an explicit list, which is convenient but reduces the benefit
111/// of building children lazily.
112///
113/// In general building all the widgets in advance is not efficient. It is
114/// better to create a delegate that builds them on demand using
115/// [ListWheelChildBuilderDelegate] or by subclassing [ListWheelChildDelegate]
116/// directly.
117///
118/// This class is provided for the cases where either the list of children is
119/// known well in advance (ideally the children are themselves compile-time
120/// constants, for example), and therefore will not be built each time the
121/// delegate itself is created, or the list is small, such that it's likely
122/// always visible (and thus there is nothing to be gained by building it on
123/// demand). For example, the body of a dialog box might fit both of these
124/// conditions.
125class ListWheelChildLoopingListDelegate extends ListWheelChildDelegate {
126 /// Constructs the delegate from a concrete list of children.
127 ListWheelChildLoopingListDelegate({required this.children});
128
129 /// The list containing all children that can be supplied.
130 final List<Widget> children;
131
132 @override
133 int? get estimatedChildCount => null;
134
135 @override
136 int trueIndexOf(int index) => index % children.length;
137
138 @override
139 Widget? build(BuildContext context, int index) {
140 if (children.isEmpty) {
141 return null;
142 }
143 return IndexedSemantics(index: index, child: children[index % children.length]);
144 }
145
146 @override
147 bool shouldRebuild(covariant ListWheelChildLoopingListDelegate oldDelegate) {
148 return children != oldDelegate.children;
149 }
150}
151
152/// A delegate that supplies children for [ListWheelScrollView] using a builder
153/// callback.
154///
155/// [ListWheelScrollView] lazily constructs its children to avoid creating more
156/// children than are visible through the [Viewport]. This delegate provides
157/// children using an [IndexedWidgetBuilder] callback, so that the children do
158/// not have to be built until they are displayed.
159class ListWheelChildBuilderDelegate extends ListWheelChildDelegate {
160 /// Constructs the delegate from a builder callback.
161 ListWheelChildBuilderDelegate({
162 required this.builder,
163 this.childCount,
164 });
165
166 /// Called lazily to build children.
167 final NullableIndexedWidgetBuilder builder;
168
169 /// {@template flutter.widgets.ListWheelChildBuilderDelegate.childCount}
170 /// If non-null, [childCount] is the maximum number of children that can be
171 /// provided, and children are available from 0 to [childCount] - 1.
172 ///
173 /// If null, then the lower and upper limit are not known. However the [builder]
174 /// must provide children for a contiguous segment. If the builder returns null
175 /// at some index, the segment terminates there.
176 /// {@endtemplate}
177 final int? childCount;
178
179 @override
180 int? get estimatedChildCount => childCount;
181
182 @override
183 Widget? build(BuildContext context, int index) {
184 if (childCount == null) {
185 final Widget? child = builder(context, index);
186 return child == null ? null : IndexedSemantics(index: index, child: child);
187 }
188 if (index < 0 || index >= childCount!) {
189 return null;
190 }
191 return IndexedSemantics(index: index, child: builder(context, index));
192 }
193
194 @override
195 bool shouldRebuild(covariant ListWheelChildBuilderDelegate oldDelegate) {
196 return builder != oldDelegate.builder || childCount != oldDelegate.childCount;
197 }
198}
199
200/// A controller for scroll views whose items have the same size.
201///
202/// Similar to a standard [ScrollController] but with the added convenience
203/// mechanisms to read and go to item indices rather than a raw pixel scroll
204/// offset.
205///
206/// See also:
207///
208/// * [ListWheelScrollView], a scrollable view widget with fixed size items
209/// that this widget controls.
210/// * [FixedExtentMetrics], the `metrics` property exposed by
211/// [ScrollNotification] from [ListWheelScrollView] which can be used
212/// to listen to the current item index on a push basis rather than polling
213/// the [FixedExtentScrollController].
214class FixedExtentScrollController extends ScrollController {
215 /// Creates a scroll controller for scrollables whose items have the same size.
216 ///
217 /// [initialItem] defaults to zero.
218 FixedExtentScrollController({
219 this.initialItem = 0,
220 super.onAttach,
221 super.onDetach,
222 });
223
224 /// The page to show when first creating the scroll view.
225 ///
226 /// Defaults to zero.
227 final int initialItem;
228
229 /// The currently selected item index that's closest to the center of the viewport.
230 ///
231 /// There are circumstances that this [FixedExtentScrollController] can't know
232 /// the current item. Reading [selectedItem] will throw an [AssertionError] in
233 /// the following cases:
234 ///
235 /// 1. No scroll view is currently using this [FixedExtentScrollController].
236 /// 2. More than one scroll views using the same [FixedExtentScrollController].
237 ///
238 /// The [hasClients] property can be used to check if a scroll view is
239 /// attached prior to accessing [selectedItem].
240 int get selectedItem {
241 assert(
242 positions.isNotEmpty,
243 'FixedExtentScrollController.selectedItem cannot be accessed before a '
244 'scroll view is built with it.',
245 );
246 assert(
247 positions.length == 1,
248 'The selectedItem property cannot be read when multiple scroll views are '
249 'attached to the same FixedExtentScrollController.',
250 );
251 final _FixedExtentScrollPosition position = this.position as _FixedExtentScrollPosition;
252 return position.itemIndex;
253 }
254
255 /// Animates the controlled scroll view to the given item index.
256 ///
257 /// The animation lasts for the given duration and follows the given curve.
258 /// The returned [Future] resolves when the animation completes.
259 Future<void> animateToItem(
260 int itemIndex, {
261 required Duration duration,
262 required Curve curve,
263 }) async {
264 if (!hasClients) {
265 return;
266 }
267
268 await Future.wait<void>(<Future<void>>[
269 for (final _FixedExtentScrollPosition position in positions.cast<_FixedExtentScrollPosition>())
270 position.animateTo(
271 itemIndex * position.itemExtent,
272 duration: duration,
273 curve: curve,
274 ),
275 ]);
276 }
277
278 /// Changes which item index is centered in the controlled scroll view.
279 ///
280 /// Jumps the item index position from its current value to the given value,
281 /// without animation, and without checking if the new value is in range.
282 void jumpToItem(int itemIndex) {
283 for (final _FixedExtentScrollPosition position in positions.cast<_FixedExtentScrollPosition>()) {
284 position.jumpTo(itemIndex * position.itemExtent);
285 }
286 }
287
288 @override
289 ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition? oldPosition) {
290 return _FixedExtentScrollPosition(
291 physics: physics,
292 context: context,
293 initialItem: initialItem,
294 oldPosition: oldPosition,
295 );
296 }
297}
298
299/// Metrics for a [ScrollPosition] to a scroll view with fixed item sizes.
300///
301/// The metrics are available on [ScrollNotification]s generated from a scroll
302/// views such as [ListWheelScrollView]s with a [FixedExtentScrollController]
303/// and exposes the current [itemIndex] and the scroll view's extents.
304///
305/// `FixedExtent` refers to the fact that the scrollable items have the same
306/// size. This is distinct from `Fixed` in the parent class name's
307/// [FixedScrollMetrics] which refers to its immutability.
308class FixedExtentMetrics extends FixedScrollMetrics {
309 /// Creates an immutable snapshot of values associated with a
310 /// [ListWheelScrollView].
311 FixedExtentMetrics({
312 required super.minScrollExtent,
313 required super.maxScrollExtent,
314 required super.pixels,
315 required super.viewportDimension,
316 required super.axisDirection,
317 required this.itemIndex,
318 required super.devicePixelRatio,
319 });
320
321 @override
322 FixedExtentMetrics copyWith({
323 double? minScrollExtent,
324 double? maxScrollExtent,
325 double? pixels,
326 double? viewportDimension,
327 AxisDirection? axisDirection,
328 int? itemIndex,
329 double? devicePixelRatio,
330 }) {
331 return FixedExtentMetrics(
332 minScrollExtent: minScrollExtent ?? (hasContentDimensions ? this.minScrollExtent : null),
333 maxScrollExtent: maxScrollExtent ?? (hasContentDimensions ? this.maxScrollExtent : null),
334 pixels: pixels ?? (hasPixels ? this.pixels : null),
335 viewportDimension: viewportDimension ?? (hasViewportDimension ? this.viewportDimension : null),
336 axisDirection: axisDirection ?? this.axisDirection,
337 itemIndex: itemIndex ?? this.itemIndex,
338 devicePixelRatio: devicePixelRatio ?? this.devicePixelRatio,
339 );
340 }
341
342 /// The scroll view's currently selected item index.
343 final int itemIndex;
344}
345
346int _getItemFromOffset({
347 required double offset,
348 required double itemExtent,
349 required double minScrollExtent,
350 required double maxScrollExtent,
351}) {
352 return (_clipOffsetToScrollableRange(offset, minScrollExtent, maxScrollExtent) / itemExtent).round();
353}
354
355double _clipOffsetToScrollableRange(
356 double offset,
357 double minScrollExtent,
358 double maxScrollExtent,
359) {
360 return math.min(math.max(offset, minScrollExtent), maxScrollExtent);
361}
362
363/// A [ScrollPositionWithSingleContext] that can only be created based on
364/// [_FixedExtentScrollable] and can access its `itemExtent` to derive [itemIndex].
365class _FixedExtentScrollPosition extends ScrollPositionWithSingleContext implements FixedExtentMetrics {
366 _FixedExtentScrollPosition({
367 required super.physics,
368 required super.context,
369 required int initialItem,
370 super.oldPosition,
371 }) : assert(
372 context is _FixedExtentScrollableState,
373 'FixedExtentScrollController can only be used with ListWheelScrollViews',
374 ),
375 super(
376 initialPixels: _getItemExtentFromScrollContext(context) * initialItem,
377 );
378
379 static double _getItemExtentFromScrollContext(ScrollContext context) {
380 final _FixedExtentScrollableState scrollable = context as _FixedExtentScrollableState;
381 return scrollable.itemExtent;
382 }
383
384 double get itemExtent => _getItemExtentFromScrollContext(context);
385
386 @override
387 int get itemIndex {
388 return _getItemFromOffset(
389 offset: pixels,
390 itemExtent: itemExtent,
391 minScrollExtent: minScrollExtent,
392 maxScrollExtent: maxScrollExtent,
393 );
394 }
395
396 @override
397 FixedExtentMetrics copyWith({
398 double? minScrollExtent,
399 double? maxScrollExtent,
400 double? pixels,
401 double? viewportDimension,
402 AxisDirection? axisDirection,
403 int? itemIndex,
404 double? devicePixelRatio,
405 }) {
406 return FixedExtentMetrics(
407 minScrollExtent: minScrollExtent ?? (hasContentDimensions ? this.minScrollExtent : null),
408 maxScrollExtent: maxScrollExtent ?? (hasContentDimensions ? this.maxScrollExtent : null),
409 pixels: pixels ?? (hasPixels ? this.pixels : null),
410 viewportDimension: viewportDimension ?? (hasViewportDimension ? this.viewportDimension : null),
411 axisDirection: axisDirection ?? this.axisDirection,
412 itemIndex: itemIndex ?? this.itemIndex,
413 devicePixelRatio: devicePixelRatio ?? this.devicePixelRatio,
414 );
415 }
416}
417
418/// A [Scrollable] which must be given its viewport children's item extent
419/// size so it can pass it on ultimately to the [FixedExtentScrollController].
420class _FixedExtentScrollable extends Scrollable {
421 const _FixedExtentScrollable({
422 super.controller,
423 super.physics,
424 required this.itemExtent,
425 required super.viewportBuilder,
426 super.restorationId,
427 super.scrollBehavior,
428 });
429
430 final double itemExtent;
431
432 @override
433 _FixedExtentScrollableState createState() => _FixedExtentScrollableState();
434}
435
436/// This [ScrollContext] is used by [_FixedExtentScrollPosition] to read the
437/// prescribed [itemExtent].
438class _FixedExtentScrollableState extends ScrollableState {
439 double get itemExtent {
440 // Downcast because only _FixedExtentScrollable can make _FixedExtentScrollableState.
441 final _FixedExtentScrollable actualWidget = widget as _FixedExtentScrollable;
442 return actualWidget.itemExtent;
443 }
444}
445
446/// A snapping physics that always lands directly on items instead of anywhere
447/// within the scroll extent.
448///
449/// Behaves similarly to a slot machine wheel except the ballistics simulation
450/// never overshoots and rolls back within a single item if it's to settle on
451/// that item.
452///
453/// Must be used with a scrollable that uses a [FixedExtentScrollController].
454///
455/// Defers back to the parent beyond the scroll extents.
456class FixedExtentScrollPhysics extends ScrollPhysics {
457 /// Creates a scroll physics that always lands on items.
458 const FixedExtentScrollPhysics({ super.parent });
459
460 @override
461 FixedExtentScrollPhysics applyTo(ScrollPhysics? ancestor) {
462 return FixedExtentScrollPhysics(parent: buildParent(ancestor));
463 }
464
465 @override
466 Simulation? createBallisticSimulation(ScrollMetrics position, double velocity) {
467 assert(
468 position is _FixedExtentScrollPosition,
469 'FixedExtentScrollPhysics can only be used with Scrollables that uses '
470 'the FixedExtentScrollController',
471 );
472
473 final _FixedExtentScrollPosition metrics = position as _FixedExtentScrollPosition;
474
475 // Scenario 1:
476 // If we're out of range and not headed back in range, defer to the parent
477 // ballistics, which should put us back in range at the scrollable's boundary.
478 if ((velocity <= 0.0 && metrics.pixels <= metrics.minScrollExtent) ||
479 (velocity >= 0.0 && metrics.pixels >= metrics.maxScrollExtent)) {
480 return super.createBallisticSimulation(metrics, velocity);
481 }
482
483 // Create a test simulation to see where it would have ballistically fallen
484 // naturally without settling onto items.
485 final Simulation? testFrictionSimulation =
486 super.createBallisticSimulation(metrics, velocity);
487
488 // Scenario 2:
489 // If it was going to end up past the scroll extent, defer back to the
490 // parent physics' ballistics again which should put us on the scrollable's
491 // boundary.
492 if (testFrictionSimulation != null
493 && (testFrictionSimulation.x(double.infinity) == metrics.minScrollExtent
494 || testFrictionSimulation.x(double.infinity) == metrics.maxScrollExtent)) {
495 return super.createBallisticSimulation(metrics, velocity);
496 }
497
498 // From the natural final position, find the nearest item it should have
499 // settled to.
500 final int settlingItemIndex = _getItemFromOffset(
501 offset: testFrictionSimulation?.x(double.infinity) ?? metrics.pixels,
502 itemExtent: metrics.itemExtent,
503 minScrollExtent: metrics.minScrollExtent,
504 maxScrollExtent: metrics.maxScrollExtent,
505 );
506
507 final double settlingPixels = settlingItemIndex * metrics.itemExtent;
508
509 // Scenario 3:
510 // If there's no velocity and we're already at where we intend to land,
511 // do nothing.
512 if (velocity.abs() < toleranceFor(position).velocity
513 && (settlingPixels - metrics.pixels).abs() < toleranceFor(position).distance) {
514 return null;
515 }
516
517 // Scenario 4:
518 // If we're going to end back at the same item because initial velocity
519 // is too low to break past it, use a spring simulation to get back.
520 if (settlingItemIndex == metrics.itemIndex) {
521 return SpringSimulation(
522 spring,
523 metrics.pixels,
524 settlingPixels,
525 velocity,
526 tolerance: toleranceFor(position),
527 );
528 }
529
530 // Scenario 5:
531 // Create a new friction simulation except the drag will be tweaked to land
532 // exactly on the item closest to the natural stopping point.
533 return FrictionSimulation.through(
534 metrics.pixels,
535 settlingPixels,
536 velocity,
537 toleranceFor(position).velocity * velocity.sign,
538 );
539 }
540}
541
542/// A box in which children on a wheel can be scrolled.
543///
544/// This widget is similar to a [ListView] but with the restriction that all
545/// children must be the same size along the scrolling axis.
546///
547/// {@youtube 560 315 https://www.youtube.com/watch?v=dUhmWAz4C7Y}
548///
549/// When the list is at the zero scroll offset, the first child is aligned with
550/// the middle of the viewport. When the list is at the final scroll offset,
551/// the last child is aligned with the middle of the viewport.
552///
553/// The children are rendered as if rotating on a wheel instead of scrolling on
554/// a plane.
555class ListWheelScrollView extends StatefulWidget {
556 /// Constructs a list in which children are scrolled a wheel. Its children
557 /// are passed to a delegate and lazily built during layout.
558 ListWheelScrollView({
559 super.key,
560 this.controller,
561 this.physics,
562 this.diameterRatio = RenderListWheelViewport.defaultDiameterRatio,
563 this.perspective = RenderListWheelViewport.defaultPerspective,
564 this.offAxisFraction = 0.0,
565 this.useMagnifier = false,
566 this.magnification = 1.0,
567 this.overAndUnderCenterOpacity = 1.0,
568 required this.itemExtent,
569 this.squeeze = 1.0,
570 this.onSelectedItemChanged,
571 this.renderChildrenOutsideViewport = false,
572 this.clipBehavior = Clip.hardEdge,
573 this.restorationId,
574 this.scrollBehavior,
575 required List<Widget> children,
576 }) : assert(diameterRatio > 0.0, RenderListWheelViewport.diameterRatioZeroMessage),
577 assert(perspective > 0),
578 assert(perspective <= 0.01, RenderListWheelViewport.perspectiveTooHighMessage),
579 assert(magnification > 0),
580 assert(overAndUnderCenterOpacity >= 0 && overAndUnderCenterOpacity <= 1),
581 assert(itemExtent > 0),
582 assert(squeeze > 0),
583 assert(
584 !renderChildrenOutsideViewport || clipBehavior == Clip.none,
585 RenderListWheelViewport.clipBehaviorAndRenderChildrenOutsideViewportConflict,
586 ),
587 childDelegate = ListWheelChildListDelegate(children: children);
588
589 /// Constructs a list in which children are scrolled a wheel. Its children
590 /// are managed by a delegate and are lazily built during layout.
591 const ListWheelScrollView.useDelegate({
592 super.key,
593 this.controller,
594 this.physics,
595 this.diameterRatio = RenderListWheelViewport.defaultDiameterRatio,
596 this.perspective = RenderListWheelViewport.defaultPerspective,
597 this.offAxisFraction = 0.0,
598 this.useMagnifier = false,
599 this.magnification = 1.0,
600 this.overAndUnderCenterOpacity = 1.0,
601 required this.itemExtent,
602 this.squeeze = 1.0,
603 this.onSelectedItemChanged,
604 this.renderChildrenOutsideViewport = false,
605 this.clipBehavior = Clip.hardEdge,
606 this.restorationId,
607 this.scrollBehavior,
608 required this.childDelegate,
609 }) : assert(diameterRatio > 0.0, RenderListWheelViewport.diameterRatioZeroMessage),
610 assert(perspective > 0),
611 assert(perspective <= 0.01, RenderListWheelViewport.perspectiveTooHighMessage),
612 assert(magnification > 0),
613 assert(overAndUnderCenterOpacity >= 0 && overAndUnderCenterOpacity <= 1),
614 assert(itemExtent > 0),
615 assert(squeeze > 0),
616 assert(
617 !renderChildrenOutsideViewport || clipBehavior == Clip.none,
618 RenderListWheelViewport.clipBehaviorAndRenderChildrenOutsideViewportConflict,
619 );
620
621 /// Typically a [FixedExtentScrollController] used to control the current item.
622 ///
623 /// A [FixedExtentScrollController] can be used to read the currently
624 /// selected/centered child item and can be used to change the current item.
625 ///
626 /// If none is provided, a new [FixedExtentScrollController] is implicitly
627 /// created.
628 ///
629 /// If a [ScrollController] is used instead of [FixedExtentScrollController],
630 /// [ScrollNotification.metrics] will no longer provide [FixedExtentMetrics]
631 /// to indicate the current item index and [onSelectedItemChanged] will not
632 /// work.
633 ///
634 /// To read the current selected item only when the value changes, use
635 /// [onSelectedItemChanged].
636 final ScrollController? controller;
637
638 /// How the scroll view should respond to user input.
639 ///
640 /// For example, determines how the scroll view continues to animate after the
641 /// user stops dragging the scroll view.
642 ///
643 /// If an explicit [ScrollBehavior] is provided to [scrollBehavior], the
644 /// [ScrollPhysics] provided by that behavior will take precedence after
645 /// [physics].
646 ///
647 /// Defaults to matching platform conventions.
648 final ScrollPhysics? physics;
649
650 /// {@macro flutter.rendering.RenderListWheelViewport.diameterRatio}
651 final double diameterRatio;
652
653 /// {@macro flutter.rendering.RenderListWheelViewport.perspective}
654 final double perspective;
655
656 /// {@macro flutter.rendering.RenderListWheelViewport.offAxisFraction}
657 final double offAxisFraction;
658
659 /// {@macro flutter.rendering.RenderListWheelViewport.useMagnifier}
660 final bool useMagnifier;
661
662 /// {@macro flutter.rendering.RenderListWheelViewport.magnification}
663 final double magnification;
664
665 /// {@macro flutter.rendering.RenderListWheelViewport.overAndUnderCenterOpacity}
666 final double overAndUnderCenterOpacity;
667
668 /// Size of each child in the main axis.
669 ///
670 /// Must be positive.
671 final double itemExtent;
672
673 /// {@macro flutter.rendering.RenderListWheelViewport.squeeze}
674 ///
675 /// Defaults to 1.
676 final double squeeze;
677
678 /// On optional listener that's called when the centered item changes.
679 final ValueChanged<int>? onSelectedItemChanged;
680
681 /// {@macro flutter.rendering.RenderListWheelViewport.renderChildrenOutsideViewport}
682 final bool renderChildrenOutsideViewport;
683
684 /// A delegate that helps lazily instantiating child.
685 final ListWheelChildDelegate childDelegate;
686
687 /// {@macro flutter.material.Material.clipBehavior}
688 ///
689 /// Defaults to [Clip.hardEdge].
690 final Clip clipBehavior;
691
692 /// {@macro flutter.widgets.scrollable.restorationId}
693 final String? restorationId;
694
695 /// {@macro flutter.widgets.shadow.scrollBehavior}
696 ///
697 /// [ScrollBehavior]s also provide [ScrollPhysics]. If an explicit
698 /// [ScrollPhysics] is provided in [physics], it will take precedence,
699 /// followed by [scrollBehavior], and then the inherited ancestor
700 /// [ScrollBehavior].
701 ///
702 /// The [ScrollBehavior] of the inherited [ScrollConfiguration] will be
703 /// modified by default to not apply a [Scrollbar].
704 final ScrollBehavior? scrollBehavior;
705
706 @override
707 State<ListWheelScrollView> createState() => _ListWheelScrollViewState();
708}
709
710class _ListWheelScrollViewState extends State<ListWheelScrollView> {
711 int _lastReportedItemIndex = 0;
712 ScrollController? _backupController;
713
714 ScrollController get _effectiveController =>
715 widget.controller ?? (_backupController ??= FixedExtentScrollController());
716
717 @override
718 void initState() {
719 super.initState();
720 if (widget.controller is FixedExtentScrollController) {
721 final FixedExtentScrollController controller = widget.controller! as FixedExtentScrollController;
722 _lastReportedItemIndex = controller.initialItem;
723 }
724 }
725
726 @override
727 void dispose() {
728 _backupController?.dispose();
729 super.dispose();
730 }
731
732 bool _handleScrollNotification(ScrollNotification notification) {
733 if (notification.depth == 0
734 && widget.onSelectedItemChanged != null
735 && notification is ScrollUpdateNotification
736 && notification.metrics is FixedExtentMetrics) {
737 final FixedExtentMetrics metrics = notification.metrics as FixedExtentMetrics;
738 final int currentItemIndex = metrics.itemIndex;
739 if (currentItemIndex != _lastReportedItemIndex) {
740 _lastReportedItemIndex = currentItemIndex;
741 final int trueIndex = widget.childDelegate.trueIndexOf(currentItemIndex);
742 widget.onSelectedItemChanged!(trueIndex);
743 }
744 }
745 return false;
746 }
747
748 @override
749 Widget build(BuildContext context) {
750 return NotificationListener<ScrollNotification>(
751 onNotification: _handleScrollNotification,
752 child: _FixedExtentScrollable(
753 controller: _effectiveController,
754 physics: widget.physics,
755 itemExtent: widget.itemExtent,
756 restorationId: widget.restorationId,
757 scrollBehavior: widget.scrollBehavior ?? ScrollConfiguration.of(context).copyWith(scrollbars: false),
758 viewportBuilder: (BuildContext context, ViewportOffset offset) {
759 return ListWheelViewport(
760 diameterRatio: widget.diameterRatio,
761 perspective: widget.perspective,
762 offAxisFraction: widget.offAxisFraction,
763 useMagnifier: widget.useMagnifier,
764 magnification: widget.magnification,
765 overAndUnderCenterOpacity: widget.overAndUnderCenterOpacity,
766 itemExtent: widget.itemExtent,
767 squeeze: widget.squeeze,
768 renderChildrenOutsideViewport: widget.renderChildrenOutsideViewport,
769 offset: offset,
770 childDelegate: widget.childDelegate,
771 clipBehavior: widget.clipBehavior,
772 );
773 },
774 ),
775 );
776 }
777}
778
779/// Element that supports building children lazily for [ListWheelViewport].
780class ListWheelElement extends RenderObjectElement implements ListWheelChildManager {
781 /// Creates an element that lazily builds children for the given widget.
782 ListWheelElement(ListWheelViewport super.widget);
783
784 @override
785 RenderListWheelViewport get renderObject => super.renderObject as RenderListWheelViewport;
786
787 // We inflate widgets at two different times:
788 // 1. When we ourselves are told to rebuild (see performRebuild).
789 // 2. When our render object needs a new child (see createChild).
790 // In both cases, we cache the results of calling into our delegate to get the
791 // widget, so that if we do case 2 later, we don't call the builder again.
792 // Any time we do case 1, though, we reset the cache.
793
794 /// A cache of widgets so that we don't have to rebuild every time.
795 final Map<int, Widget?> _childWidgets = HashMap<int, Widget?>();
796
797 /// The map containing all active child elements. SplayTreeMap is used so that
798 /// we have all elements ordered and iterable by their keys.
799 final SplayTreeMap<int, Element> _childElements = SplayTreeMap<int, Element>();
800
801 @override
802 void update(ListWheelViewport newWidget) {
803 final ListWheelViewport oldWidget = widget as ListWheelViewport;
804 super.update(newWidget);
805 final ListWheelChildDelegate newDelegate = newWidget.childDelegate;
806 final ListWheelChildDelegate oldDelegate = oldWidget.childDelegate;
807 if (newDelegate != oldDelegate &&
808 (newDelegate.runtimeType != oldDelegate.runtimeType || newDelegate.shouldRebuild(oldDelegate))) {
809 performRebuild();
810 renderObject.markNeedsLayout();
811 }
812 }
813
814 @override
815 int? get childCount => (widget as ListWheelViewport).childDelegate.estimatedChildCount;
816
817 @override
818 void performRebuild() {
819 _childWidgets.clear();
820 super.performRebuild();
821 if (_childElements.isEmpty) {
822 return;
823 }
824
825 final int firstIndex = _childElements.firstKey()!;
826 final int lastIndex = _childElements.lastKey()!;
827
828 for (int index = firstIndex; index <= lastIndex; ++index) {
829 final Element? newChild = updateChild(_childElements[index], retrieveWidget(index), index);
830 if (newChild != null) {
831 _childElements[index] = newChild;
832 } else {
833 _childElements.remove(index);
834 }
835 }
836 }
837
838 /// Asks the underlying delegate for a widget at the given index.
839 ///
840 /// Normally the builder is only called once for each index and the result
841 /// will be cached. However when the element is rebuilt, the cache will be
842 /// cleared.
843 Widget? retrieveWidget(int index) {
844 return _childWidgets.putIfAbsent(index, () => (widget as ListWheelViewport).childDelegate.build(this, index));
845 }
846
847 @override
848 bool childExistsAt(int index) => retrieveWidget(index) != null;
849
850 @override
851 void createChild(int index, { required RenderBox? after }) {
852 owner!.buildScope(this, () {
853 final bool insertFirst = after == null;
854 assert(insertFirst || _childElements[index - 1] != null);
855 final Element? newChild =
856 updateChild(_childElements[index], retrieveWidget(index), index);
857 if (newChild != null) {
858 _childElements[index] = newChild;
859 } else {
860 _childElements.remove(index);
861 }
862 });
863 }
864
865 @override
866 void removeChild(RenderBox child) {
867 final int index = renderObject.indexOf(child);
868 owner!.buildScope(this, () {
869 assert(_childElements.containsKey(index));
870 final Element? result = updateChild(_childElements[index], null, index);
871 assert(result == null);
872 _childElements.remove(index);
873 assert(!_childElements.containsKey(index));
874 });
875 }
876
877 @override
878 Element? updateChild(Element? child, Widget? newWidget, Object? newSlot) {
879 final ListWheelParentData? oldParentData = child?.renderObject?.parentData as ListWheelParentData?;
880 final Element? newChild = super.updateChild(child, newWidget, newSlot);
881 final ListWheelParentData? newParentData = newChild?.renderObject?.parentData as ListWheelParentData?;
882 if (newParentData != null) {
883 newParentData.index = newSlot! as int;
884 if (oldParentData != null) {
885 newParentData.offset = oldParentData.offset;
886 }
887 }
888
889 return newChild;
890 }
891
892 @override
893 void insertRenderObjectChild(RenderObject child, int slot) {
894 final RenderListWheelViewport renderObject = this.renderObject;
895 assert(renderObject.debugValidateChild(child));
896 renderObject.insert(child as RenderBox, after: _childElements[slot - 1]?.renderObject as RenderBox?);
897 assert(renderObject == this.renderObject);
898 }
899
900 @override
901 void moveRenderObjectChild(RenderObject child, int oldSlot, int newSlot) {
902 const String moveChildRenderObjectErrorMessage =
903 'Currently we maintain the list in contiguous increasing order, so '
904 'moving children around is not allowed.';
905 assert(false, moveChildRenderObjectErrorMessage);
906 }
907
908 @override
909 void removeRenderObjectChild(RenderObject child, int slot) {
910 assert(child.parent == renderObject);
911 renderObject.remove(child as RenderBox);
912 }
913
914 @override
915 void visitChildren(ElementVisitor visitor) {
916 _childElements.forEach((int key, Element child) {
917 visitor(child);
918 });
919 }
920
921 @override
922 void forgetChild(Element child) {
923 _childElements.remove(child.slot);
924 super.forgetChild(child);
925 }
926
927}
928
929/// A viewport showing a subset of children on a wheel.
930///
931/// Typically used with [ListWheelScrollView], this viewport is similar to
932/// [Viewport] in that it shows a subset of children in a scrollable based
933/// on the scrolling offset and the children's dimensions. But uses
934/// [RenderListWheelViewport] to display the children on a wheel.
935///
936/// See also:
937///
938/// * [ListWheelScrollView], widget that combines this viewport with a scrollable.
939/// * [RenderListWheelViewport], the render object that renders the children
940/// on a wheel.
941class ListWheelViewport extends RenderObjectWidget {
942 /// Creates a viewport where children are rendered onto a wheel.
943 ///
944 /// The [diameterRatio] argument defaults to 2.
945 ///
946 /// The [perspective] argument defaults to 0.003.
947 ///
948 /// The [itemExtent] argument in pixels must be provided and must be positive.
949 ///
950 /// The [clipBehavior] argument defaults to [Clip.hardEdge].
951 ///
952 /// The [renderChildrenOutsideViewport] argument defaults to false and must
953 /// not be null.
954 ///
955 /// The [offset] argument must be provided.
956 const ListWheelViewport({
957 super.key,
958 this.diameterRatio = RenderListWheelViewport.defaultDiameterRatio,
959 this.perspective = RenderListWheelViewport.defaultPerspective,
960 this.offAxisFraction = 0.0,
961 this.useMagnifier = false,
962 this.magnification = 1.0,
963 this.overAndUnderCenterOpacity = 1.0,
964 required this.itemExtent,
965 this.squeeze = 1.0,
966 this.renderChildrenOutsideViewport = false,
967 required this.offset,
968 required this.childDelegate,
969 this.clipBehavior = Clip.hardEdge,
970 }) : assert(diameterRatio > 0, RenderListWheelViewport.diameterRatioZeroMessage),
971 assert(perspective > 0),
972 assert(perspective <= 0.01, RenderListWheelViewport.perspectiveTooHighMessage),
973 assert(overAndUnderCenterOpacity >= 0 && overAndUnderCenterOpacity <= 1),
974 assert(itemExtent > 0),
975 assert(squeeze > 0),
976 assert(
977 !renderChildrenOutsideViewport || clipBehavior == Clip.none,
978 RenderListWheelViewport.clipBehaviorAndRenderChildrenOutsideViewportConflict,
979 );
980
981 /// {@macro flutter.rendering.RenderListWheelViewport.diameterRatio}
982 final double diameterRatio;
983
984 /// {@macro flutter.rendering.RenderListWheelViewport.perspective}
985 final double perspective;
986
987 /// {@macro flutter.rendering.RenderListWheelViewport.offAxisFraction}
988 final double offAxisFraction;
989
990 /// {@macro flutter.rendering.RenderListWheelViewport.useMagnifier}
991 final bool useMagnifier;
992
993 /// {@macro flutter.rendering.RenderListWheelViewport.magnification}
994 final double magnification;
995
996 /// {@macro flutter.rendering.RenderListWheelViewport.overAndUnderCenterOpacity}
997 final double overAndUnderCenterOpacity;
998
999 /// {@macro flutter.rendering.RenderListWheelViewport.itemExtent}
1000 final double itemExtent;
1001
1002 /// {@macro flutter.rendering.RenderListWheelViewport.squeeze}
1003 ///
1004 /// Defaults to 1.
1005 final double squeeze;
1006
1007 /// {@macro flutter.rendering.RenderListWheelViewport.renderChildrenOutsideViewport}
1008 final bool renderChildrenOutsideViewport;
1009
1010 /// [ViewportOffset] object describing the content that should be visible
1011 /// in the viewport.
1012 final ViewportOffset offset;
1013
1014 /// A delegate that lazily instantiates children.
1015 final ListWheelChildDelegate childDelegate;
1016
1017 /// {@macro flutter.material.Material.clipBehavior}
1018 ///
1019 /// Defaults to [Clip.hardEdge].
1020 final Clip clipBehavior;
1021
1022 @override
1023 ListWheelElement createElement() => ListWheelElement(this);
1024
1025 @override
1026 RenderListWheelViewport createRenderObject(BuildContext context) {
1027 final ListWheelElement childManager = context as ListWheelElement;
1028 return RenderListWheelViewport(
1029 childManager: childManager,
1030 offset: offset,
1031 diameterRatio: diameterRatio,
1032 perspective: perspective,
1033 offAxisFraction: offAxisFraction,
1034 useMagnifier: useMagnifier,
1035 magnification: magnification,
1036 overAndUnderCenterOpacity: overAndUnderCenterOpacity,
1037 itemExtent: itemExtent,
1038 squeeze: squeeze,
1039 renderChildrenOutsideViewport: renderChildrenOutsideViewport,
1040 clipBehavior: clipBehavior,
1041 );
1042 }
1043
1044 @override
1045 void updateRenderObject(BuildContext context, RenderListWheelViewport renderObject) {
1046 renderObject
1047 ..offset = offset
1048 ..diameterRatio = diameterRatio
1049 ..perspective = perspective
1050 ..offAxisFraction = offAxisFraction
1051 ..useMagnifier = useMagnifier
1052 ..magnification = magnification
1053 ..overAndUnderCenterOpacity = overAndUnderCenterOpacity
1054 ..itemExtent = itemExtent
1055 ..squeeze = squeeze
1056 ..renderChildrenOutsideViewport = renderChildrenOutsideViewport
1057 ..clipBehavior = clipBehavior;
1058 }
1059}
1060