// Copyright 2014 The Flutter 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/material.dart'; import 'package:flutter/rendering.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) { positions.forEach(_parent!.detach); } _parent = newParent; if (_parent != null) { positions.forEach(_parent!.attach); } } @override void attach(ScrollPosition position) { assert(position is LinkedScrollPosition, 'A LinkedScrollController must only be used with LinkedScrollPositions.'); final LinkedScrollPosition linkedPosition = position as LinkedScrollPosition; 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) { positions.forEach(_parent!.detach); } super.dispose(); } @override LinkedScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition? oldPosition) { return 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 (final LinkedScrollPosition position in positions.cast<LinkedScrollPosition>()) { 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, { required super.physics, required super.context, required double super.initialPixels, super.oldPosition, }); final LinkedScrollController owner; Set<LinkedScrollActivity>? _beforeActivities; Set<LinkedScrollActivity>? _afterActivities; @override void beginActivity(ScrollActivity? newActivity) { if (newActivity == null) { return; } if (_beforeActivities != null) { for (final LinkedScrollActivity activity in _beforeActivities!) { activity.unlink(this); } _beforeActivities!.clear(); } if (_afterActivities != null) { for (final 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 ??= HashSet<LinkedScrollActivity>(); _beforeActivities!.addAll(owner.linkWithBefore(this)); for (final 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 ??= HashSet<LinkedScrollActivity>(); _afterActivities!.addAll(owner.linkWithAfter(this)); for (final 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)); } void _userMoved(ScrollDirection direction) { updateUserScrollDirection(direction); } LinkedScrollActivity link(LinkedScrollPosition driver) { if (this.activity is! LinkedScrollActivity) { beginActivity(LinkedScrollActivity(this)); } final LinkedScrollActivity? activity = this.activity as LinkedScrollActivity?; 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 super.delegate, ); @override LinkedScrollPosition get delegate => super.delegate as LinkedScrollPosition; final Set<LinkedScrollPosition> drivers = 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; // LinkedScrollActivity is not self-driven but moved by calls to the [moveBy] // method. @override double get velocity => 0.0; double moveBy(double delta) { assert(drivers.isNotEmpty); ScrollDirection? commonDirection; for (final LinkedScrollPosition driver in drivers) { commonDirection ??= driver.userScrollDirection; if (driver.userScrollDirection != commonDirection) { commonDirection = ScrollDirection.idle; } } if (commonDirection != null) { delegate._userMoved(commonDirection); } return delegate.setPixels(delegate.pixels + delta); } @override void dispose() { for (final LinkedScrollPosition driver in drivers) { driver.unlink(this); } super.dispose(); } } class Test extends StatefulWidget { const Test({ super.key }); @override State<Test> createState() => _TestState(); } class _TestState extends State<Test> { late LinkedScrollController _beforeController; late LinkedScrollController _afterController; @override void initState() { super.initState(); _beforeController = LinkedScrollController(); _afterController = LinkedScrollController(before: _beforeController); _beforeController.after = _afterController; } @override void didChangeDependencies() { super.didChangeDependencies(); _beforeController.setParent(PrimaryScrollController.maybeOf(context)); _afterController.setParent(PrimaryScrollController.maybeOf(context)); } @override void dispose() { _beforeController.dispose(); _afterController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Directionality( textDirection: TextDirection.ltr, child: Column( children: <Widget>[ Expanded( child: ListView( controller: _beforeController, children: <Widget>[ Container( margin: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0), height: 250.0, color: const Color(0xFF90F090), child: const Center(child: Text('Hello A')), ), Container( margin: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0), height: 250.0, color: const Color(0xFF90F090), child: const Center(child: Text('Hello B')), ), Container( margin: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0), height: 250.0, color: const Color(0xFF90F090), child: const Center(child: Text('Hello C')), ), Container( margin: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0), height: 250.0, color: const Color(0xFF90F090), child: const Center(child: Text('Hello D')), ), ], ), ), const Divider(), Expanded( child: ListView( controller: _afterController, children: <Widget>[ Container( margin: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0), height: 250.0, color: const Color(0xFF9090F0), child: const Center(child: Text('Hello 1')), ), Container( margin: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0), height: 250.0, color: const Color(0xFF9090F0), child: const Center(child: Text('Hello 2')), ), Container( margin: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0), height: 250.0, color: const Color(0xFF9090F0), child: const Center(child: Text('Hello 3')), ), Container( margin: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0), height: 250.0, color: const Color(0xFF9090F0), child: const Center(child: Text('Hello 4')), ), ], ), ), ], ), ); } } void main() { testWidgets('LinkedScrollController - 1', (WidgetTester tester) async { await tester.pumpWidget(const 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(const 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)); }); }