Commit daa7860e authored by Hans Muller's avatar Hans Muller Committed by GitHub

Add a ScrollController parameter to NestedScrollView (#11242)

parent 5f9e5605
......@@ -32,12 +32,10 @@ import 'ticker_provider.dart';
/// content ostensibly below it.
typedef List<Widget> NestedScrollViewHeaderSliversBuilder(BuildContext context, bool innerBoxIsScrolled);
// TODO(abarth): Make this configurable with a controller.
const double _kInitialScrollOffset = 0.0;
class NestedScrollView extends StatefulWidget {
const NestedScrollView({
Key key,
this.controller,
this.scrollDirection: Axis.vertical,
this.reverse: false,
this.physics,
......@@ -49,7 +47,9 @@ class NestedScrollView extends StatefulWidget {
assert(body != null),
super(key: key);
// TODO(ianh): we should expose a controller so you can call animateTo, etc.
/// An object that can be used to control the position to which the outer
/// scroll view is scrolled.
final ScrollController controller;
/// The axis along which the scroll view scrolls.
///
......@@ -114,7 +114,7 @@ class _NestedScrollViewState extends State<NestedScrollView> {
@override
void initState() {
super.initState();
_coordinator = new _NestedScrollCoordinator(context, _kInitialScrollOffset);
_coordinator = new _NestedScrollCoordinator(context, widget.controller);
}
@override
......@@ -170,12 +170,14 @@ class _NestedScrollMetrics extends FixedScrollMetrics {
typedef ScrollActivity _NestedScrollActivityGetter(_NestedScrollPosition position);
class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldController {
_NestedScrollCoordinator(this._context, double initialScrollOffset) {
_NestedScrollCoordinator(this._context, this._parent) {
final double initialScrollOffset = _parent?.initialScrollOffset ?? 0.0;
_outerController = new _NestedScrollController(this, initialScrollOffset: initialScrollOffset, debugLabel: 'outer');
_innerController = new _NestedScrollController(this, initialScrollOffset: initialScrollOffset, debugLabel: 'inner');
_innerController = new _NestedScrollController(this, initialScrollOffset: 0.0, debugLabel: 'inner');
}
final BuildContext _context;
final ScrollController _parent;
_NestedScrollController _outerController;
_NestedScrollController _innerController;
......@@ -407,7 +409,7 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont
Future<Null> animateTo(double to, {
@required Duration duration,
@required Curve curve,
}) {
}) async {
final DrivenScrollActivity outerActivity = _outerPosition.createDrivenScrollActivity(
nestOffset(to, _outerPosition),
duration,
......@@ -426,7 +428,7 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont
return innerActivity;
},
);
return Future.wait<Null>(resultFutures);
await Future.wait<Null>(resultFutures);
}
void jumpTo(double to) {
......@@ -513,7 +515,7 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont
}
void updateParent() {
_outerPosition?.setParent(PrimaryScrollController.of(_context));
_outerPosition?.setParent(_parent ?? PrimaryScrollController.of(_context));
}
@mustCallSuper
......@@ -827,7 +829,6 @@ class _NestedOuterBallisticScrollActivity extends BallisticScrollActivity {
done = true;
}
} else if (velocity < 0.0) {
assert(velocity < 0.0);
if (value > metrics.maxRange)
return true;
if (value < metrics.minRange) {
......
......@@ -45,11 +45,12 @@ class ScrollController extends ChangeNotifier {
///
/// The values of `initialScrollOffset` and `keepScrollOffset` must not be null.
ScrollController({
this.initialScrollOffset: 0.0,
double initialScrollOffset: 0.0,
this.keepScrollOffset: true,
this.debugLabel,
}) : assert(initialScrollOffset != null),
assert(keepScrollOffset != null);
assert(keepScrollOffset != null),
_initialScrollOffset = initialScrollOffset;
/// The initial value to use for [offset].
///
......@@ -58,7 +59,8 @@ class ScrollController extends ChangeNotifier {
/// if [keepScrollOffset] is false or a scroll offset hasn't been saved yet.
///
/// Defaults to 0.0.
final double initialScrollOffset;
final double _initialScrollOffset;
double get initialScrollOffset => _initialScrollOffset;
/// Each time a scroll completes, save the current scroll [offset] with
/// [PageStorage] and restore it if this controller's scrollable is recreated.
......@@ -266,3 +268,89 @@ class ScrollController extends ChangeNotifier {
}
}
}
// Examples can assume:
// TrackingScrollController _trackingScrollController;
/// A [ScrollController] whose `initialScrollOffset` tracks its most recently
/// updated [ScrollPosition].
///
/// This class can be used to synchronize the scroll offset of two or more
/// lazily created scroll views that share a single [TrackingScrollController].
/// It tracks the most recently updated scroll position and reports it as its
/// `initialScrollOffset`.
///
/// ## Sample code
///
/// In this example each [PageView] page contains a [ListView] and all three
/// [ListView]'s share a [TrackingController]. The scroll offsets of all three
/// list views will track each other, to the extent that's possible given the
/// different list lengths.
///
/// ```dart
/// new PageView(
/// children: <Widget>[
/// new ListView(
/// controller: _trackingScrollController,
/// children: new List<Widget>.generate(100, (int i) => new Text('page 0 item $i')).toList(),
/// ),
/// new ListView(
/// controller: _trackingScrollController,
/// children: new List<Widget>.generate(200, (int i) => new Text('page 1 item $i')).toList(),
/// ),
/// new ListView(
/// controller: _trackingScrollController,
/// children: new List<Widget>.generate(300, (int i) => new Text('page 2 item $i')).toList(),
/// ),
/// ],
/// )
/// ```
///
/// In this example the `_trackingController` would have been created by the
/// stateful widget that built the widget tree.
class TrackingScrollController extends ScrollController {
TrackingScrollController({
double initialScrollOffset: 0.0,
bool keepScrollOffset: true,
String debugLabel,
}) : super(initialScrollOffset: initialScrollOffset,
keepScrollOffset: keepScrollOffset,
debugLabel: debugLabel);
Map<ScrollPosition, VoidCallback> _positionToListener = <ScrollPosition, VoidCallback>{};
ScrollPosition _lastUpdated;
/// The last [ScrollPosition] to change. Returns null if there aren't any
/// attached scroll positions or there hasn't been any scrolling yet.
ScrollPosition get mostRecentlyUpdatedPosition => _lastUpdated;
/// Returns the scroll offset of the [mostRecentlyUpdatedPosition] or 0.0.
@override
double get initialScrollOffset => _lastUpdated?.pixels ?? super.initialScrollOffset;
@override
void attach(ScrollPosition position) {
super.attach(position);
assert(!_positionToListener.containsKey(position));
_positionToListener[position] = () { _lastUpdated = position; };
position.addListener(_positionToListener[position]);
}
@override
void detach(ScrollPosition position) {
super.detach(position);
assert(_positionToListener.containsKey(position));
position.removeListener(_positionToListener[position]);
_positionToListener.remove(position);
}
@override
void dispose() {
for (ScrollPosition position in positions) {
assert(_positionToListener.containsKey(position));
position.removeListener(_positionToListener[position]);
}
_positionToListener.clear();
super.dispose();
}
}
......@@ -6,17 +6,18 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
Widget buildTest() {
Widget buildTest({ ScrollController controller, String title: 'TTTTTTTT' }) {
return new MediaQuery(
data: const MediaQueryData(),
child: new Scaffold(
body: new DefaultTabController(
length: 4,
child: new NestedScrollView(
controller: controller,
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return <Widget>[
new SliverAppBar(
title: const Text('TTTTTTTT'),
title: new Text(title),
pinned: true,
expandedHeight: 200.0,
forceElevated: innerBoxIsScrolled,
......@@ -183,4 +184,108 @@ void main() {
expect(find.text('ccc1'), findsOneWidget);
expect(tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 200.0);
});
testWidgets('NestedScrollView with a ScrollController', (WidgetTester tester) async {
final ScrollController controller = new ScrollController(initialScrollOffset: 50.0);
double scrollOffset;
controller.addListener(() {
scrollOffset = controller.offset;
});
await tester.pumpWidget(buildTest(controller: controller));
expect(controller.position.minScrollExtent, 0.0);
expect(controller.position.pixels, 50.0);
expect(controller.position.maxScrollExtent, 200.0);
// The appbar's expandedHeight - initialScrollOffset = 150.
expect(tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 150.0);
// Fully expand the appbar by scrolling (no animation) to 0.0.
controller.jumpTo(0.0);
await(tester.pumpAndSettle());
expect(scrollOffset, 0.0);
expect(tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 200.0);
// Scroll back to 50.0 animating over 100ms.
controller.animateTo(50.0, duration: const Duration(milliseconds: 100), curve: Curves.linear);
await tester.pump();
await tester.pump();
expect(scrollOffset, 0.0);
expect(tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 200.0);
await tester.pump(const Duration(milliseconds: 50)); // 50ms - halfway to scroll offset = 50.0.
expect(scrollOffset, 25.0);
expect(tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 175.0);
await tester.pump(const Duration(milliseconds: 50)); // 100ms - all the way to scroll offset = 50.0.
expect(scrollOffset, 50.0);
expect(tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 150.0);
// Scroll to the end, (we're not scrolling to the end of the list that contains aaa1,
// just to the end of the outer scrollview). Verify that the first item in each tab
// is still visible.
controller.jumpTo(controller.position.maxScrollExtent);
await tester.pumpAndSettle();
expect(scrollOffset, 200.0);
expect(find.text('aaa1'), findsOneWidget);
await tester.tap(find.text('BB'));
await tester.pumpAndSettle();
expect(find.text('bbb1'), findsOneWidget);
await tester.tap(find.text('CC'));
await tester.pumpAndSettle();
expect(find.text('ccc1'), findsOneWidget);
await tester.tap(find.text('DD'));
await tester.pumpAndSettle();
expect(find.text('ddd1'), findsOneWidget);
});
testWidgets('Three NestedScrollViews with one ScrollController', (WidgetTester tester) async {
final TrackingScrollController controller = new TrackingScrollController();
expect(controller.mostRecentlyUpdatedPosition, isNull);
expect(controller.initialScrollOffset, 0.0);
await tester.pumpWidget(
new PageView(
children: <Widget>[
buildTest(controller: controller, title: 'Page0'),
buildTest(controller: controller, title: 'Page1'),
buildTest(controller: controller, title: 'Page2'),
],
),
);
// Initially Page0 is visible and Page0's appbar is fully expanded (height = 200.0).
expect(find.text('Page0'), findsOneWidget);
expect(find.text('Page1'), findsNothing);
expect(find.text('Page2'), findsNothing);
expect(tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 200.0);
// A scroll collapses Page0's appbar to 150.0.
controller.jumpTo(50.0);
await(tester.pumpAndSettle());
expect(tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 150.0);
// Fling to Page1. Page1's appbar height is the same as the appbar for Page0.
await tester.fling(find.text('Page0'), const Offset(-100.0, 0.0), 10000.0);
await(tester.pumpAndSettle());
expect(find.text('Page0'), findsNothing);
expect(find.text('Page1'), findsOneWidget);
expect(find.text('Page2'), findsNothing);
expect(tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 150.0);
// Expand Page1's appbar and then fling to Page2. Page2's appbar appears
// fully expanded.
controller.jumpTo(0.0);
await(tester.pumpAndSettle());
expect(tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 200.0);
await tester.fling(find.text('Page1'), const Offset(-100.0, 0.0), 10000.0);
await(tester.pumpAndSettle());
expect(find.text('Page0'), findsNothing);
expect(find.text('Page1'), findsNothing);
expect(find.text('Page2'), findsOneWidget);
expect(tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 200.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