page_view.dart 21 KB
Newer Older
1 2 3 4 5
// Copyright 2016 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:async';
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 'debug.dart';
13 14
import 'framework.dart';
import 'notification_listener.dart';
15
import 'page_storage.dart';
16
import 'scroll_context.dart';
17
import 'scroll_controller.dart';
18
import 'scroll_metrics.dart';
19 20 21
import 'scroll_notification.dart';
import 'scroll_physics.dart';
import 'scroll_position.dart';
22
import 'scroll_position_with_single_context.dart';
23
import 'scroll_view.dart';
24
import 'scrollable.dart';
25
import 'sliver.dart';
26
import 'viewport.dart';
27

28 29 30
/// A controller for [PageView].
///
/// A page controller lets you manipulate which page is visible in a [PageView].
Adam Barth's avatar
Adam Barth committed
31 32 33
/// In addition to being able to control the pixel offset of the content inside
/// the [PageView], a [PageController] also lets you control the offset in terms
/// of pages, which are increments of the viewport size.
34 35 36
///
/// See also:
///
37
///  * [PageView], which is the widget this object controls.
38
class PageController extends ScrollController {
39 40
  /// Creates a page controller.
  ///
41
  /// The [initialPage], [keepPage], and [viewportFraction] arguments must not be null.
42
  PageController({
43 44 45
    this.initialPage = 0,
    this.keepPage = true,
    this.viewportFraction = 1.0,
46
  }) : assert(initialPage != null),
47
       assert(keepPage != null),
48 49
       assert(viewportFraction != null),
       assert(viewportFraction > 0.0);
50

51
  /// The page to show when first creating the [PageView].
52
  final int initialPage;
53

54 55 56 57 58 59 60 61 62 63 64 65 66
  /// Save the current [page] with [PageStorage] and restore it if
  /// this controller's scrollable is recreated.
  ///
  /// If this property is set to false, the current [page] is never saved
  /// and [initialPage] is always used to initialize the scroll offset.
  /// If true (the default), the initial page is used the first time the
  /// controller's scrollable is created, since there's isn't a page to
  /// restore yet. Subsequently the saved page is restored and
  /// [initialPage] is ignored.
  ///
  /// See also:
  ///
  ///  * [PageStorageKey], which should be used when more than one
67
  ///    scrollable appears in the same route, to distinguish the [PageStorage]
68 69 70
  ///    locations used to save scroll offsets.
  final bool keepPage;

71 72 73 74 75 76 77
  /// The fraction of the viewport that each page should occupy.
  ///
  /// Defaults to 1.0, which means each page fills the viewport in the scrolling
  /// direction.
  final double viewportFraction;

