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:math' as math; |
6 | |
7 | import 'package:flutter/foundation.dart' show clampDouble, precisionErrorTolerance; |
8 | import 'package:flutter/gestures.dart' show DragStartBehavior; |
9 | import 'package:flutter/rendering.dart'; |
10 | |
11 | import 'basic.dart'; |
12 | import 'debug.dart'; |
13 | import 'framework.dart'; |
14 | import 'notification_listener.dart'; |
15 | import 'page_storage.dart'; |
16 | import 'scroll_configuration.dart'; |
17 | import 'scroll_context.dart'; |
18 | import 'scroll_controller.dart'; |
19 | import 'scroll_delegate.dart'; |
20 | import 'scroll_metrics.dart'; |
21 | import 'scroll_notification.dart'; |
22 | import 'scroll_physics.dart'; |
23 | import 'scroll_position.dart'; |
24 | import 'scroll_position_with_single_context.dart'; |
25 | import 'scroll_view.dart'; |
26 | import 'scrollable.dart'; |
27 | import 'sliver_fill.dart'; |
28 | import 'viewport.dart'; |
29 | |
30 | /// A controller for [PageView]. |
31 | /// |
32 | /// A page controller lets you manipulate which page is visible in a [PageView]. |
33 | /// In addition to being able to control the pixel offset of the content inside |
34 | /// the [PageView], a [PageController] also lets you control the offset in terms |
35 | /// of pages, which are increments of the viewport size. |
36 | /// |
37 | /// See also: |
38 | /// |
39 | /// * [PageView], which is the widget this object controls. |
40 | /// |
41 | /// {@tool snippet} |
42 | /// |
43 | /// This widget introduces a [MaterialApp], [Scaffold] and [PageView] with two pages |
44 | /// using the default constructor. Both pages contain an [ElevatedButton] allowing you |
45 | /// to animate the [PageView] using a [PageController]. |
46 | /// |
47 | /// ```dart |
48 | /// class MyPageView extends StatefulWidget { |
49 | /// const MyPageView({super.key}); |
50 | /// |
51 | /// @override |
52 | /// State<MyPageView> createState() => _MyPageViewState(); |
53 | /// } |
54 | /// |
55 | /// class _MyPageViewState extends State<MyPageView> { |
56 | /// final PageController _pageController = PageController(); |
57 | /// |
58 | /// @override |
59 | /// void dispose() { |
60 | /// _pageController.dispose(); |
61 | /// super.dispose(); |
62 | /// } |
63 | /// |
64 | /// @override |
65 | /// Widget build(BuildContext context) { |
66 | /// return MaterialApp( |
67 | /// home: Scaffold( |
68 | /// body: PageView( |
69 | /// controller: _pageController, |
70 | /// children: <Widget>[ |
71 | /// ColoredBox( |
72 | /// color: Colors.red, |
73 | /// child: Center( |
74 | /// child: ElevatedButton( |
75 | /// onPressed: () { |
76 | /// if (_pageController.hasClients) { |
77 | /// _pageController.animateToPage( |
78 | /// 1, |
79 | /// duration: const Duration(milliseconds: 400), |
80 | /// curve: Curves.easeInOut, |
81 | /// ); |
82 | /// } |
83 | /// }, |
84 | /// child: const Text('Next'), |
85 | /// ), |
86 | /// ), |
87 | /// ), |
88 | /// ColoredBox( |
89 | /// color: Colors.blue, |
90 | /// child: Center( |
91 | /// child: ElevatedButton( |
92 | /// onPressed: () { |
93 | /// if (_pageController.hasClients) { |
94 | /// _pageController.animateToPage( |
95 | /// 0, |
96 | /// duration: const Duration(milliseconds: 400), |
97 | /// curve: Curves.easeInOut, |
98 | /// ); |
99 | /// } |
100 | /// }, |
101 | /// child: const Text('Previous'), |
102 | /// ), |
103 | /// ), |
104 | /// ), |
105 | /// ], |
106 | /// ), |
107 | /// ), |
108 | /// ); |
109 | /// } |
110 | /// } |
111 | /// ``` |
112 | /// {@end-tool} |
113 | class PageController extends ScrollController { |
114 | /// Creates a page controller. |
115 | PageController({ |
116 | this.initialPage = 0, |
117 | this.keepPage = true, |
118 | this.viewportFraction = 1.0, |
119 | super.onAttach, |
120 | super.onDetach, |
121 | }) : assert(viewportFraction > 0.0); |
122 | |
123 | /// The page to show when first creating the [PageView]. |
124 | final int initialPage; |
125 | |
126 | /// Save the current [page] with [PageStorage] and restore it if |
127 | /// this controller's scrollable is recreated. |
128 | /// |
129 | /// If this property is set to false, the current [page] is never saved |
130 | /// and [initialPage] is always used to initialize the scroll offset. |
131 | /// If true (the default), the initial page is used the first time the |
132 | /// controller's scrollable is created, since there's isn't a page to |
133 | /// restore yet. Subsequently the saved page is restored and |
134 | /// [initialPage] is ignored. |
135 | /// |
136 | /// See also: |
137 | /// |
138 | /// * [PageStorageKey], which should be used when more than one |
139 | /// scrollable appears in the same route, to distinguish the [PageStorage] |
140 | /// locations used to save scroll offsets. |
141 | final bool keepPage; |
142 | |
143 | /// {@template flutter.widgets.pageview.viewportFraction} |
144 | /// The fraction of the viewport that each page should occupy. |
145 | /// |
146 | /// Defaults to 1.0, which means each page fills the viewport in the scrolling |
147 | /// direction. |
148 | /// {@endtemplate} |
149 | final double viewportFraction; |
150 | |
151 | /// The current page displayed in the controlled [PageView]. |
152 | /// |
153 | /// There are circumstances that this [PageController] can't know the current |
154 | /// page. Reading [page] will throw an [AssertionError] in the following cases: |
155 | /// |
156 | /// 1. No [PageView] is currently using this [PageController]. Once a |
157 | /// [PageView] starts using this [PageController], the new [page] |
158 | /// position will be derived: |
159 | /// |
160 | /// * First, based on the attached [PageView]'s [BuildContext] and the |
161 | /// position saved at that context's [PageStorage] if [keepPage] is true. |
162 | /// * Second, from the [PageController]'s [initialPage]. |
163 | /// |
164 | /// 2. More than one [PageView] using the same [PageController]. |
165 | /// |
166 | /// The [hasClients] property can be used to check if a [PageView] is attached |
167 | /// prior to accessing [page]. |
168 | double? get page { |
169 | assert( |
170 | positions.isNotEmpty, |
171 | 'PageController.page cannot be accessed before a PageView is built with it.' , |
172 | ); |
173 | assert( |
174 | positions.length == 1, |
175 | 'The page property cannot be read when multiple PageViews are attached to ' |
176 | 'the same PageController.' , |
177 | ); |
178 | final _PagePosition position = this.position as _PagePosition; |
179 | return position.page; |
180 | } |
181 | |
182 | /// Animates the controlled [PageView] from the current page to the given page. |
183 | /// |
184 | /// The animation lasts for the given duration and follows the given curve. |
185 | /// The returned [Future] resolves when the animation completes. |
186 | Future<void> animateToPage( |
187 | int page, { |
188 | required Duration duration, |
189 | required Curve curve, |
190 | }) { |
191 | final _PagePosition position = this.position as _PagePosition; |
192 | if (position._cachedPage != null) { |
193 | position._cachedPage = page.toDouble(); |
194 | return Future<void>.value(); |
195 | } |
196 | |
197 | return position.animateTo( |
198 | position.getPixelsFromPage(page.toDouble()), |
199 | duration: duration, |
200 | curve: curve, |
201 | ); |
202 | } |
203 | |
204 | /// Changes which page is displayed in the controlled [PageView]. |
205 | /// |
206 | /// Jumps the page position from its current value to the given value, |
207 | /// without animation, and without checking if the new value is in range. |
208 | void jumpToPage(int page) { |
209 | final _PagePosition position = this.position as _PagePosition; |
210 | if (position._cachedPage != null) { |
211 | position._cachedPage = page.toDouble(); |
212 | return; |
213 | } |
214 | |
215 | position.jumpTo(position.getPixelsFromPage(page.toDouble())); |
216 | } |
217 | |
218 | /// Animates the controlled [PageView] to the next page. |
219 | /// |
220 | /// The animation lasts for the given duration and follows the given curve. |
221 | /// The returned [Future] resolves when the animation completes. |
222 | Future<void> nextPage({ required Duration duration, required Curve curve }) { |
223 | return animateToPage(page!.round() + 1, duration: duration, curve: curve); |
224 | } |
225 | |
226 | /// Animates the controlled [PageView] to the previous page. |
227 | /// |
228 | /// The animation lasts for the given duration and follows the given curve. |
229 | /// The returned [Future] resolves when the animation completes. |
230 | Future<void> previousPage({ required Duration duration, required Curve curve }) { |
231 | return animateToPage(page!.round() - 1, duration: duration, curve: curve); |
232 | } |
233 | |
234 | @override |
235 | ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition? oldPosition) { |
236 | return _PagePosition( |
237 | physics: physics, |
238 | context: context, |
239 | initialPage: initialPage, |
240 | keepPage: keepPage, |
241 | viewportFraction: viewportFraction, |
242 | oldPosition: oldPosition, |
243 | ); |
244 | } |
245 | |
246 | @override |
247 | void attach(ScrollPosition position) { |
248 | super.attach(position); |
249 | final _PagePosition pagePosition = position as _PagePosition; |
250 | pagePosition.viewportFraction = viewportFraction; |
251 | } |
252 | } |
253 | |
254 | /// Metrics for a [PageView]. |
255 | /// |
256 | /// The metrics are available on [ScrollNotification]s generated from |
257 | /// [PageView]s. |
258 | class PageMetrics extends FixedScrollMetrics { |
259 | /// Creates an immutable snapshot of values associated with a [PageView]. |
260 | PageMetrics({ |
261 | required super.minScrollExtent, |
262 | required super.maxScrollExtent, |
263 | required super.pixels, |
264 | required super.viewportDimension, |
265 | required super.axisDirection, |
266 | required this.viewportFraction, |
267 | required super.devicePixelRatio, |
268 | }); |
269 | |
270 | @override |
271 | PageMetrics copyWith({ |
272 | double? minScrollExtent, |
273 | double? maxScrollExtent, |
274 | double? pixels, |
275 | double? viewportDimension, |
276 | AxisDirection? axisDirection, |
277 | double? viewportFraction, |
278 | double? devicePixelRatio, |
279 | }) { |
280 | return PageMetrics( |
281 | minScrollExtent: minScrollExtent ?? (hasContentDimensions ? this.minScrollExtent : null), |
282 | maxScrollExtent: maxScrollExtent ?? (hasContentDimensions ? this.maxScrollExtent : null), |
283 | pixels: pixels ?? (hasPixels ? this.pixels : null), |
284 | viewportDimension: viewportDimension ?? (hasViewportDimension ? this.viewportDimension : null), |
285 | axisDirection: axisDirection ?? this.axisDirection, |
286 | viewportFraction: viewportFraction ?? this.viewportFraction, |
287 | devicePixelRatio: devicePixelRatio ?? this.devicePixelRatio, |
288 | ); |
289 | } |
290 | |
291 | /// The current page displayed in the [PageView]. |
292 | double? get page { |
293 | return math.max(0.0, clampDouble(pixels, minScrollExtent, maxScrollExtent)) / |
294 | math.max(1.0, viewportDimension * viewportFraction); |
295 | } |
296 | |
297 | /// The fraction of the viewport that each page occupies. |
298 | /// |
299 | /// Used to compute [page] from the current [pixels]. |
300 | final double viewportFraction; |
301 | } |
302 | |
303 | class _PagePosition extends ScrollPositionWithSingleContext implements PageMetrics { |
304 | _PagePosition({ |
305 | required super.physics, |
306 | required super.context, |
307 | this.initialPage = 0, |
308 | bool keepPage = true, |
309 | double viewportFraction = 1.0, |
310 | super.oldPosition, |
311 | }) : assert(viewportFraction > 0.0), |
312 | _viewportFraction = viewportFraction, |
313 | _pageToUseOnStartup = initialPage.toDouble(), |
314 | super( |
315 | initialPixels: null, |
316 | keepScrollOffset: keepPage, |
317 | ); |
318 | |
319 | final int initialPage; |
320 | double _pageToUseOnStartup; |
321 | // When the viewport has a zero-size, the `page` can not |
322 | // be retrieved by `getPageFromPixels`, so we need to cache the page |
323 | // for use when resizing the viewport to non-zero next time. |
324 | double? _cachedPage; |
325 | |
326 | @override |
327 | Future<void> ensureVisible( |
328 | RenderObject object, { |
329 | double alignment = 0.0, |
330 | Duration duration = Duration.zero, |
331 | Curve curve = Curves.ease, |
332 | ScrollPositionAlignmentPolicy alignmentPolicy = ScrollPositionAlignmentPolicy.explicit, |
333 | RenderObject? targetRenderObject, |
334 | }) { |
335 | // Since the _PagePosition is intended to cover the available space within |
336 | // its viewport, stop trying to move the target render object to the center |
337 | // - otherwise, could end up changing which page is visible and moving the |
338 | // targetRenderObject out of the viewport. |
339 | return super.ensureVisible( |
340 | object, |
341 | alignment: alignment, |
342 | duration: duration, |
343 | curve: curve, |
344 | alignmentPolicy: alignmentPolicy, |
345 | ); |
346 | } |
347 | |
348 | @override |
349 | double get viewportFraction => _viewportFraction; |
350 | double _viewportFraction; |
351 | set viewportFraction(double value) { |
352 | if (_viewportFraction == value) { |
353 | return; |
354 | } |
355 | final double? oldPage = page; |
356 | _viewportFraction = value; |
357 | if (oldPage != null) { |
358 | forcePixels(getPixelsFromPage(oldPage)); |
359 | } |
360 | } |
361 | |
362 | // The amount of offset that will be added to [minScrollExtent] and subtracted |
363 | // from [maxScrollExtent], such that every page will properly snap to the center |
364 | // of the viewport when viewportFraction is greater than 1. |
365 | // |
366 | // The value is 0 if viewportFraction is less than or equal to 1, larger than 0 |
367 | // otherwise. |
368 | double get _initialPageOffset => math.max(0, viewportDimension * (viewportFraction - 1) / 2); |
369 | |
370 | double getPageFromPixels(double pixels, double viewportDimension) { |
371 | assert(viewportDimension > 0.0); |
372 | final double actual = math.max(0.0, pixels - _initialPageOffset) / (viewportDimension * viewportFraction); |
373 | final double round = actual.roundToDouble(); |
374 | if ((actual - round).abs() < precisionErrorTolerance) { |
375 | return round; |
376 | } |
377 | return actual; |
378 | } |
379 | |
380 | double getPixelsFromPage(double page) { |
381 | return page * viewportDimension * viewportFraction + _initialPageOffset; |
382 | } |
383 | |
384 | @override |
385 | double? get page { |
386 | assert( |
387 | !hasPixels || hasContentDimensions, |
388 | 'Page value is only available after content dimensions are established.' , |
389 | ); |
390 | return !hasPixels || !hasContentDimensions |
391 | ? null |
392 | : _cachedPage ?? getPageFromPixels(clampDouble(pixels, minScrollExtent, maxScrollExtent), viewportDimension); |
393 | } |
394 | |
395 | @override |
396 | void saveScrollOffset() { |
397 | PageStorage.maybeOf(context.storageContext)?.writeState(context.storageContext, _cachedPage ?? getPageFromPixels(pixels, viewportDimension)); |
398 | } |
399 | |
400 | @override |
401 | void restoreScrollOffset() { |
402 | if (!hasPixels) { |
403 | final double? value = PageStorage.maybeOf(context.storageContext)?.readState(context.storageContext) as double?; |
404 | if (value != null) { |
405 | _pageToUseOnStartup = value; |
406 | } |
407 | } |
408 | } |
409 | |
410 | @override |
411 | void saveOffset() { |
412 | context.saveOffset(_cachedPage ?? getPageFromPixels(pixels, viewportDimension)); |
413 | } |
414 | |
415 | @override |
416 | void restoreOffset(double offset, {bool initialRestore = false}) { |
417 | if (initialRestore) { |
418 | _pageToUseOnStartup = offset; |
419 | } else { |
420 | jumpTo(getPixelsFromPage(offset)); |
421 | } |
422 | } |
423 | |
424 | @override |
425 | bool applyViewportDimension(double viewportDimension) { |
426 | final double? oldViewportDimensions = hasViewportDimension ? this.viewportDimension : null; |
427 | if (viewportDimension == oldViewportDimensions) { |
428 | return true; |
429 | } |
430 | final bool result = super.applyViewportDimension(viewportDimension); |
431 | final double? oldPixels = hasPixels ? pixels : null; |
432 | double page; |
433 | if (oldPixels == null) { |
434 | page = _pageToUseOnStartup; |
435 | } else if (oldViewportDimensions == 0.0) { |
436 | // If resize from zero, we should use the _cachedPage to recover the state. |
437 | page = _cachedPage!; |
438 | } else { |
439 | page = getPageFromPixels(oldPixels, oldViewportDimensions!); |
440 | } |
441 | final double newPixels = getPixelsFromPage(page); |
442 | |
443 | // If the viewportDimension is zero, cache the page |
444 | // in case the viewport is resized to be non-zero. |
445 | _cachedPage = (viewportDimension == 0.0) ? page : null; |
446 | |
447 | if (newPixels != oldPixels) { |
448 | correctPixels(newPixels); |
449 | return false; |
450 | } |
451 | return result; |
452 | } |
453 | |
454 | @override |
455 | void absorb(ScrollPosition other) { |
456 | super.absorb(other); |
457 | assert(_cachedPage == null); |
458 | |
459 | if (other is! _PagePosition) { |
460 | return; |
461 | } |
462 | |
463 | if (other._cachedPage != null) { |
464 | _cachedPage = other._cachedPage; |
465 | } |
466 | } |
467 | |
468 | @override |
469 | bool applyContentDimensions(double minScrollExtent, double maxScrollExtent) { |
470 | final double newMinScrollExtent = minScrollExtent + _initialPageOffset; |
471 | return super.applyContentDimensions( |
472 | newMinScrollExtent, |
473 | math.max(newMinScrollExtent, maxScrollExtent - _initialPageOffset), |
474 | ); |
475 | } |
476 | |
477 | @override |
478 | PageMetrics copyWith({ |
479 | double? minScrollExtent, |
480 | double? maxScrollExtent, |
481 | double? pixels, |
482 | double? viewportDimension, |
483 | AxisDirection? axisDirection, |
484 | double? viewportFraction, |
485 | double? devicePixelRatio, |
486 | }) { |
487 | return PageMetrics( |
488 | minScrollExtent: minScrollExtent ?? (hasContentDimensions ? this.minScrollExtent : null), |
489 | maxScrollExtent: maxScrollExtent ?? (hasContentDimensions ? this.maxScrollExtent : null), |
490 | pixels: pixels ?? (hasPixels ? this.pixels : null), |
491 | viewportDimension: viewportDimension ?? (hasViewportDimension ? this.viewportDimension : null), |
492 | axisDirection: axisDirection ?? this.axisDirection, |
493 | viewportFraction: viewportFraction ?? this.viewportFraction, |
494 | devicePixelRatio: devicePixelRatio ?? this.devicePixelRatio, |
495 | ); |
496 | } |
497 | } |
498 | |
499 | class _ForceImplicitScrollPhysics extends ScrollPhysics { |
500 | const _ForceImplicitScrollPhysics({ |
501 | required this.allowImplicitScrolling, |
502 | super.parent, |
503 | }); |
504 | |
505 | @override |
506 | _ForceImplicitScrollPhysics applyTo(ScrollPhysics? ancestor) { |
507 | return _ForceImplicitScrollPhysics( |
508 | allowImplicitScrolling: allowImplicitScrolling, |
509 | parent: buildParent(ancestor), |
510 | ); |
511 | } |
512 | |
513 | @override |
514 | final bool allowImplicitScrolling; |
515 | } |
516 | |
517 | /// Scroll physics used by a [PageView]. |
518 | /// |
519 | /// These physics cause the page view to snap to page boundaries. |
520 | /// |
521 | /// See also: |
522 | /// |
523 | /// * [ScrollPhysics], the base class which defines the API for scrolling |
524 | /// physics. |
525 | /// * [PageView.physics], which can override the physics used by a page view. |
526 | class PageScrollPhysics extends ScrollPhysics { |
527 | /// Creates physics for a [PageView]. |
528 | const PageScrollPhysics({ super.parent }); |
529 | |
530 | @override |
531 | PageScrollPhysics applyTo(ScrollPhysics? ancestor) { |
532 | return PageScrollPhysics(parent: buildParent(ancestor)); |
533 | } |
534 | |
535 | double _getPage(ScrollMetrics position) { |
536 | if (position is _PagePosition) { |
537 | return position.page!; |
538 | } |
539 | return position.pixels / position.viewportDimension; |
540 | } |
541 | |
542 | double _getPixels(ScrollMetrics position, double page) { |
543 | if (position is _PagePosition) { |
544 | return position.getPixelsFromPage(page); |
545 | } |
546 | return page * position.viewportDimension; |
547 | } |
548 | |
549 | double _getTargetPixels(ScrollMetrics position, Tolerance tolerance, double velocity) { |
550 | double page = _getPage(position); |
551 | if (velocity < -tolerance.velocity) { |
552 | page -= 0.5; |
553 | } else if (velocity > tolerance.velocity) { |
554 | page += 0.5; |
555 | } |
556 | return _getPixels(position, page.roundToDouble()); |
557 | } |
558 | |
559 | @override |
560 | Simulation? createBallisticSimulation(ScrollMetrics position, double velocity) { |
561 | // If we're out of range and not headed back in range, defer to the parent |
562 | // ballistics, which should put us back in range at a page boundary. |
563 | if ((velocity <= 0.0 && position.pixels <= position.minScrollExtent) || |
564 | (velocity >= 0.0 && position.pixels >= position.maxScrollExtent)) { |
565 | return super.createBallisticSimulation(position, velocity); |
566 | } |
567 | final Tolerance tolerance = toleranceFor(position); |
568 | final double target = _getTargetPixels(position, tolerance, velocity); |
569 | if (target != position.pixels) { |
570 | return ScrollSpringSimulation(spring, position.pixels, target, velocity, tolerance: tolerance); |
571 | } |
572 | return null; |
573 | } |
574 | |
575 | @override |
576 | bool get allowImplicitScrolling => false; |
577 | } |
578 | |
579 | // Having this global (mutable) page controller is a bit of a hack. We need it |
580 | // to plumb in the factory for _PagePosition, but it will end up accumulating |
581 | // a large list of scroll positions. As long as you don't try to actually |
582 | // control the scroll positions, everything should be fine. |
583 | final PageController _defaultPageController = PageController(); |
584 | const PageScrollPhysics _kPagePhysics = PageScrollPhysics(); |
585 | |
586 | /// A scrollable list that works page by page. |
587 | /// |
588 | /// Each child of a page view is forced to be the same size as the viewport. |
589 | /// |
590 | /// You can use a [PageController] to control which page is visible in the view. |
591 | /// In addition to being able to control the pixel offset of the content inside |
592 | /// the [PageView], a [PageController] also lets you control the offset in terms |
593 | /// of pages, which are increments of the viewport size. |
594 | /// |
595 | /// The [PageController] can also be used to control the |
596 | /// [PageController.initialPage], which determines which page is shown when the |
597 | /// [PageView] is first constructed, and the [PageController.viewportFraction], |
598 | /// which determines the size of the pages as a fraction of the viewport size. |
599 | /// |
600 | /// {@youtube 560 315 https://www.youtube.com/watch?v=J1gE9xvph-A} |
601 | /// |
602 | /// {@tool dartpad} |
603 | /// Here is an example of [PageView]. It creates a centered [Text] in each of the three pages |
604 | /// which scroll horizontally. |
605 | /// |
606 | /// ** See code in examples/api/lib/widgets/page_view/page_view.0.dart ** |
607 | /// {@end-tool} |
608 | /// |
609 | /// ## Persisting the scroll position during a session |
610 | /// |
611 | /// Scroll views attempt to persist their scroll position using [PageStorage]. |
612 | /// For a [PageView], this can be disabled by setting [PageController.keepPage] |
613 | /// to false on the [controller]. If it is enabled, using a [PageStorageKey] for |
614 | /// the [key] of this widget is recommended to help disambiguate different |
615 | /// scroll views from each other. |
616 | /// |
617 | /// See also: |
618 | /// |
619 | /// * [PageController], which controls which page is visible in the view. |
620 | /// * [SingleChildScrollView], when you need to make a single child scrollable. |
621 | /// * [ListView], for a scrollable list of boxes. |
622 | /// * [GridView], for a scrollable grid of boxes. |
623 | /// * [ScrollNotification] and [NotificationListener], which can be used to watch |
624 | /// the scroll position without using a [ScrollController]. |
625 | class PageView extends StatefulWidget { |
626 | /// Creates a scrollable list that works page by page from an explicit [List] |
627 | /// of widgets. |
628 | /// |
629 | /// This constructor is appropriate for page views with a small number of |
630 | /// children because constructing the [List] requires doing work for every |
631 | /// child that could possibly be displayed in the page view, instead of just |
632 | /// those children that are actually visible. |
633 | /// |
634 | /// Like other widgets in the framework, this widget expects that |
635 | /// the [children] list will not be mutated after it has been passed in here. |
636 | /// See the documentation at [SliverChildListDelegate.children] for more details. |
637 | /// |
638 | /// {@template flutter.widgets.PageView.allowImplicitScrolling} |
639 | /// If [allowImplicitScrolling] is true, the [PageView] will participate in |
640 | /// accessibility scrolling more like a [ListView], where implicit scroll |
641 | /// actions will move to the next page rather than into the contents of the |
642 | /// [PageView]. |
643 | /// {@endtemplate} |
644 | PageView({ |
645 | super.key, |
646 | this.scrollDirection = Axis.horizontal, |
647 | this.reverse = false, |
648 | PageController? controller, |
649 | this.physics, |
650 | this.pageSnapping = true, |
651 | this.onPageChanged, |
652 | List<Widget> children = const <Widget>[], |
653 | this.dragStartBehavior = DragStartBehavior.start, |
654 | this.allowImplicitScrolling = false, |
655 | this.restorationId, |
656 | this.clipBehavior = Clip.hardEdge, |
657 | this.scrollBehavior, |
658 | this.padEnds = true, |
659 | }) : controller = controller ?? _defaultPageController, |
660 | childrenDelegate = SliverChildListDelegate(children); |
661 | |
662 | /// Creates a scrollable list that works page by page using widgets that are |
663 | /// created on demand. |
664 | /// |
665 | /// This constructor is appropriate for page views with a large (or infinite) |
666 | /// number of children because the builder is called only for those children |
667 | /// that are actually visible. |
668 | /// |
669 | /// Providing a non-null [itemCount] lets the [PageView] compute the maximum |
670 | /// scroll extent. |
671 | /// |
672 | /// [itemBuilder] will be called only with indices greater than or equal to |
673 | /// zero and less than [itemCount]. |
674 | /// |
675 | /// {@macro flutter.widgets.ListView.builder.itemBuilder} |
676 | /// |
677 | /// {@template flutter.widgets.PageView.findChildIndexCallback} |
678 | /// The [findChildIndexCallback] corresponds to the |
679 | /// [SliverChildBuilderDelegate.findChildIndexCallback] property. If null, |
680 | /// a child widget may not map to its existing [RenderObject] when the order |
681 | /// of children returned from the children builder changes. |
682 | /// This may result in state-loss. This callback needs to be implemented if |
683 | /// the order of the children may change at a later time. |
684 | /// {@endtemplate} |
685 | /// |
686 | /// {@macro flutter.widgets.PageView.allowImplicitScrolling} |
687 | PageView.builder({ |
688 | super.key, |
689 | this.scrollDirection = Axis.horizontal, |
690 | this.reverse = false, |
691 | PageController? controller, |
692 | this.physics, |
693 | this.pageSnapping = true, |
694 | this.onPageChanged, |
695 | required NullableIndexedWidgetBuilder itemBuilder, |
696 | ChildIndexGetter? findChildIndexCallback, |
697 | int? itemCount, |
698 | this.dragStartBehavior = DragStartBehavior.start, |
699 | this.allowImplicitScrolling = false, |
700 | this.restorationId, |
701 | this.clipBehavior = Clip.hardEdge, |
702 | this.scrollBehavior, |
703 | this.padEnds = true, |
704 | }) : controller = controller ?? _defaultPageController, |
705 | childrenDelegate = SliverChildBuilderDelegate( |
706 | itemBuilder, |
707 | findChildIndexCallback: findChildIndexCallback, |
708 | childCount: itemCount, |
709 | ); |
710 | |
711 | /// Creates a scrollable list that works page by page with a custom child |
712 | /// model. |
713 | /// |
714 | /// {@tool dartpad} |
715 | /// This example shows a [PageView] that uses a custom [SliverChildBuilderDelegate] to support child |
716 | /// reordering. |
717 | /// |
718 | /// ** See code in examples/api/lib/widgets/page_view/page_view.1.dart ** |
719 | /// {@end-tool} |
720 | /// |
721 | /// {@macro flutter.widgets.PageView.allowImplicitScrolling} |
722 | PageView.custom({ |
723 | super.key, |
724 | this.scrollDirection = Axis.horizontal, |
725 | this.reverse = false, |
726 | PageController? controller, |
727 | this.physics, |
728 | this.pageSnapping = true, |
729 | this.onPageChanged, |
730 | required this.childrenDelegate, |
731 | this.dragStartBehavior = DragStartBehavior.start, |
732 | this.allowImplicitScrolling = false, |
733 | this.restorationId, |
734 | this.clipBehavior = Clip.hardEdge, |
735 | this.scrollBehavior, |
736 | this.padEnds = true, |
737 | }) : controller = controller ?? _defaultPageController; |
738 | |
739 | /// Controls whether the widget's pages will respond to |
740 | /// [RenderObject.showOnScreen], which will allow for implicit accessibility |
741 | /// scrolling. |
742 | /// |
743 | /// With this flag set to false, when accessibility focus reaches the end of |
744 | /// the current page and the user attempts to move it to the next element, the |
745 | /// focus will traverse to the next widget outside of the page view. |
746 | /// |
747 | /// With this flag set to true, when accessibility focus reaches the end of |
748 | /// the current page and user attempts to move it to the next element, focus |
749 | /// will traverse to the next page in the page view. |
750 | final bool allowImplicitScrolling; |
751 | |
752 | /// {@macro flutter.widgets.scrollable.restorationId} |
753 | final String? restorationId; |
754 | |
755 | /// The [Axis] along which the scroll view's offset increases with each page. |
756 | /// |
757 | /// For the direction in which active scrolling may be occurring, see |
758 | /// [ScrollDirection]. |
759 | /// |
760 | /// Defaults to [Axis.horizontal]. |
761 | final Axis scrollDirection; |
762 | |
763 | /// Whether the page view scrolls in the reading direction. |
764 | /// |
765 | /// For example, if the reading direction is left-to-right and |
766 | /// [scrollDirection] is [Axis.horizontal], then the page view scrolls from |
767 | /// left to right when [reverse] is false and from right to left when |
768 | /// [reverse] is true. |
769 | /// |
770 | /// Similarly, if [scrollDirection] is [Axis.vertical], then the page view |
771 | /// scrolls from top to bottom when [reverse] is false and from bottom to top |
772 | /// when [reverse] is true. |
773 | /// |
774 | /// Defaults to false. |
775 | final bool reverse; |
776 | |
777 | /// An object that can be used to control the position to which this page |
778 | /// view is scrolled. |
779 | final PageController controller; |
780 | |
781 | /// How the page view should respond to user input. |
782 | /// |
783 | /// For example, determines how the page view continues to animate after the |
784 | /// user stops dragging the page view. |
785 | /// |
786 | /// The physics are modified to snap to page boundaries using |
787 | /// [PageScrollPhysics] prior to being used. |
788 | /// |
789 | /// If an explicit [ScrollBehavior] is provided to [scrollBehavior], the |
790 | /// [ScrollPhysics] provided by that behavior will take precedence after |
791 | /// [physics]. |
792 | /// |
793 | /// Defaults to matching platform conventions. |
794 | final ScrollPhysics? physics; |
795 | |
796 | /// Set to false to disable page snapping, useful for custom scroll behavior. |
797 | /// |
798 | /// If the [padEnds] is false and [PageController.viewportFraction] < 1.0, |
799 | /// the page will snap to the beginning of the viewport; otherwise, the page |
800 | /// will snap to the center of the viewport. |
801 | final bool pageSnapping; |
802 | |
803 | /// Called whenever the page in the center of the viewport changes. |
804 | final ValueChanged<int>? onPageChanged; |
805 | |
806 | /// A delegate that provides the children for the [PageView]. |
807 | /// |
808 | /// The [PageView.custom] constructor lets you specify this delegate |
809 | /// explicitly. The [PageView] and [PageView.builder] constructors create a |
810 | /// [childrenDelegate] that wraps the given [List] and [IndexedWidgetBuilder], |
811 | /// respectively. |
812 | final SliverChildDelegate childrenDelegate; |
813 | |
814 | /// {@macro flutter.widgets.scrollable.dragStartBehavior} |
815 | final DragStartBehavior dragStartBehavior; |
816 | |
817 | /// {@macro flutter.material.Material.clipBehavior} |
818 | /// |
819 | /// Defaults to [Clip.hardEdge]. |
820 | final Clip clipBehavior; |
821 | |
822 | /// {@macro flutter.widgets.shadow.scrollBehavior} |
823 | /// |
824 | /// [ScrollBehavior]s also provide [ScrollPhysics]. If an explicit |
825 | /// [ScrollPhysics] is provided in [physics], it will take precedence, |
826 | /// followed by [scrollBehavior], and then the inherited ancestor |
827 | /// [ScrollBehavior]. |
828 | /// |
829 | /// The [ScrollBehavior] of the inherited [ScrollConfiguration] will be |
830 | /// modified by default to not apply a [Scrollbar]. |
831 | final ScrollBehavior? scrollBehavior; |
832 | |
833 | /// Whether to add padding to both ends of the list. |
834 | /// |
835 | /// If this is set to true and [PageController.viewportFraction] < 1.0, padding will be added |
836 | /// such that the first and last child slivers will be in the center of |
837 | /// the viewport when scrolled all the way to the start or end, respectively. |
838 | /// |
839 | /// If [PageController.viewportFraction] >= 1.0, this property has no effect. |
840 | /// |
841 | /// This property defaults to true. |
842 | final bool padEnds; |
843 | |
844 | @override |
845 | State<PageView> createState() => _PageViewState(); |
846 | } |
847 | |
848 | class _PageViewState extends State<PageView> { |
849 | int _lastReportedPage = 0; |
850 | |
851 | @override |
852 | void initState() { |
853 | super.initState(); |
854 | _lastReportedPage = widget.controller.initialPage; |
855 | } |
856 | |
857 | AxisDirection _getDirection(BuildContext context) { |
858 | switch (widget.scrollDirection) { |
859 | case Axis.horizontal: |
860 | assert(debugCheckHasDirectionality(context)); |
861 | final TextDirection textDirection = Directionality.of(context); |
862 | final AxisDirection axisDirection = textDirectionToAxisDirection(textDirection); |
863 | return widget.reverse ? flipAxisDirection(axisDirection) : axisDirection; |
864 | case Axis.vertical: |
865 | return widget.reverse ? AxisDirection.up : AxisDirection.down; |
866 | } |
867 | } |
868 | |
869 | @override |
870 | Widget build(BuildContext context) { |
871 | final AxisDirection axisDirection = _getDirection(context); |
872 | final ScrollPhysics physics = _ForceImplicitScrollPhysics( |
873 | allowImplicitScrolling: widget.allowImplicitScrolling, |
874 | ).applyTo( |
875 | widget.pageSnapping |
876 | ? _kPagePhysics.applyTo(widget.physics ?? widget.scrollBehavior?.getScrollPhysics(context)) |
877 | : widget.physics ?? widget.scrollBehavior?.getScrollPhysics(context), |
878 | ); |
879 | |
880 | return NotificationListener<ScrollNotification>( |
881 | onNotification: (ScrollNotification notification) { |
882 | if (notification.depth == 0 && widget.onPageChanged != null && notification is ScrollUpdateNotification) { |
883 | final PageMetrics metrics = notification.metrics as PageMetrics; |
884 | final int currentPage = metrics.page!.round(); |
885 | if (currentPage != _lastReportedPage) { |
886 | _lastReportedPage = currentPage; |
887 | widget.onPageChanged!(currentPage); |
888 | } |
889 | } |
890 | return false; |
891 | }, |
892 | child: Scrollable( |
893 | dragStartBehavior: widget.dragStartBehavior, |
894 | axisDirection: axisDirection, |
895 | controller: widget.controller, |
896 | physics: physics, |
897 | restorationId: widget.restorationId, |
898 | scrollBehavior: widget.scrollBehavior ?? ScrollConfiguration.of(context).copyWith(scrollbars: false), |
899 | viewportBuilder: (BuildContext context, ViewportOffset position) { |
900 | return Viewport( |
901 | // TODO(dnfield): we should provide a way to set cacheExtent |
902 | // independent of implicit scrolling: |
903 | // https://github.com/flutter/flutter/issues/45632 |
904 | cacheExtent: widget.allowImplicitScrolling ? 1.0 : 0.0, |
905 | cacheExtentStyle: CacheExtentStyle.viewport, |
906 | axisDirection: axisDirection, |
907 | offset: position, |
908 | clipBehavior: widget.clipBehavior, |
909 | slivers: <Widget>[ |
910 | SliverFillViewport( |
911 | viewportFraction: widget.controller.viewportFraction, |
912 | delegate: widget.childrenDelegate, |
913 | padEnds: widget.padEnds, |
914 | ), |
915 | ], |
916 | ); |
917 | }, |
918 | ), |
919 | ); |
920 | } |
921 | |
922 | @override |
923 | void debugFillProperties(DiagnosticPropertiesBuilder description) { |
924 | super.debugFillProperties(description); |
925 | description.add(EnumProperty<Axis>('scrollDirection' , widget.scrollDirection)); |
926 | description.add(FlagProperty('reverse' , value: widget.reverse, ifTrue: 'reversed' )); |
927 | description.add(DiagnosticsProperty<PageController>('controller' , widget.controller, showName: false)); |
928 | description.add(DiagnosticsProperty<ScrollPhysics>('physics' , widget.physics, showName: false)); |
929 | description.add(FlagProperty('pageSnapping' , value: widget.pageSnapping, ifFalse: 'snapping disabled' )); |
930 | description.add(FlagProperty('allowImplicitScrolling' , value: widget.allowImplicitScrolling, ifTrue: 'allow implicit scrolling' )); |
931 | } |
932 | } |
933 | |