Skip to content

Commit daa7860

Browse files
author
Hans Muller
authored
Add a ScrollController parameter to NestedScrollView (flutter#11242)
1 parent 5f9e560 commit daa7860

File tree

3 files changed

+211
-17
lines changed

3 files changed

+211
-17
lines changed

packages/flutter/lib/src/widgets/nested_scroll_view.dart

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,10 @@ import 'ticker_provider.dart';
3232
/// content ostensibly below it.
3333
typedef List<Widget> NestedScrollViewHeaderSliversBuilder(BuildContext context, bool innerBoxIsScrolled);
3434

35-
// TODO(abarth): Make this configurable with a controller.
36-
const double _kInitialScrollOffset = 0.0;
37-
3835
class NestedScrollView extends StatefulWidget {
3936
const NestedScrollView({
4037
Key key,
38+
this.controller,
4139
this.scrollDirection: Axis.vertical,
4240
this.reverse: false,
4341
this.physics,
@@ -49,7 +47,9 @@ class NestedScrollView extends StatefulWidget {
4947
assert(body != null),
5048
super(key: key);
5149

52-
// TODO(ianh): we should expose a controller so you can call animateTo, etc.
50+
/// An object that can be used to control the position to which the outer
51+
/// scroll view is scrolled.
52+
final ScrollController controller;
5353

5454
/// The axis along which the scroll view scrolls.
5555
///
@@ -114,7 +114,7 @@ class _NestedScrollViewState extends State<NestedScrollView> {
114114
@override
115115
void initState() {
116116
super.initState();
117-
_coordinator = new _NestedScrollCoordinator(context, _kInitialScrollOffset);
117+
_coordinator = new _NestedScrollCoordinator(context, widget.controller);
118118
}
119119

120120
@override
@@ -170,12 +170,14 @@ class _NestedScrollMetrics extends FixedScrollMetrics {
170170
typedef ScrollActivity _NestedScrollActivityGetter(_NestedScrollPosition position);
171171

172172
class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldController {
173-
_NestedScrollCoordinator(this._context, double initialScrollOffset) {
173+
_NestedScrollCoordinator(this._context, this._parent) {
174+
final double initialScrollOffset = _parent?.initialScrollOffset ?? 0.0;
174175
_outerController = new _NestedScrollController(this, initialScrollOffset: initialScrollOffset, debugLabel: 'outer');
175-
_innerController = new _NestedScrollController(this, initialScrollOffset: initialScrollOffset, debugLabel: 'inner');
176+
_innerController = new _NestedScrollController(this, initialScrollOffset: 0.0, debugLabel: 'inner');
176177
}
177178

178179
final BuildContext _context;
180+
final ScrollController _parent;
179181
_NestedScrollController _outerController;
180182
_NestedScrollController _innerController;
181183

@@ -407,7 +409,7 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont
407409
Future<Null> animateTo(double to, {
408410
@required Duration duration,
409411
@required Curve curve,
410-
}) {
412+
}) async {
411413
final DrivenScrollActivity outerActivity = _outerPosition.createDrivenScrollActivity(
412414
nestOffset(to, _outerPosition),
413415
duration,
@@ -426,7 +428,7 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont
426428
return innerActivity;
427429
},
428430
);
429-
return Future.wait<Null>(resultFutures);
431+
await Future.wait<Null>(resultFutures);
430432
}
431433

432434
void jumpTo(double to) {
@@ -513,7 +515,7 @@ class _NestedScrollCoordinator implements ScrollActivityDelegate, ScrollHoldCont
513515
}
514516

515517
void updateParent() {
516-
_outerPosition?.setParent(PrimaryScrollController.of(_context));
518+
_outerPosition?.setParent(_parent ?? PrimaryScrollController.of(_context));
517519
}
518520

519521
@mustCallSuper
@@ -827,7 +829,6 @@ class _NestedOuterBallisticScrollActivity extends BallisticScrollActivity {
827829
done = true;
828830
}
829831
} else if (velocity < 0.0) {
830-
assert(velocity < 0.0);
831832
if (value > metrics.maxRange)
832833
return true;
833834
if (value < metrics.minRange) {

packages/flutter/lib/src/widgets/scroll_controller.dart

Lines changed: 91 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,12 @@ class ScrollController extends ChangeNotifier {
4545
///
4646
/// The values of `initialScrollOffset` and `keepScrollOffset` must not be null.
4747
ScrollController({
48-
this.initialScrollOffset: 0.0,
48+
double initialScrollOffset: 0.0,
4949
this.keepScrollOffset: true,
5050
this.debugLabel,
5151
}) : assert(initialScrollOffset != null),
52-
assert(keepScrollOffset != null);
52+
assert(keepScrollOffset != null),
53+
_initialScrollOffset = initialScrollOffset;
5354

5455
/// The initial value to use for [offset].
5556
///
@@ -58,7 +59,8 @@ class ScrollController extends ChangeNotifier {
5859
/// if [keepScrollOffset] is false or a scroll offset hasn't been saved yet.
5960
///
6061
/// Defaults to 0.0.
61-
final double initialScrollOffset;
62+
final double _initialScrollOffset;
63+
double get initialScrollOffset => _initialScrollOffset;
6264

6365
/// Each time a scroll completes, save the current scroll [offset] with
6466
/// [PageStorage] and restore it if this controller's scrollable is recreated.
@@ -266,3 +268,89 @@ class ScrollController extends ChangeNotifier {
266268
}
267269
}
268270
}
271+
272+
// Examples can assume:
273+
// TrackingScrollController _trackingScrollController;
274+
275+
/// A [ScrollController] whose `initialScrollOffset` tracks its most recently
276+
/// updated [ScrollPosition].
277+
///
278+
/// This class can be used to synchronize the scroll offset of two or more
279+
/// lazily created scroll views that share a single [TrackingScrollController].
280+
/// It tracks the most recently updated scroll position and reports it as its
281+
/// `initialScrollOffset`.
282+
///
283+
/// ## Sample code
284+
///
285+
/// In this example each [PageView] page contains a [ListView] and all three
286+
/// [ListView]'s share a [TrackingController]. The scroll offsets of all three
287+
/// list views will track each other, to the extent that's possible given the
288+
/// different list lengths.
289+
///
290+
/// ```dart
291+
/// new PageView(
292+
/// children: <Widget>[
293+
/// new ListView(
294+
/// controller: _trackingScrollController,
295+
/// children: new List<Widget>.generate(100, (int i) => new Text('page 0 item $i')).toList(),
296+
/// ),
297+
/// new ListView(
298+
/// controller: _trackingScrollController,
299+
/// children: new List<Widget>.generate(200, (int i) => new Text('page 1 item $i')).toList(),
300+
/// ),
301+
/// new ListView(
302+
/// controller: _trackingScrollController,
303+
/// children: new List<Widget>.generate(300, (int i) => new Text('page 2 item $i')).toList(),
304+
/// ),
305+
/// ],
306+
/// )
307+
/// ```
308+
///
309+
/// In this example the `_trackingController` would have been created by the
310+
/// stateful widget that built the widget tree.
311+
class TrackingScrollController extends ScrollController {
312+
TrackingScrollController({
313+
double initialScrollOffset: 0.0,
314+
bool keepScrollOffset: true,
315+
String debugLabel,
316+
}) : super(initialScrollOffset: initialScrollOffset,
317+
keepScrollOffset: keepScrollOffset,
318+
debugLabel: debugLabel);
319+
320+
Map<ScrollPosition, VoidCallback> _positionToListener = <ScrollPosition, VoidCallback>{};
321+
ScrollPosition _lastUpdated;
322+
323+
/// The last [ScrollPosition] to change. Returns null if there aren't any
324+
/// attached scroll positions or there hasn't been any scrolling yet.
325+
ScrollPosition get mostRecentlyUpdatedPosition => _lastUpdated;
326+
327+
/// Returns the scroll offset of the [mostRecentlyUpdatedPosition] or 0.0.
328+
@override
329+
double get initialScrollOffset => _lastUpdated?.pixels ?? super.initialScrollOffset;
330+
331+
@override
332+
void attach(ScrollPosition position) {
333+
super.attach(position);
334+
assert(!_positionToListener.containsKey(position));
335+
_positionToListener[position] = () { _lastUpdated = position; };
336+
position.addListener(_positionToListener[position]);
337+
}
338+
339+
@override
340+
void detach(ScrollPosition position) {
341+
super.detach(position);
342+
assert(_positionToListener.containsKey(position));
343+
position.removeListener(_positionToListener[position]);
344+
_positionToListener.remove(position);
345+
}
346+
347+
@override
348+
void dispose() {
349+
for (ScrollPosition position in positions) {
350+
assert(_positionToListener.containsKey(position));
351+
position.removeListener(_positionToListener[position]);
352+
}
353+
_positionToListener.clear();
354+
super.dispose();
355+
}
356+
}

packages/flutter/test/widgets/nested_scroll_view_test.dart

Lines changed: 108 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,18 @@ import 'package:flutter/foundation.dart';
66
import 'package:flutter/material.dart';
77
import 'package:flutter_test/flutter_test.dart';
88

9-
Widget buildTest() {
9+
Widget buildTest({ ScrollController controller, String title: 'TTTTTTTT' }) {
1010
return new MediaQuery(
1111
data: const MediaQueryData(),
1212
child: new Scaffold(
1313
body: new DefaultTabController(
1414
length: 4,
1515
child: new NestedScrollView(
16+
controller: controller,
1617
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
1718
return <Widget>[
1819
new SliverAppBar(
19-
title: const Text('TTTTTTTT'),
20+
title: new Text(title),
2021
pinned: true,
2122
expandedHeight: 200.0,
2223
forceElevated: innerBoxIsScrolled,
@@ -183,4 +184,108 @@ void main() {
183184
expect(find.text('ccc1'), findsOneWidget);
184185
expect(tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 200.0);
185186
});
186-
}
187+
188+
testWidgets('NestedScrollView with a ScrollController', (WidgetTester tester) async {
189+
final ScrollController controller = new ScrollController(initialScrollOffset: 50.0);
190+
191+
double scrollOffset;
192+
controller.addListener(() {
193+
scrollOffset = controller.offset;
194+
});
195+
196+
await tester.pumpWidget(buildTest(controller: controller));
197+
expect(controller.position.minScrollExtent, 0.0);
198+
expect(controller.position.pixels, 50.0);
199+
expect(controller.position.maxScrollExtent, 200.0);
200+
201+
// The appbar's expandedHeight - initialScrollOffset = 150.
202+
expect(tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 150.0);
203+
204+
// Fully expand the appbar by scrolling (no animation) to 0.0.
205+
controller.jumpTo(0.0);
206+
await(tester.pumpAndSettle());
207+
expect(scrollOffset, 0.0);
208+
expect(tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 200.0);
209+
210+
// Scroll back to 50.0 animating over 100ms.
211+
controller.animateTo(50.0, duration: const Duration(milliseconds: 100), curve: Curves.linear);
212+
await tester.pump();
213+
await tester.pump();
214+
expect(scrollOffset, 0.0);
215+
expect(tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 200.0);
216+
await tester.pump(const Duration(milliseconds: 50)); // 50ms - halfway to scroll offset = 50.0.
217+
expect(scrollOffset, 25.0);
218+
expect(tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 175.0);
219+
await tester.pump(const Duration(milliseconds: 50)); // 100ms - all the way to scroll offset = 50.0.
220+
expect(scrollOffset, 50.0);
221+
expect(tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 150.0);
222+
223+
// Scroll to the end, (we're not scrolling to the end of the list that contains aaa1,
224+
// just to the end of the outer scrollview). Verify that the first item in each tab
225+
// is still visible.
226+
controller.jumpTo(controller.position.maxScrollExtent);
227+
await tester.pumpAndSettle();
228+
expect(scrollOffset, 200.0);
229+
expect(find.text('aaa1'), findsOneWidget);
230+
231+
await tester.tap(find.text('BB'));
232+
await tester.pumpAndSettle();
233+
expect(find.text('bbb1'), findsOneWidget);
234+
235+
await tester.tap(find.text('CC'));
236+
await tester.pumpAndSettle();
237+
expect(find.text('ccc1'), findsOneWidget);
238+
239+
await tester.tap(find.text('DD'));
240+
await tester.pumpAndSettle();
241+
expect(find.text('ddd1'), findsOneWidget);
242+
});
243+
244+
testWidgets('Three NestedScrollViews with one ScrollController', (WidgetTester tester) async {
245+
final TrackingScrollController controller = new TrackingScrollController();
246+
expect(controller.mostRecentlyUpdatedPosition, isNull);
247+
expect(controller.initialScrollOffset, 0.0);
248+
249+
await tester.pumpWidget(
250+
new PageView(
251+
children: <Widget>[
252+
buildTest(controller: controller, title: 'Page0'),
253+
buildTest(controller: controller, title: 'Page1'),
254+
buildTest(controller: controller, title: 'Page2'),
255+
],
256+
),
257+
);
258+
259+
// Initially Page0 is visible and Page0's appbar is fully expanded (height = 200.0).
260+
expect(find.text('Page0'), findsOneWidget);
261+
expect(find.text('Page1'), findsNothing);
262+
expect(find.text('Page2'), findsNothing);
263+
expect(tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 200.0);
264+
265+
// A scroll collapses Page0's appbar to 150.0.
266+
controller.jumpTo(50.0);
267+
await(tester.pumpAndSettle());
268+
expect(tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 150.0);
269+
270+
// Fling to Page1. Page1's appbar height is the same as the appbar for Page0.
271+
await tester.fling(find.text('Page0'), const Offset(-100.0, 0.0), 10000.0);
272+
await(tester.pumpAndSettle());
273+
expect(find.text('Page0'), findsNothing);
274+
expect(find.text('Page1'), findsOneWidget);
275+
expect(find.text('Page2'), findsNothing);
276+
expect(tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 150.0);
277+
278+
// Expand Page1's appbar and then fling to Page2. Page2's appbar appears
279+
// fully expanded.
280+
controller.jumpTo(0.0);
281+
await(tester.pumpAndSettle());
282+
expect(tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 200.0);
283+
await tester.fling(find.text('Page1'), const Offset(-100.0, 0.0), 10000.0);
284+
await(tester.pumpAndSettle());
285+
expect(find.text('Page0'), findsNothing);
286+
expect(find.text('Page1'), findsNothing);
287+
expect(find.text('Page2'), findsOneWidget);
288+
expect(tester.renderObject<RenderBox>(find.byType(AppBar)).size.height, 200.0);
289+
});
290+
291+
}

0 commit comments

Comments
 (0)