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'; |
9 | library; |
10 | |
11 | import 'dart:collection'; |
12 | import 'dart:math' as math; |
13 | |
14 | import 'package:flutter/gestures.dart'; |
15 | import 'package:flutter/physics.dart'; |
16 | import 'package:flutter/rendering.dart'; |
17 | |
18 | import 'basic.dart'; |
19 | import 'framework.dart'; |
20 | import 'notification_listener.dart'; |
21 | import 'scroll_configuration.dart'; |
22 | import 'scroll_context.dart'; |
23 | import 'scroll_controller.dart'; |
24 | import 'scroll_metrics.dart'; |
25 | import 'scroll_notification.dart'; |
26 | import 'scroll_physics.dart'; |
27 | import 'scroll_position.dart'; |
28 | import 'scroll_position_with_single_context.dart'; |
29 | import '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. |
46 | abstract 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. |
88 | class 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. |
132 | class 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. |
166 | class 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]. |
221 | class 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. |
315 | class 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 | |
353 | int _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 | |
362 | double _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]. |
372 | class _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]. |
427 | class _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]. |
447 | class _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. |
465 | class 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. |
564 | class 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 | |
731 | class _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]. |
803 | class 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. |
964 | class 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 | |