blob: 7d1274c71928165351c417ac0e5209b8b012f944 [file] [log] [blame]
// 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 ScrollPhysics physics,
required ScrollContext context,
required 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 (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 delegate,
) : 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({ Key? key }) : super(key: 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.of(context));
_afterController.setParent(PrimaryScrollController.of(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));
});
}