// 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. // This file contains a wacky demonstration of creating a custom ScrollPosition // setup. It's testing that we don't regress the factoring of the // ScrollPosition/ScrollActivity logic into a state where you can no longer // implement this, e.g. by oversimplifying it or overfitting it to the features // built into the framework itself. import 'dart:collection'; import 'dart:math' as math; import 'package:flutter/rendering.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; class LinkedScrollController extends ScrollController { LinkedScrollController({ this.before, this.after }); LinkedScrollController before; LinkedScrollController after; ScrollController _parent; void setParent(ScrollController newParent) { if (_parent != null) { for (ScrollPosition position in positions) _parent.detach(position); } _parent = newParent; if (_parent != null) { for (ScrollPosition position in positions) _parent.attach(position); } } @override void attach(ScrollPosition position) { assert(position is LinkedScrollPosition, 'A LinkedScrollController must only be used with LinkedScrollPositions.'); final LinkedScrollPosition linkedPosition = position; assert(linkedPosition.owner == this, 'A LinkedScrollPosition cannot change controllers once created.'); super.attach(position); _parent?.attach(position); } @override void detach(ScrollPosition position) { super.detach(position); _parent?.detach(position); } @override void dispose() { if (_parent != null) { for (ScrollPosition position in positions) _parent.detach(position); } super.dispose(); } @override LinkedScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition oldPosition) { return new LinkedScrollPosition( this, physics: physics, context: context, initialPixels: initialScrollOffset, oldPosition: oldPosition, ); } bool get canLinkWithBefore => before != null && before.hasClients; bool get canLinkWithAfter => after != null && after.hasClients; Iterable<LinkedScrollActivity> linkWithBefore(LinkedScrollPosition driver) { assert(canLinkWithBefore); return before.link(driver); } Iterable<LinkedScrollActivity> linkWithAfter(LinkedScrollPosition driver) { assert(canLinkWithAfter); return after.link(driver); } Iterable<LinkedScrollActivity> link(LinkedScrollPosition driver) sync* { assert(hasClients); for (LinkedScrollPosition position in positions) yield position.link(driver); } @override void debugFillDescription(List<String> description) { super.debugFillDescription(description); if (before != null && after != null) { description.add('links: ⬌'); } else if (before != null) { description.add('links: ⬅'); } else if (after != null) { description.add('links: ➡'); } else { description.add('links: none'); } } } class LinkedScrollPosition extends ScrollPositionWithSingleContext { LinkedScrollPosition(this.owner, { ScrollPhysics physics, ScrollContext context, double initialPixels, ScrollPosition oldPosition, }) : assert(owner != null), super( physics: physics, context: context, initialPixels: initialPixels, oldPosition: oldPosition, ); final LinkedScrollController owner; Set<LinkedScrollActivity> _beforeActivities; Set<LinkedScrollActivity> _afterActivities; @override void beginActivity(ScrollActivity newActivity) { if (newActivity == null) return; if (_beforeActivities != null) { for (LinkedScrollActivity activity in _beforeActivities) activity.unlink(this); _beforeActivities.clear(); } if (_afterActivities != null) { for (LinkedScrollActivity activity in _afterActivities) activity.unlink(this); _afterActivities.clear(); } super.beginActivity(newActivity); } @override void applyUserOffset(double delta) { updateUserScrollDirection(delta > 0.0 ? ScrollDirection.forward : ScrollDirection.reverse); final double value = pixels - physics.applyPhysicsToUserOffset(this, delta); if (value == pixels) return; double beforeOverscroll = 0.0; if (owner.canLinkWithBefore && (value < minScrollExtent)) { final double delta = value - minScrollExtent; _beforeActivities ??= new HashSet<LinkedScrollActivity>(); _beforeActivities.addAll(owner.linkWithBefore(this)); for (LinkedScrollActivity activity in _beforeActivities) beforeOverscroll = math.min(activity.moveBy(delta), beforeOverscroll); assert(beforeOverscroll <= 0.0); } double afterOverscroll = 0.0; if (owner.canLinkWithAfter && (value > maxScrollExtent)) { final double delta = value - maxScrollExtent; _afterActivities ??= new HashSet<LinkedScrollActivity>(); _afterActivities.addAll(owner.linkWithAfter(this)); for (LinkedScrollActivity activity in _afterActivities) afterOverscroll = math.max(activity.moveBy(delta), afterOverscroll); assert(afterOverscroll >= 0.0); } assert(beforeOverscroll == 0.0 || afterOverscroll == 0.0); final double localOverscroll = setPixels(value.clamp( owner.canLinkWithBefore ? minScrollExtent : -double.INFINITY, owner.canLinkWithAfter ? maxScrollExtent : double.INFINITY, )); assert(localOverscroll == 0.0 || (beforeOverscroll == 0.0 && afterOverscroll == 0.0)); } LinkedScrollActivity link(LinkedScrollPosition driver) { if (this.activity is! LinkedScrollActivity) beginActivity(new LinkedScrollActivity(this)); final LinkedScrollActivity activity = this.activity; activity.link(driver); return activity; } void unlink(LinkedScrollActivity activity) { if (_beforeActivities != null) _beforeActivities.remove(activity); if (_afterActivities != null) _afterActivities.remove(activity); } @override void debugFillDescription(List<String> description) { super.debugFillDescription(description); description.add('owner: $owner'); } } class LinkedScrollActivity extends ScrollActivity { LinkedScrollActivity( LinkedScrollPosition delegate, ) : super(delegate); @override LinkedScrollPosition get delegate => super.delegate; final Set<LinkedScrollPosition> drivers = new HashSet<LinkedScrollPosition>(); void link(LinkedScrollPosition driver) { drivers.add(driver); } void unlink(LinkedScrollPosition driver) { drivers.remove(driver); if (drivers.isEmpty) delegate?.goIdle(); } @override bool get shouldIgnorePointer => true; @override bool get isScrolling => true; double moveBy(double delta) { assert(drivers.isNotEmpty); ScrollDirection commonDirection; for (LinkedScrollPosition driver in drivers) { commonDirection ??= driver.userScrollDirection; if (driver.userScrollDirection != commonDirection) commonDirection = ScrollDirection.idle; } delegate.updateUserScrollDirection(commonDirection); return delegate.setPixels(delegate.pixels + delta); } @override void dispose() { for (LinkedScrollPosition driver in drivers) driver.unlink(this); super.dispose(); } } class Test extends StatefulWidget { @override _TestState createState() => new _TestState(); } class _TestState extends State<Test> { LinkedScrollController _beforeController; LinkedScrollController _afterController; @override void initState() { super.initState(); _beforeController = new LinkedScrollController(); _afterController = new LinkedScrollController(before: _beforeController); _beforeController.after = _afterController; } @override void didChangeDependencies() { super.didChangeDependencies(); _beforeController.setParent(PrimaryScrollController.of(context)); _afterController.setParent(PrimaryScrollController.of(context)); } @override void dispose() { _beforeController.dispose(); _afterController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return new Column( children: <Widget>[ new Expanded( child: new ListView( controller: _beforeController, children: <Widget>[ new Container( margin: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0), height: 250.0, color: const Color(0xFF90F090), child: const Center(child: const Text('Hello A')), ), new Container( margin: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0), height: 250.0, color: const Color(0xFF90F090), child: const Center(child: const Text('Hello B')), ), new Container( margin: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0), height: 250.0, color: const Color(0xFF90F090), child: const Center(child: const Text('Hello C')), ), new Container( margin: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0), height: 250.0, color: const Color(0xFF90F090), child: const Center(child: const Text('Hello D')), ), ], ), ), const Divider(), new Expanded( child: new ListView( controller: _afterController, children: <Widget>[ new Container( margin: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0), height: 250.0, color: const Color(0xFF9090F0), child: const Center(child: const Text('Hello 1')), ), new Container( margin: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0), height: 250.0, color: const Color(0xFF9090F0), child: const Center(child: const Text('Hello 2')), ), new Container( margin: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0), height: 250.0, color: const Color(0xFF9090F0), child: const Center(child: const Text('Hello 3')), ), new Container( margin: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0), height: 250.0, color: const Color(0xFF9090F0), child: const Center(child: const Text('Hello 4')), ), ], ), ), ], ); } } void main() { testWidgets('LinkedScrollController - 1', (WidgetTester tester) async { await tester.pumpWidget(new Test()); expect(find.text('Hello A'), findsOneWidget); expect(find.text('Hello 1'), findsOneWidget); expect(find.text('Hello D'), findsNothing); expect(find.text('Hello 4'), findsNothing); await tester.pump(const Duration(seconds: 2)); await tester.fling(find.text('Hello A'), const Offset(0.0, -50.0), 10000.0); await tester.pumpAndSettle(); await tester.pump(const Duration(seconds: 2)); expect(find.text('Hello A'), findsNothing); expect(find.text('Hello 1'), findsOneWidget); expect(find.text('Hello D'), findsOneWidget); expect(find.text('Hello 4'), findsNothing); await tester.pump(const Duration(seconds: 2)); await tester.drag(find.text('Hello D'), const Offset(0.0, -10000.0)); await tester.pump(const Duration(seconds: 2)); expect(find.text('Hello A'), findsNothing); expect(find.text('Hello 1'), findsNothing); expect(find.text('Hello D'), findsOneWidget); expect(find.text('Hello 4'), findsOneWidget); await tester.pump(const Duration(seconds: 2)); await tester.drag(find.text('Hello D'), const Offset(0.0, -10000.0)); await tester.pump(const Duration(seconds: 2)); expect(find.text('Hello A'), findsNothing); expect(find.text('Hello 1'), findsNothing); expect(find.text('Hello D'), findsOneWidget); expect(find.text('Hello 4'), findsOneWidget); await tester.pump(const Duration(seconds: 2)); await tester.drag(find.text('Hello 4'), const Offset(0.0, -10000.0)); await tester.pump(const Duration(seconds: 2)); expect(find.text('Hello A'), findsNothing); expect(find.text('Hello 1'), findsNothing); expect(find.text('Hello D'), findsOneWidget); expect(find.text('Hello 4'), findsOneWidget); await tester.pump(const Duration(seconds: 2)); await tester.drag(find.text('Hello D'), const Offset(0.0, 10000.0)); await tester.pump(const Duration(seconds: 2)); expect(find.text('Hello A'), findsOneWidget); expect(find.text('Hello 1'), findsNothing); expect(find.text('Hello D'), findsNothing); expect(find.text('Hello 4'), findsOneWidget); await tester.pump(const Duration(seconds: 2)); await tester.drag(find.text('Hello A'), const Offset(0.0, 10000.0)); await tester.pump(const Duration(seconds: 2)); expect(find.text('Hello A'), findsOneWidget); expect(find.text('Hello 1'), findsNothing); expect(find.text('Hello D'), findsNothing); expect(find.text('Hello 4'), findsOneWidget); await tester.pump(const Duration(seconds: 2)); await tester.drag(find.text('Hello A'), const Offset(0.0, -10000.0)); await tester.pump(const Duration(seconds: 2)); expect(find.text('Hello A'), findsNothing); expect(find.text('Hello 1'), findsNothing); expect(find.text('Hello D'), findsOneWidget); expect(find.text('Hello 4'), findsOneWidget); await tester.pump(const Duration(seconds: 2)); await tester.drag(find.text('Hello 4'), const Offset(0.0, 10000.0)); await tester.pump(const Duration(seconds: 2)); expect(find.text('Hello A'), findsOneWidget); expect(find.text('Hello 1'), findsOneWidget); expect(find.text('Hello D'), findsNothing); expect(find.text('Hello 4'), findsNothing); await tester.pump(const Duration(seconds: 2)); await tester.drag(find.text('Hello 1'), const Offset(0.0, 10000.0)); await tester.pump(const Duration(seconds: 2)); expect(find.text('Hello A'), findsOneWidget); expect(find.text('Hello 1'), findsOneWidget); expect(find.text('Hello D'), findsNothing); expect(find.text('Hello 4'), findsNothing); await tester.pump(const Duration(seconds: 2)); await tester.drag(find.text('Hello 1'), const Offset(0.0, -10000.0)); await tester.pump(const Duration(seconds: 2)); expect(find.text('Hello A'), findsOneWidget); expect(find.text('Hello 1'), findsNothing); expect(find.text('Hello D'), findsNothing); expect(find.text('Hello 4'), findsOneWidget); }); testWidgets('LinkedScrollController - 2', (WidgetTester tester) async { await tester.pumpWidget(new Test()); expect(find.text('Hello A'), findsOneWidget); expect(find.text('Hello B'), findsOneWidget); expect(find.text('Hello C'), findsNothing); expect(find.text('Hello D'), findsNothing); expect(find.text('Hello 1'), findsOneWidget); expect(find.text('Hello 2'), findsOneWidget); expect(find.text('Hello 3'), findsNothing); expect(find.text('Hello 4'), findsNothing); final TestGesture gestureTop = await tester.startGesture(const Offset(200.0, 150.0)); final TestGesture gestureBottom = await tester.startGesture(const Offset(600.0, 450.0)); await tester.pump(const Duration(seconds: 1)); await gestureTop.moveBy(const Offset(0.0, -270.0)); await tester.pump(const Duration(seconds: 1)); expect(find.text('Hello A'), findsNothing); expect(find.text('Hello B'), findsOneWidget); expect(find.text('Hello C'), findsOneWidget); expect(find.text('Hello D'), findsNothing); expect(find.text('Hello 1'), findsOneWidget); expect(find.text('Hello 2'), findsOneWidget); expect(find.text('Hello 3'), findsNothing); expect(find.text('Hello 4'), findsNothing); await gestureBottom.moveBy(const Offset(0.0, -270.0)); await tester.pump(const Duration(seconds: 1)); expect(find.text('Hello A'), findsNothing); expect(find.text('Hello B'), findsOneWidget); expect(find.text('Hello C'), findsOneWidget); expect(find.text('Hello D'), findsNothing); expect(find.text('Hello 1'), findsNothing); expect(find.text('Hello 2'), findsOneWidget); expect(find.text('Hello 3'), findsOneWidget); expect(find.text('Hello 4'), findsNothing); await gestureTop.moveBy(const Offset(0.0, -270.0)); await gestureBottom.moveBy(const Offset(0.0, -270.0)); await tester.pump(const Duration(seconds: 1)); expect(find.text('Hello A'), findsNothing); expect(find.text('Hello B'), findsNothing); expect(find.text('Hello C'), findsOneWidget); expect(find.text('Hello D'), findsOneWidget); expect(find.text('Hello 1'), findsNothing); expect(find.text('Hello 2'), findsNothing); expect(find.text('Hello 3'), findsOneWidget); expect(find.text('Hello 4'), findsOneWidget); await gestureTop.moveBy(const Offset(0.0, 270.0)); await gestureBottom.moveBy(const Offset(0.0, 270.0)); await tester.pump(const Duration(seconds: 1)); expect(find.text('Hello A'), findsNothing); expect(find.text('Hello B'), findsOneWidget); expect(find.text('Hello C'), findsOneWidget); expect(find.text('Hello D'), findsNothing); expect(find.text('Hello 1'), findsNothing); expect(find.text('Hello 2'), findsOneWidget); expect(find.text('Hello 3'), findsOneWidget); expect(find.text('Hello 4'), findsNothing); await gestureBottom.moveBy(const Offset(0.0, 270.0)); await tester.pump(const Duration(seconds: 1)); expect(find.text('Hello A'), findsNothing); expect(find.text('Hello B'), findsOneWidget); expect(find.text('Hello C'), findsOneWidget); expect(find.text('Hello D'), findsNothing); expect(find.text('Hello 1'), findsOneWidget); expect(find.text('Hello 2'), findsOneWidget); expect(find.text('Hello 3'), findsNothing); expect(find.text('Hello 4'), findsNothing); await gestureBottom.moveBy(const Offset(0.0, 50.0)); await tester.pump(const Duration(seconds: 1)); expect(find.text('Hello A'), findsOneWidget); expect(find.text('Hello B'), findsOneWidget); expect(find.text('Hello C'), findsNothing); expect(find.text('Hello D'), findsNothing); expect(find.text('Hello 1'), findsOneWidget); expect(find.text('Hello 2'), findsOneWidget); expect(find.text('Hello 3'), findsNothing); expect(find.text('Hello 4'), findsNothing); await gestureBottom.moveBy(const Offset(0.0, 50.0)); await tester.pump(const Duration(seconds: 1)); expect(find.text('Hello A'), findsOneWidget); expect(find.text('Hello B'), findsOneWidget); expect(find.text('Hello C'), findsNothing); expect(find.text('Hello D'), findsNothing); expect(find.text('Hello 1'), findsOneWidget); expect(find.text('Hello 2'), findsOneWidget); expect(find.text('Hello 3'), findsNothing); expect(find.text('Hello 4'), findsNothing); await gestureBottom.moveBy(const Offset(0.0, 50.0)); await tester.pump(const Duration(seconds: 1)); expect(find.text('Hello A'), findsOneWidget); expect(find.text('Hello B'), findsOneWidget); expect(find.text('Hello C'), findsNothing); expect(find.text('Hello D'), findsNothing); expect(find.text('Hello 1'), findsOneWidget); expect(find.text('Hello 2'), findsOneWidget); expect(find.text('Hello 3'), findsNothing); expect(find.text('Hello 4'), findsNothing); await gestureTop.moveBy(const Offset(0.0, -270.0)); expect(find.text('Hello A'), findsOneWidget); expect(find.text('Hello B'), findsOneWidget); expect(find.text('Hello C'), findsNothing); expect(find.text('Hello D'), findsNothing); expect(find.text('Hello 1'), findsOneWidget); expect(find.text('Hello 2'), findsOneWidget); expect(find.text('Hello 3'), findsNothing); expect(find.text('Hello 4'), findsNothing); await tester.pump(const Duration(seconds: 1)); await tester.pump(const Duration(seconds: 60)); }); }