  /// The current page displayed in the controlled [PageView].
78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93
  ///
  /// There are circumstances that this [PageController] can't know the current
  /// page. Reading [page] will throw an [AssertionError] in the following cases:
  ///
  /// 1. No [PageView] is currently using this [PageController]. Once a
  /// [PageView] starts using this [PageController], the new [page]
  /// position will be derived:
  ///
  ///   * First, based on the attached [PageView]'s [BuildContext] and the
  ///     position saved at that context's [PageStorage] if [keepPage] is true.
  ///   * Second, from the [PageController]'s [initialPage].
  ///
  /// 2. More than one [PageView] using the same [PageController].
  ///
  /// The [hasClients] property can be used to check if a [PageView] is attached
  /// prior to accessing [page].
94
  double get page {
95 96 97 98 99 100
    assert(
      positions.isNotEmpty,
      'PageController.page cannot be accessed before a PageView is built with it.',
    );
    assert(
      positions.length == 1,
101 102
      'The page property cannot be read when multiple PageViews are attached to '
      'the same PageController.',
103
    );
104 105
    final _PagePosition position = this.position;
    return position.page;
106 107
  }

108 109 110 111 112 113
  /// Animates the controlled [PageView] from the current page to the given page.
  ///
  /// The animation lasts for the given duration and follows the given curve.
  /// The returned [Future] resolves when the animation completes.
  ///
  /// The `duration` and `curve` arguments must not be null.
114 115 116 117
  Future<Null> animateToPage(int page, {
    @required Duration duration,
    @required Curve curve,
  }) {
118
    final _PagePosition position = this.position;
119 120 121 122 123
    return position.animateTo(
      position.getPixelsFromPage(page.toDouble()),
      duration: duration,
      curve: curve,
    );
124 125
  }

126 127
  /// Changes which page is displayed in the controlled [PageView].
  ///
128 129
  /// Jumps the page position from its current value to the given value,
  /// without animation, and without checking if the new value is in range.
130
  void jumpToPage(int page) {
131 132
    final _PagePosition position = this.position;
    position.jumpTo(position.getPixelsFromPage(page.toDouble()));
133 134
  }

135 136 137 138 139 140
  /// Animates the controlled [PageView] to the next page.
  ///
  /// The animation lasts for the given duration and follows the given curve.
  /// The returned [Future] resolves when the animation completes.
  ///
  /// The `duration` and `curve` arguments must not be null.
141 142
  Future<Null> nextPage({ @required Duration duration, @required Curve curve }) {
    return animateToPage(page.round() + 1, duration: duration, curve: curve);
143 144
  }

145 146 147 148 149 150
  /// Animates the controlled [PageView] to the previous page.
  ///
  /// The animation lasts for the given duration and follows the given curve.
  /// The returned [Future] resolves when the animation completes.
  ///
  /// The `duration` and `curve` arguments must not be null.
151 152
  Future<Null> previousPage({ @required Duration duration, @required Curve curve }) {
    return animateToPage(page.round() - 1, duration: duration, curve: curve);
153 154 155
  }

  @override
156
  ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition oldPosition) {
157
    return _PagePosition(
158
      physics: physics,
159
      context: context,
160
      initialPage: initialPage,
161
      keepPage: keepPage,
162
      viewportFraction: viewportFraction,
163 164 165
      oldPosition: oldPosition,
    );
  }
166 167 168 169

  @override
  void attach(ScrollPosition position) {
    super.attach(position);
170
    final _PagePosition pagePosition = position;
171 172 173 174 175 176 177 178
    pagePosition.viewportFraction = viewportFraction;
  }
}

/// Metrics for a [PageView].
///
/// The metrics are available on [ScrollNotification]s generated from
/// [PageView]s.
179
class PageMetrics extends FixedScrollMetrics {
180
  /// Creates an immutable snapshot of values associated with a [PageView].
181
  PageMetrics({
182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204
    @required double minScrollExtent,
    @required double maxScrollExtent,
    @required double pixels,
    @required double viewportDimension,
    @required AxisDirection axisDirection,
    @required this.viewportFraction,
  }) : super(
         minScrollExtent: minScrollExtent,
         maxScrollExtent: maxScrollExtent,
         pixels: pixels,
         viewportDimension: viewportDimension,
         axisDirection: axisDirection,
       );

  @override
  PageMetrics copyWith({
    double minScrollExtent,
    double maxScrollExtent,
    double pixels,
    double viewportDimension,
    AxisDirection axisDirection,
    double viewportFraction,
  }) {
205
    return PageMetrics(
206 207 208 209 210 211 212 213
      minScrollExtent: minScrollExtent ?? this.minScrollExtent,
      maxScrollExtent: maxScrollExtent ?? this.maxScrollExtent,
      pixels: pixels ?? this.pixels,
      viewportDimension: viewportDimension ?? this.viewportDimension,
      axisDirection: axisDirection ?? this.axisDirection,
      viewportFraction: viewportFraction ?? this.viewportFraction,
    );
  }
214 215

