Commit 0403ed46 authored by Adam Barth's avatar Adam Barth Committed by GitHub

Add viewportFraction feature to PageView (#8539)

This feature lets you see a portion of the next and previous page in a
PageView.

Fixes #8408
parent bb1dea74
......@@ -18,13 +18,41 @@ abstract class RenderSliverFixedExtentBoxAdaptor extends RenderSliverMultiBoxAda
/// The main-axis extent of each item.
double get itemExtent;
@protected
double indexToScrollOffset(double itemExtent, int index) => itemExtent * index;
@protected
int getMinChildIndexForScrollOffset(double scrollOffset, double itemExtent) {
return itemExtent > 0.0 ? math.max(0, scrollOffset ~/ itemExtent) : 0;
}
@protected
int getMaxChildIndexForScrollOffset(double scrollOffset, double itemExtent) {
return itemExtent > 0.0 ? math.max(0, (scrollOffset / itemExtent).ceil() - 1) : 0;
}
@protected
double estimateMaxScrollOffset(SliverConstraints constraints, {
int firstIndex,
int lastIndex,
double leadingScrollOffset,
double trailingScrollOffset,
}) {
return childManager.estimateMaxScrollOffset(
constraints,
firstIndex: firstIndex,
lastIndex: lastIndex,
leadingScrollOffset: leadingScrollOffset,
trailingScrollOffset: trailingScrollOffset,
);
}
@override
void performLayout() {
assert(childManager.debugAssertChildListLocked());
childManager.setDidUnderflow(false);
final double itemExtent = this.itemExtent;
double indexToScrollOffset(int index) => itemExtent * index;
final double scrollOffset = constraints.scrollOffset;
assert(scrollOffset >= 0.0);
......@@ -37,8 +65,8 @@ abstract class RenderSliverFixedExtentBoxAdaptor extends RenderSliverMultiBoxAda
maxExtent: itemExtent,
);
final int firstIndex = itemExtent > 0.0 ? math.max(0, scrollOffset ~/ itemExtent) : 0;
final int targetLastIndex = itemExtent > 0.0 ? math.max(0, (targetEndScrollOffset / itemExtent).ceil() - 1) : 0;
final int firstIndex = getMinChildIndexForScrollOffset(scrollOffset, itemExtent);
final int targetLastIndex = getMaxChildIndexForScrollOffset(targetEndScrollOffset, itemExtent);
if (firstChild != null) {
final int oldFirstIndex = indexOf(firstChild);
......@@ -50,7 +78,7 @@ abstract class RenderSliverFixedExtentBoxAdaptor extends RenderSliverMultiBoxAda
}
if (firstChild == null) {
if (!addInitialChild(index: firstIndex, scrollOffset: indexToScrollOffset(firstIndex))) {
if (!addInitialChild(index: firstIndex, scrollOffset: indexToScrollOffset(itemExtent, firstIndex))) {
// There are no children.
geometry = SliverGeometry.zero;
return;
......@@ -62,7 +90,7 @@ abstract class RenderSliverFixedExtentBoxAdaptor extends RenderSliverMultiBoxAda
for (int index = indexOf(firstChild) - 1; index >= firstIndex; --index) {
final RenderBox child = insertAndLayoutLeadingChild(childConstraints);
final SliverMultiBoxAdaptorParentData childParentData = child.parentData;
childParentData.layoutOffset = indexToScrollOffset(index);
childParentData.layoutOffset = indexToScrollOffset(itemExtent, index);
assert(childParentData.index == index);
trailingChildWithLayout ??= child;
}
......@@ -70,7 +98,7 @@ abstract class RenderSliverFixedExtentBoxAdaptor extends RenderSliverMultiBoxAda
if (trailingChildWithLayout == null) {
firstChild.layout(childConstraints);
final SliverMultiBoxAdaptorParentData childParentData = firstChild.parentData;
childParentData.layoutOffset = indexToScrollOffset(firstIndex);
childParentData.layoutOffset = indexToScrollOffset(itemExtent, firstIndex);
trailingChildWithLayout = firstChild;
}
......@@ -88,19 +116,19 @@ abstract class RenderSliverFixedExtentBoxAdaptor extends RenderSliverMultiBoxAda
trailingChildWithLayout = child;
assert(child != null);
final SliverMultiBoxAdaptorParentData childParentData = child.parentData;
childParentData.layoutOffset = indexToScrollOffset(childParentData.index);
childParentData.layoutOffset = indexToScrollOffset(itemExtent, childParentData.index);
}
final int lastIndex = indexOf(lastChild);
final double leadingScrollOffset = indexToScrollOffset(firstIndex);
final double trailingScrollOffset = indexToScrollOffset(lastIndex + 1);
final double leadingScrollOffset = indexToScrollOffset(itemExtent, firstIndex);
final double trailingScrollOffset = indexToScrollOffset(itemExtent, lastIndex + 1);
assert(childScrollOffset(firstChild) <= scrollOffset);
assert(firstIndex == 0 || childScrollOffset(firstChild) <= scrollOffset);
assert(debugAssertChildListIsNonEmptyAndContiguous());
assert(indexOf(firstChild) == firstIndex);
assert(lastIndex <= targetLastIndex);
final double estimatedMaxScrollOffset = childManager.estimateMaxScrollOffset(
final double estimatedMaxScrollOffset = estimateMaxScrollOffset(
constraints,
firstIndex: firstIndex,
lastIndex: lastIndex,
......@@ -147,8 +175,56 @@ class RenderSliverFixedExtentList extends RenderSliverFixedExtentBoxAdaptor {
class RenderSliverFill extends RenderSliverFixedExtentBoxAdaptor {
RenderSliverFill({
@required RenderSliverBoxChildManager childManager,
}) : super(childManager: childManager);
double viewportFraction: 1.0,
}) : _viewportFraction = viewportFraction, super(childManager: childManager) {
assert(viewportFraction != null);
assert(viewportFraction > 0.0);
}
@override
double get itemExtent => constraints.viewportMainAxisExtent * viewportFraction;
double get viewportFraction => _viewportFraction;
double _viewportFraction;
set viewportFraction (double newValue) {
assert(newValue != null);
if (_viewportFraction == newValue)
return;
_viewportFraction = newValue;
markNeedsLayout();
}
double get _padding => (1.0 - viewportFraction) * constraints.viewportMainAxisExtent * 0.5;
@override
double indexToScrollOffset(double itemExtent, int index) {
return _padding + super.indexToScrollOffset(itemExtent, index);
}
@override
int getMinChildIndexForScrollOffset(double scrollOffset, double itemExtent) {
return super.getMinChildIndexForScrollOffset(math.max(scrollOffset - _padding, 0.0), itemExtent);
}
@override
int getMaxChildIndexForScrollOffset(double scrollOffset, double itemExtent) {
return super.getMaxChildIndexForScrollOffset(math.max(scrollOffset - _padding, 0.0), itemExtent);
}
@override
double get itemExtent => constraints.viewportMainAxisExtent;
double estimateMaxScrollOffset(SliverConstraints constraints, {
int firstIndex,
int lastIndex,
double leadingScrollOffset,
double trailingScrollOffset,
}) {
final double padding = _padding;
return childManager.estimateMaxScrollOffset(
constraints,
firstIndex: firstIndex,
lastIndex: lastIndex,
leadingScrollOffset: leadingScrollOffset - padding,
trailingScrollOffset: trailingScrollOffset - padding,
) + padding + padding;
}
}
......@@ -3,7 +3,10 @@
// 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 'package:meta/meta.dart';
......@@ -19,37 +22,82 @@ 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].
///
/// See also:
///
/// - [PageView], which is the widget this object controls.
class PageController extends ScrollController {
/// Creates a page controller.
///
/// The [initialPage] and [viewportFraction] arguments must not be null.
PageController({
this.initialPage: 0,
this.viewportFraction: 1.0,
}) {
assert(initialPage != null);
assert(viewportFraction != null);
assert(viewportFraction > 0.0);
}
/// The page to show when first creating the [PageView].
final int initialPage;
/// 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].
double get page {
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 ScrollPosition position = this.position;
return position.animateTo(page * position.viewportDimension, duration: duration, 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].
///
/// 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.
void jumpToPage(int page) {
final ScrollPosition position = this.position;
position.jumpTo(page * position.viewportDimension);
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.
void nextPage({ @required Duration duration, @required Curve curve }) {
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.
void previousPage({ @required Duration duration, @required Curve curve }) {
animateToPage(page.round() - 1, duration: duration, curve: curve);
}
......@@ -60,9 +108,33 @@ class PageController extends ScrollController {
physics: physics,
state: state,
initialPage: initialPage,
viewportFraction: viewportFraction,
oldPosition: oldPosition,
);
}
@override
void attach(ScrollPosition position) {
super.attach(position);
_PagePosition pagePosition = position;
pagePosition.viewportFraction = viewportFraction;
}
}
/// Metrics for a [PageView].
///
/// The metrics are available on [ScrollNotification]s generated from
/// [PageView]s.
class PageMetrics extends ScrollMetrics {
/// 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 ScrollPosition {
......@@ -70,33 +142,107 @@ class _PagePosition extends ScrollPosition {
ScrollPhysics physics,
AbstractScrollState state,
this.initialPage: 0,
double viewportFraction: 1.0,
ScrollPosition oldPosition,
}) : super(
}) : _viewportFraction = viewportFraction, super(
physics: physics,
state: state,
initialPixels: null,
oldPosition: oldPosition,
) {
assert(initialPage != null);
assert(viewportFraction != null);
assert(viewportFraction > 0.0);
}
final int initialPage;
double get page => pixels / viewportDimension;
double get viewportFraction => _viewportFraction;
double _viewportFraction;
set viewportFraction(double newValue) {
if (_viewportFraction == newValue)
return;
final double oldPage = page;
_viewportFraction = newValue;
if (oldPage != null)
correctPixels(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, viewportDimension);
@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) ? initialPage.toDouble() : oldPixels / oldViewportDimensions;
final double newPixels = page * viewportDimension;
final double page = (oldPixels == null || oldViewportDimensions == 0.0) ? initialPage.toDouble() : getPageFromPixels(oldPixels, oldViewportDimensions);
final double newPixels = getPixelsFromPage(page);
if (newPixels != oldPixels) {
correctPixels(newPixels);
return false;
}
return result;
}
@override
PageMetrics getMetrics() {
return new PageMetrics(
parent: super.getMetrics(),
page: page,
);
}
}
/// Scroll physics used by a [PageView].
///
/// These physics cause the page view to snap to page boundaries.
class PageScrollPhysics extends ScrollPhysics {
/// Creates physics for a [PageView].
const PageScrollPhysics({ ScrollPhysics parent }) : super(parent);
@override
PageScrollPhysics applyTo(ScrollPhysics parent) => new PageScrollPhysics(parent: parent);
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(ScrollPosition 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);
return new ScrollSpringSimulation(spring, position.pixels, target, velocity, tolerance: tolerance);
}
}
// Having this global (mutable) page controller is a bit of a hack. We need it
......@@ -104,6 +250,7 @@ class _PagePosition extends ScrollPosition {
// 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.
// TODO(ianh): More documentation here.
......@@ -119,7 +266,7 @@ class PageView extends StatefulWidget {
this.scrollDirection: Axis.horizontal,
this.reverse: false,
PageController controller,
this.physics: const PageScrollPhysics(),
this.physics,
this.onPageChanged,
List<Widget> children: const <Widget>[],
}) : controller = controller ?? _defaultPageController,
......@@ -131,7 +278,7 @@ class PageView extends StatefulWidget {
this.scrollDirection: Axis.horizontal,
this.reverse: false,
PageController controller,
this.physics: const PageScrollPhysics(),
this.physics,
this.onPageChanged,
IndexedWidgetBuilder itemBuilder,
int itemCount,
......@@ -144,7 +291,7 @@ class PageView extends StatefulWidget {
this.scrollDirection: Axis.horizontal,
this.reverse: false,
PageController controller,
this.physics: const PageScrollPhysics(),
this.physics,
this.onPageChanged,
@required this.childrenDelegate,
}) : controller = controller ?? _defaultPageController, super(key: key) {
......@@ -189,12 +336,12 @@ class _PageViewState extends State<PageView> {
@override
Widget build(BuildContext context) {
AxisDirection axisDirection = _getDirection(context);
final AxisDirection axisDirection = _getDirection(context);
return new NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification notification) {
if (notification.depth == 0 && config.onPageChanged != null && notification is ScrollUpdateNotification) {
final ScrollMetrics metrics = notification.metrics;
final int currentPage = (metrics.extentBefore / metrics.viewportDimension).round();
final PageMetrics metrics = notification.metrics;
final int currentPage = metrics.page.round();
if (currentPage != _lastReportedPage) {
_lastReportedPage = currentPage;
config.onPageChanged(currentPage);
......@@ -205,13 +352,16 @@ class _PageViewState extends State<PageView> {
child: new Scrollable(
axisDirection: axisDirection,
controller: config.controller,
physics: config.physics,
physics: config.physics == null ? _kPagePhysics : _kPagePhysics.applyTo(config.physics),
viewportBuilder: (BuildContext context, ViewportOffset offset) {
return new Viewport(
axisDirection: axisDirection,
offset: offset,
slivers: <Widget>[
new SliverFill(delegate: config.childrenDelegate),
new SliverFill(
viewportFraction: config.controller.viewportFraction,
delegate: config.childrenDelegate
),
],
);
},
......
......@@ -29,6 +29,14 @@ class ScrollMetrics {
@required this.viewportDimension,
});
/// Creates a [ScrollMetrics] that has the same properties as the given
/// [ScrollMetrics].
ScrollMetrics.clone(ScrollMetrics other)
: extentBefore = other.extentBefore,
extentInside = other.extentInside,
extentAfter = other.extentAfter,
viewportDimension = other.viewportDimension;
/// The quantity of content conceptually "above" the currently visible content
/// of the viewport in the scrollable. This is the content above the content
/// described by [extentInside].
......
......@@ -166,36 +166,3 @@ class AlwaysScrollableScrollPhysics extends ScrollPhysics {
@override
bool shouldAcceptUserOffset(ScrollPosition position) => true;
}
class PageScrollPhysics extends ScrollPhysics {
const PageScrollPhysics({ ScrollPhysics parent }) : super(parent);
@override
PageScrollPhysics applyTo(ScrollPhysics parent) => new PageScrollPhysics(parent: parent);
double _roundToPage(ScrollPosition position, double pixels, double pageSize) {
final int index = (pixels + pageSize / 2.0) ~/ pageSize;
return (pageSize * index).clamp(position.minScrollExtent, position.maxScrollExtent);
}
double _getTargetPixels(ScrollPosition position, Tolerance tolerance, double velocity) {
final double pageSize = position.viewportDimension;
if (velocity < -tolerance.velocity)
return _roundToPage(position, position.pixels - pageSize / 2.0, pageSize);
if (velocity > tolerance.velocity)
return _roundToPage(position, position.pixels + pageSize / 2.0, pageSize);
return _roundToPage(position, position.pixels, pageSize);
}
@override
Simulation createBallisticSimulation(ScrollPosition 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);
return new ScrollSpringSimulation(spring, position.pixels, target, velocity, tolerance: tolerance);
}
}
......@@ -247,12 +247,23 @@ class SliverFill extends SliverMultiBoxAdaptorWidget {
SliverFill({
Key key,
@required SliverChildDelegate delegate,
}) : super(key: key, delegate: delegate);
this.viewportFraction: 1.0,
}) : super(key: key, delegate: delegate) {
assert(viewportFraction != null);
assert(viewportFraction > 0.0);
}
final double viewportFraction;
@override
RenderSliverFill createRenderObject(BuildContext context) {
final SliverMultiBoxAdaptorElement element = context;
return new RenderSliverFill(childManager: element);
return new RenderSliverFill(childManager: element, viewportFraction: viewportFraction);
}
@override
void updateRenderObject(BuildContext context, RenderSliverFill renderObject) {
renderObject.viewportFraction = viewportFraction;
}
}
......
......@@ -264,4 +264,108 @@ void main() {
expect(find.text('Alaska'), findsOneWidget);
});
testWidgets('PageView viewportFraction', (WidgetTester tester) async {
PageController controller = new PageController(viewportFraction: 7/8);
Widget build(PageController controller) {
return new PageView.builder(
controller: controller,
itemCount: kStates.length,
itemBuilder: (BuildContext context, int index) {
return new Container(
height: 200.0,
color: index % 2 == 0 ? const Color(0xFF0000FF) : const Color(0xFF00FF00),
child: new Text(kStates[index]),
);
}
);
}
await tester.pumpWidget(build(controller));
expect(tester.getTopLeft(find.text('Alabama')), const Point(50.0, 0.0));
expect(tester.getTopLeft(find.text('Alaska')), const Point(750.0, 0.0));
controller.jumpToPage(10);
await tester.pump();
expect(tester.getTopLeft(find.text('Georgia')), const Point(-650.0, 0.0));
expect(tester.getTopLeft(find.text('Hawaii')), const Point(50.0, 0.0));
expect(tester.getTopLeft(find.text('Idaho')), const Point(750.0, 0.0));
controller = new PageController(viewportFraction: 39/40);
await tester.pumpWidget(build(controller));
expect(tester.getTopLeft(find.text('Georgia')), const Point(-770.0, 0.0));
expect(tester.getTopLeft(find.text('Hawaii')), const Point(10.0, 0.0));
expect(tester.getTopLeft(find.text('Idaho')), const Point(790.0, 0.0));
});
testWidgets('PageView small viewportFraction', (WidgetTester tester) async {
PageController controller = new PageController(viewportFraction: 1/8);
Widget build(PageController controller) {
return new PageView.builder(
controller: controller,
itemCount: kStates.length,
itemBuilder: (BuildContext context, int index) {
return new Container(
height: 200.0,
color: index % 2 == 0 ? const Color(0xFF0000FF) : const Color(0xFF00FF00),
child: new Text(kStates[index]),
);
}
);
}
await tester.pumpWidget(build(controller));
expect(tester.getTopLeft(find.text('Alabama')), const Point(350.0, 0.0));
expect(tester.getTopLeft(find.text('Alaska')), const Point(450.0, 0.0));
expect(tester.getTopLeft(find.text('Arizona')), const Point(550.0, 0.0));
expect(tester.getTopLeft(find.text('Arkansas')), const Point(650.0, 0.0));
expect(tester.getTopLeft(find.text('California')), const Point(750.0, 0.0));
controller.jumpToPage(10);
await tester.pump();
expect(tester.getTopLeft(find.text('Connecticut')), const Point(-50.0, 0.0));
expect(tester.getTopLeft(find.text('Delaware')), const Point(50.0, 0.0));
expect(tester.getTopLeft(find.text('Florida')), const Point(150.0, 0.0));
expect(tester.getTopLeft(find.text('Georgia')), const Point(250.0, 0.0));
expect(tester.getTopLeft(find.text('Hawaii')), const Point(350.0, 0.0));
expect(tester.getTopLeft(find.text('Idaho')), const Point(450.0, 0.0));
expect(tester.getTopLeft(find.text('Illinois')), const Point(550.0, 0.0));
expect(tester.getTopLeft(find.text('Indiana')), const Point(650.0, 0.0));
expect(tester.getTopLeft(find.text('Iowa')), const Point(750.0, 0.0));
});
testWidgets('PageView large viewportFraction', (WidgetTester tester) async {
PageController controller = new PageController(viewportFraction: 5/4);
Widget build(PageController controller) {
return new PageView.builder(
controller: controller,
itemCount: kStates.length,
itemBuilder: (BuildContext context, int index) {
return new Container(
height: 200.0,
color: index % 2 == 0 ? const Color(0xFF0000FF) : const Color(0xFF00FF00),
child: new Text(kStates[index]),
);
}
);
}
await tester.pumpWidget(build(controller));
expect(tester.getTopLeft(find.text('Alabama')), const Point(-100.0, 0.0));
expect(tester.getBottomRight(find.text('Alabama')), const Point(900.0, 600.0));
controller.jumpToPage(10);
await tester.pump();
expect(tester.getTopLeft(find.text('Hawaii')), const Point(-100.0, 0.0));
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment