// 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'; import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter/physics.dart'; import 'package:flutter/rendering.dart'; import 'basic.dart'; import 'debug.dart'; import 'framework.dart'; import 'notification_listener.dart'; import 'page_storage.dart'; import 'scroll_context.dart'; import 'scroll_controller.dart'; import 'scroll_metrics.dart'; import 'scroll_notification.dart'; import 'scroll_physics.dart'; import 'scroll_position.dart'; import 'scroll_position_with_single_context.dart'; import 'scroll_view.dart'; import 'scrollable.dart'; import 'sliver.dart'; import 'viewport.dart'; /// A controller for [PageView]. /// /// A page controller lets you manipulate which page is visible in a [PageView]. /// 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. /// /// See also: /// /// * [PageView], which is the widget this object controls. class PageController extends ScrollController { /// Creates a page controller. /// /// The [initialPage], [keepPage], and [viewportFraction] arguments must not be null. PageController({ this.initialPage: 0, this.keepPage: true, this.viewportFraction: 1.0, }) : assert(initialPage != null), assert(keepPage != null), assert(viewportFraction != null), assert(viewportFraction > 0.0); /// The page to show when first creating the [PageView]. final int initialPage; /// 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 /// scrollable appears in the same route, to distinguish the [PageStorage] /// locations used to save scroll offsets. final bool keepPage; /// 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]. /// /// 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]. double get page { assert( positions.isNotEmpty, 'PageController.page cannot be accessed before a PageView is built with it.', ); assert( positions.length == 1, 'Multiple PageViews cannot be attached to the same PageController.', ); final _PagePosition position = this.position; return position.page; } /// 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. Future<Null> animateToPage(int page, { @required Duration duration, @required Curve curve, }) { final _PagePosition position = this.position; return position.animateTo( position.getPixelsFromPage(page.toDouble()), duration: duration, curve: curve, ); } /// Changes which page is displayed in the controlled [PageView]. /// /// Jumps the page position from its current value to the given value, /// without animation, and without checking if the new value is in range. void jumpToPage(int page) { final _PagePosition position = this.position; position.jumpTo(position.getPixelsFromPage(page.toDouble())); } /// 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. Future<Null> nextPage({ @required Duration duration, @required Curve curve }) { return animateToPage(page.round() + 1, duration: duration, curve: curve); } /// 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. Future<Null> previousPage({ @required Duration duration, @required Curve curve }) { return animateToPage(page.round() - 1, duration: duration, curve: curve); } @override ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition oldPosition) { return new _PagePosition( physics: physics, context: context, initialPage: initialPage, keepPage: keepPage, viewportFraction: viewportFraction, oldPosition: oldPosition, ); } @override void attach(ScrollPosition position) { super.attach(position); final _PagePosition pagePosition = position; pagePosition.viewportFraction = viewportFraction; } } /// Metrics for a [PageView]. /// /// The metrics are available on [ScrollNotification]s generated from /// [PageView]s. class PageMetrics extends FixedScrollMetrics { /// Creates page metrics that add the given information to the `parent` /// metrics. PageMetrics({ ScrollMetrics parent, this.page, }) : super.clone(parent); /// The current page displayed in the [PageView]. final double page; } class _PagePosition extends ScrollPositionWithSingleContext { _PagePosition({ ScrollPhysics physics, ScrollContext context, this.initialPage: 0, bool keepPage: true, double viewportFraction: 1.0, ScrollPosition oldPosition, }) : assert(initialPage != null), assert(keepPage != null), assert(viewportFraction != null), assert(viewportFraction > 0.0), _viewportFraction = viewportFraction, _pageToUseOnStartup = initialPage.toDouble(), super( physics: physics, context: context, initialPixels: null, keepScrollOffset: keepPage, oldPosition: oldPosition, ); final int initialPage; double _pageToUseOnStartup; double get viewportFraction => _viewportFraction; double _viewportFraction; set viewportFraction(double value) { if (_viewportFraction == value) return; final double oldPage = page; _viewportFraction = value; if (oldPage != null) forcePixels(getPixelsFromPage(oldPage)); } 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; } double get page => pixels == null ? null : getPageFromPixels(pixels.clamp(minScrollExtent, maxScrollExtent), viewportDimension); @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) _pageToUseOnStartup = value; } } @override bool applyViewportDimension(double viewportDimension) { final double oldViewportDimensions = this.viewportDimension; final bool result = super.applyViewportDimension(viewportDimension); final double oldPixels = pixels; final double page = (oldPixels == null || oldViewportDimensions == 0.0) ? _pageToUseOnStartup : getPageFromPixels(oldPixels, oldViewportDimensions); final double newPixels = getPixelsFromPage(page); if (newPixels != oldPixels) { correctPixels(newPixels); return false; } return result; } @override PageMetrics cloneMetrics() { return new PageMetrics( parent: this, page: page, ); } } /// Scroll physics used by a [PageView]. /// /// These physics cause the page view to snap to page boundaries. /// /// 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. class PageScrollPhysics extends ScrollPhysics { /// Creates physics for a [PageView]. const PageScrollPhysics({ ScrollPhysics parent }) : super(parent: parent); @override PageScrollPhysics applyTo(ScrollPhysics ancestor) { return new PageScrollPhysics(parent: buildParent(ancestor)); } 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 Simulation createBallisticSimulation(ScrollMetrics position, double velocity) { // 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); if (target != position.pixels) return new ScrollSpringSimulation(spring, position.pixels, target, velocity, tolerance: tolerance); return null; } } // 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. final PageController _defaultPageController = new PageController(); const PageScrollPhysics _kPagePhysics = const PageScrollPhysics(); /// A scrollable list that works page by page. /// /// 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. /// /// See also: /// /// * [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. /// * [ScrollNotification] and [NotificationListener], which can be used to watch /// the scroll position without using a [ScrollController]. class PageView extends StatefulWidget { /// 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. PageView({ Key key, this.scrollDirection: Axis.horizontal, this.reverse: false, PageController controller, this.physics, this.pageSnapping: true, this.onPageChanged, List<Widget> children: const <Widget>[], }) : controller = controller ?? _defaultPageController, childrenDelegate = new SliverChildListDelegate(children), super(key: key); /// 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]. PageView.builder({ Key key, this.scrollDirection: Axis.horizontal, this.reverse: false, PageController controller, this.physics, this.pageSnapping: true, this.onPageChanged, @required IndexedWidgetBuilder itemBuilder, int itemCount, }) : controller = controller ?? _defaultPageController, childrenDelegate = new SliverChildBuilderDelegate(itemBuilder, childCount: itemCount), super(key: key); /// Creates a scrollable list that works page by page with a custom child /// model. PageView.custom({ Key key, this.scrollDirection: Axis.horizontal, this.reverse: false, PageController controller, this.physics, this.pageSnapping: true, this.onPageChanged, @required this.childrenDelegate, }) : assert(childrenDelegate != null), controller = controller ?? _defaultPageController, super(key: key); /// The axis along which the page view scrolls. /// /// Defaults to [Axis.horizontal]. final Axis scrollDirection; /// 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. final bool reverse; /// An object that can be used to control the position to which this page /// view is scrolled. final PageController controller; /// 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. final ScrollPhysics physics; /// Set to false to disable page snapping, useful for custom scroll behavior. final bool pageSnapping; /// Called whenever the page in the center of the viewport changes. final ValueChanged<int> onPageChanged; /// 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. final SliverChildDelegate childrenDelegate; @override _PageViewState createState() => new _PageViewState(); } class _PageViewState extends State<PageView> { int _lastReportedPage = 0; @override void initState() { super.initState(); _lastReportedPage = widget.controller.initialPage; } AxisDirection _getDirection(BuildContext context) { switch (widget.scrollDirection) { case Axis.horizontal: assert(debugCheckHasDirectionality(context)); final TextDirection textDirection = Directionality.of(context); final AxisDirection axisDirection = textDirectionToAxisDirection(textDirection); return widget.reverse ? flipAxisDirection(axisDirection) : axisDirection; case Axis.vertical: return widget.reverse ? AxisDirection.up : AxisDirection.down; } return null; } @override Widget build(BuildContext context) { final AxisDirection axisDirection = _getDirection(context); final ScrollPhysics physics = widget.pageSnapping ? _kPagePhysics.applyTo(widget.physics) : widget.physics; return new NotificationListener<ScrollNotification>( onNotification: (ScrollNotification notification) { if (notification.depth == 0 && widget.onPageChanged != null && notification is ScrollUpdateNotification) { final PageMetrics metrics = notification.metrics; final int currentPage = metrics.page.round(); if (currentPage != _lastReportedPage) { _lastReportedPage = currentPage; widget.onPageChanged(currentPage); } } return false; }, child: new Scrollable( axisDirection: axisDirection, controller: widget.controller, physics: physics, viewportBuilder: (BuildContext context, ViewportOffset position) { return new Viewport( axisDirection: axisDirection, offset: position, slivers: <Widget>[ new SliverFillViewport( viewportFraction: widget.controller.viewportFraction, delegate: widget.childrenDelegate ), ], ); }, ), ); } @override void debugFillProperties(DiagnosticPropertiesBuilder description) { super.debugFillProperties(description); description.add(new EnumProperty<Axis>('scrollDirection', widget.scrollDirection)); description.add(new FlagProperty('reverse', value: widget.reverse, ifTrue: 'reversed')); description.add(new DiagnosticsProperty<PageController>('controller', widget.controller, showName: false)); description.add(new DiagnosticsProperty<ScrollPhysics>('physics', widget.physics, showName: false)); description.add(new FlagProperty('pageSnapping', value: widget.pageSnapping, ifFalse: 'snapping disabled')); } }