  /// The current page displayed in the [PageView].
216 217 218 219 220 221 222 223 224
  double get page {
    return math.max(0.0, pixels.clamp(minScrollExtent, maxScrollExtent)) /
           math.max(1.0, viewportDimension * viewportFraction);
  }

  /// The fraction of the viewport that each page occupies.
  ///
  /// Used to compute [page] from the current [pixels].
  final double viewportFraction;
225 226
}

227
class _PagePosition extends ScrollPositionWithSingleContext implements PageMetrics {
228 229
  _PagePosition({
    ScrollPhysics physics,
230
    ScrollContext context,
231 232 233
    this.initialPage = 0,
    bool keepPage = true,
    double viewportFraction = 1.0,
234
    ScrollPosition oldPosition,
235
  }) : assert(initialPage != null),
236
       assert(keepPage != null),
237 238 239
       assert(viewportFraction != null),
       assert(viewportFraction > 0.0),
       _viewportFraction = viewportFraction,
240 241
       _pageToUseOnStartup = initialPage.toDouble(),
       super(
242 243 244
         physics: physics,
         context: context,
         initialPixels: null,
245
         keepScrollOffset: keepPage,
246 247
         oldPosition: oldPosition,
       );
248

249
  final int initialPage;
250
  double _pageToUseOnStartup;
251

252
  @override
253 254
  double get viewportFraction => _viewportFraction;
  double _viewportFraction;
255 256
  set viewportFraction(double value) {
    if (_viewportFraction == value)
257 258
      return;
    final double oldPage = page;
259
    _viewportFraction = value;
260
    if (oldPage != null)
261
      forcePixels(getPixelsFromPage(oldPage));
262 263 264 265 266 267 268 269 270 271
  }

  double getPageFromPixels(double pixels, double viewportDimension) {
    return math.max(0.0, pixels) / math.max(1.0, viewportDimension * viewportFraction);
  }

  double getPixelsFromPage(double page) {
    return page * viewportDimension * viewportFraction;
  }

272
  @override
273
  double get page => pixels == null ? null : getPageFromPixels(pixels.clamp(minScrollExtent, maxScrollExtent), viewportDimension);
274

275 276 277 278 279 280 281 282 283 284
  @override
  void saveScrollOffset() {
    PageStorage.of(context.storageContext)?.writeState(context.storageContext, getPageFromPixels(pixels, viewportDimension));
  }

  @override
  void restoreScrollOffset() {
    if (pixels == null) {
      final double value = PageStorage.of(context.storageContext)?.readState(context.storageContext);
      if (value != null)
285
        _pageToUseOnStartup = value;
286 287 288
    }
  }

289 290 291 292 293
  @override
  bool applyViewportDimension(double viewportDimension) {
    final double oldViewportDimensions = this.viewportDimension;
    final bool result = super.applyViewportDimension(viewportDimension);
    final double oldPixels = pixels;
294
    final double page = (oldPixels == null || oldViewportDimensions == 0.0) ? _pageToUseOnStartup : getPageFromPixels(oldPixels, oldViewportDimensions);
295
    final double newPixels = getPixelsFromPage(page);
296 297 298 299 300 301
    if (newPixels != oldPixels) {
      correctPixels(newPixels);
      return false;
    }
    return result;
  }
302 303

  @override
304 305 306 307 308 309 310 311
  PageMetrics copyWith({
    double minScrollExtent,
    double maxScrollExtent,
    double pixels,
    double viewportDimension,
    AxisDirection axisDirection,
    double viewportFraction,
  }) {
312
    return PageMetrics(
313 314 315 316 317 318
      minScrollExtent: minScrollExtent ?? this.minScrollExtent,
      maxScrollExtent: maxScrollExtent ?? this.maxScrollExtent,
      pixels: pixels ?? this.pixels,
      viewportDimension: viewportDimension ?? this.viewportDimension,
      axisDirection: axisDirection ?? this.axisDirection,
      viewportFraction: viewportFraction ?? this.viewportFraction,
319 320 321 322 323 324 325
    );
  }
}

