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