blob: 4ea9a933ba1cc9ab41c9ea20d39a04c28bebcf87 [file] [log] [blame]
// 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/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,
'The page property cannot be read when multiple PageViews are 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 an immutable snapshot of values associated with a [PageView].
PageMetrics({
@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,
}) {
return new PageMetrics(
minScrollExtent: minScrollExtent ?? this.minScrollExtent,
maxScrollExtent: maxScrollExtent ?? this.maxScrollExtent,
pixels: pixels ?? this.pixels,
viewportDimension: viewportDimension ?? this.viewportDimension,
axisDirection: axisDirection ?? this.axisDirection,
viewportFraction: viewportFraction ?? this.viewportFraction,
);
}
/// The current page displayed in the [PageView].
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;
}
class _PagePosition extends ScrollPositionWithSingleContext implements PageMetrics {
_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;
@override
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;
}
@override
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 copyWith({
double minScrollExtent,
double maxScrollExtent,
double pixels,
double viewportDimension,
AxisDirection axisDirection,
double viewportFraction,
}) {
return new PageMetrics(
minScrollExtent: minScrollExtent ?? this.minScrollExtent,
maxScrollExtent: maxScrollExtent ?? this.maxScrollExtent,
pixels: pixels ?? this.pixels,
viewportDimension: viewportDimension ?? this.viewportDimension,
axisDirection: axisDirection ?? this.axisDirection,
viewportFraction: viewportFraction ?? this.viewportFraction,
);
}
}
/// 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(
cacheExtent: 0.0,
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'));
}
}