/// Scroll physics used by a [PageView].
///
/// These physics cause the page view to snap to page boundaries.
326 327 328 329 330 331
///
/// See also:
///
///  * [ScrollPhysics], the base class which defines the API for scrolling
///    physics.
///  * [PageView.physics], which can override the physics used by a page view.
332 333
class PageScrollPhysics extends ScrollPhysics {
  /// Creates physics for a [PageView].
334
  const PageScrollPhysics({ ScrollPhysics parent }) : super(parent: parent);
335 336

  @override
337
  PageScrollPhysics applyTo(ScrollPhysics ancestor) {
338
    return PageScrollPhysics(parent: buildParent(ancestor));
339
  }
340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362

  double _getPage(ScrollPosition position) {
    if (position is _PagePosition)
      return position.page;
    return position.pixels / position.viewportDimension;
  }

  double _getPixels(ScrollPosition position, double page) {
    if (position is _PagePosition)
      return position.getPixelsFromPage(page);
    return page * position.viewportDimension;
  }

  double _getTargetPixels(ScrollPosition position, Tolerance tolerance, double velocity) {
    double page = _getPage(position);
    if (velocity < -tolerance.velocity)
      page -= 0.5;
    else if (velocity > tolerance.velocity)
      page += 0.5;
    return _getPixels(position, page.roundToDouble());
  }

  @override
363
  Simulation createBallisticSimulation(ScrollMetrics position, double velocity) {
364 365 366 367 368 369 370
    // If we're out of range and not headed back in range, defer to the parent
    // ballistics, which should put us back in range at a page boundary.
    if ((velocity <= 0.0 && position.pixels <= position.minScrollExtent) ||
        (velocity >= 0.0 && position.pixels >= position.maxScrollExtent))
      return super.createBallisticSimulation(position, velocity);
    final Tolerance tolerance = this.tolerance;
    final double target = _getTargetPixels(position, tolerance, velocity);
371
    if (target != position.pixels)
372
      return ScrollSpringSimulation(spring, position.pixels, target, velocity, tolerance: tolerance);
373
    return null;
374
  }
375 376 377

  @override
  bool get allowImplicitScrolling => false;
378 379 380 381 382 383
}

// Having this global (mutable) page controller is a bit of a hack. We need it
// to plumb in the factory for _PagePosition, but it will end up accumulating
// a large list of scroll positions. As long as you don't try to actually
// control the scroll positions, everything should be fine.
384
final PageController _defaultPageController = PageController();
385
const PageScrollPhysics _kPagePhysics = PageScrollPhysics();
386 387

