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