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