/// A scrollable list that works page by page.
Adam Barth's avatar
Adam Barth committed
388 389 390 391 392 393 394 395 396 397 398 399
///
/// Each child of a page view is forced to be the same size as the viewport.
///
/// You can use a [PageController] to control which page is visible in the view.
/// In addition to being able to control the pixel offset of the content inside
/// the [PageView], a [PageController] also lets you control the offset in terms
/// of pages, which are increments of the viewport size.
///
/// The [PageController] can also be used to control the
/// [PageController.initialPage], which determines which page is shown when the
/// [PageView] is first constructed, and the [PageController.viewportFraction],
/// which determines the size of the pages as a fraction of the viewport size.
400 401 402
///
/// See also:
///
Adam Barth's avatar
Adam Barth committed
403 404 405 406
///  * [PageController], which controls which page is visible in the view.
///  * [SingleChildScrollView], when you need to make a single child scrollable.
///  * [ListView], for a scrollable list of boxes.
///  * [GridView], for a scrollable grid of boxes.
407
///  * [ScrollNotification] and [NotificationListener], which can be used to watch
408
///    the scroll position without using a [ScrollController].
409
class PageView extends StatefulWidget {
Adam Barth's avatar
Adam Barth committed
410 411 412 413 414 415 416
  /// Creates a scrollable list that works page by page from an explicit [List]
  /// of widgets.
  ///
  /// This constructor is appropriate for page views with a small number of
  /// children because constructing the [List] requires doing work for every
  /// child that could possibly be displayed in the page view, instead of just
  /// those children that are actually visible.
417 418
  PageView({
    Key key,
419 420
    this.scrollDirection = Axis.horizontal,
    this.reverse = false,
421
    PageController controller,
422
    this.physics,
423
    this.pageSnapping = true,
424
    this.onPageChanged,
425
    List<Widget> children = const <Widget>[],
426
  }) : controller = controller ?? _defaultPageController,
427
       childrenDelegate = SliverChildListDelegate(children),
428
       super(key: key);
429

Adam Barth's avatar
Adam Barth committed
430 431 432 433 434 435 436 437 438 439 440 441
  /// Creates a scrollable list that works page by page using widgets that are
  /// created on demand.
  ///
  /// This constructor is appropriate for page views with a large (or infinite)
  /// number of children because the builder is called only for those children
  /// that are actually visible.
  ///
  /// Providing a non-null [itemCount] lets the [PageView] compute the maximum
  /// scroll extent.
  ///
  /// [itemBuilder] will be called only with indices greater than or equal to
  /// zero and less than [itemCount].
442 443
  PageView.builder({
    Key key,
444 445
    this.scrollDirection = Axis.horizontal,
    this.reverse = false,
446
    PageController controller,
447
    this.physics,
448
    this.pageSnapping = true,
449
    this.onPageChanged,
450
    @required IndexedWidgetBuilder itemBuilder,
451
    int itemCount,
452
  }) : controller = controller ?? _defaultPageController,
453
       childrenDelegate = SliverChildBuilderDelegate(itemBuilder, childCount: itemCount),
454
       super(key: key);
455

Adam Barth's avatar
Adam Barth committed
456 457
  /// Creates a scrollable list that works page by page with a custom child
  /// model.
458 459
  PageView.custom({
    Key key,
460 461
    this.scrollDirection = Axis.horizontal,
    this.reverse = false,
462
    PageController controller,
463
    this.physics,
464
    this.pageSnapping = true,
465 466
    this.onPageChanged,
    @required this.childrenDelegate,
467 468 469
  }) : assert(childrenDelegate != null),
       controller = controller ?? _defaultPageController,
       super(key: key);
470

Adam Barth's avatar
Adam Barth committed
471 472 473
  /// The axis along which the page view scrolls.
  ///
  /// Defaults to [Axis.horizontal].
474 475
  final Axis scrollDirection;

Adam Barth's avatar
Adam Barth committed
476 477 478 479 480 481 482 483 484 485 486 487
  /// Whether the page view scrolls in the reading direction.
  ///
  /// For example, if the reading direction is left-to-right and
  /// [scrollDirection] is [Axis.horizontal], then the page view scrolls from
  /// left to right when [reverse] is false and from right to left when
  /// [reverse] is true.
  ///
  /// Similarly, if [scrollDirection] is [Axis.vertical], then the page view
  /// scrolls from top to bottom when [reverse] is false and from bottom to top
  /// when [reverse] is true.
  ///
  /// Defaults to false.
488 489
  final bool reverse;

Adam Barth's avatar
Adam Barth committed
490 491
  /// An object that can be used to control the position to which this page
  /// view is scrolled.
492 493
  final PageController controller;

Adam Barth's avatar
Adam Barth committed
494 495 496 497 498 499 500 501 502
  /// How the page view should respond to user input.
  ///
  /// For example, determines how the page view continues to animate after the
  /// user stops dragging the page view.
  ///
  /// The physics are modified to snap to page boundaries using
  /// [PageScrollPhysics] prior to being used.
  ///
  /// Defaults to matching platform conventions.
503 504
  final ScrollPhysics physics;

505 506 507
  /// Set to false to disable page snapping, useful for custom scroll behavior.
  final bool pageSnapping;

Adam Barth's avatar
Adam Barth committed
508
  /// Called whenever the page in the center of the viewport changes.
509 510
  final ValueChanged<int> onPageChanged;

Adam Barth's avatar
Adam Barth committed
511 512 513 514 515 516
  /// A delegate that provides the children for the [PageView].
  ///
  /// The [PageView.custom] constructor lets you specify this delegate
  /// explicitly. The [PageView] and [PageView.builder] constructors create a
  /// [childrenDelegate] that wraps the given [List] and [IndexedWidgetBuilder],
  /// respectively.
517 518 519
  final SliverChildDelegate childrenDelegate;

