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