  @override
520
  _PageViewState createState() => _PageViewState();
521 522 523 524 525 526 527 528
}

class _PageViewState extends State<PageView> {
  int _lastReportedPage = 0;

  @override
  void initState() {
    super.initState();
529
    _lastReportedPage = widget.controller.initialPage;
530 531 532
  }

  AxisDirection _getDirection(BuildContext context) {
533
    switch (widget.scrollDirection) {
534
      case Axis.horizontal:
535
        assert(debugCheckHasDirectionality(context));
536
        final TextDirection textDirection = Directionality.of(context);
537 538
        final AxisDirection axisDirection = textDirectionToAxisDirection(textDirection);
        return widget.reverse ? flipAxisDirection(axisDirection) : axisDirection;
539
      case Axis.vertical:
540
        return widget.reverse ? AxisDirection.up : AxisDirection.down;
541 542
    }
    return null;
543 544 545 546
  }

  @override
  Widget build(BuildContext context) {
547
    final AxisDirection axisDirection = _getDirection(context);
548 549 550 551
    final ScrollPhysics physics = widget.pageSnapping
        ? _kPagePhysics.applyTo(widget.physics)
        : widget.physics;

552
    return NotificationListener<ScrollNotification>(
Adam Barth's avatar
Adam Barth committed
553
      onNotification: (ScrollNotification notification) {
554
        if (notification.depth == 0 && widget.onPageChanged != null && notification is ScrollUpdateNotification) {
555 556
          final PageMetrics metrics = notification.metrics;
          final int currentPage = metrics.page.round();
557 558
          if (currentPage != _lastReportedPage) {
            _lastReportedPage = currentPage;
559
            widget.onPageChanged(currentPage);
560
          }
561
        }
562
        return false;
563
      },
564
      child: Scrollable(
565
        axisDirection: axisDirection,
566
        controller: widget.controller,
567
        physics: physics,
568
        viewportBuilder: (BuildContext context, ViewportOffset position) {
569
          return Viewport(
570
            cacheExtent: 0.0,
571
            axisDirection: axisDirection,
572
            offset: position,
573
            slivers: <Widget>[
574
              SliverFillViewport(
575 576
                viewportFraction: widget.controller.viewportFraction,
                delegate: widget.childrenDelegate
577
              ),
578 579 580 581
            ],
          );
        },
      ),
582 583
    );
  }
584 585

  @override
586
  void debugFillProperties(DiagnosticPropertiesBuilder description) {
587
    super.debugFillProperties(description);
588 589 590 591 592
    description.add(EnumProperty<Axis>('scrollDirection', widget.scrollDirection));
    description.add(FlagProperty('reverse', value: widget.reverse, ifTrue: 'reversed'));
    description.add(DiagnosticsProperty<PageController>('controller', widget.controller, showName: false));
    description.add(DiagnosticsProperty<ScrollPhysics>('physics', widget.physics, showName: false));
    description.add(FlagProperty('pageSnapping', value: widget.pageSnapping, ifFalse: 'snapping disabled'));
593
  }
594
}