flutter_flutter/packages/flutter/test/widgets/linked_scroll_view_test.dart
Navaron Bracke 906ffa3a2c
remove unused divider (#180990)
This PR removes a single Divider from one test, since it was not adding
anything noteworthy to the test.

Part of https://github.com/flutter/flutter/issues/177415
Part of https://github.com/flutter/flutter/issues/180501

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

**Note**: The Flutter team is currently trialing the use of [Gemini Code
Assist for
GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code).
Comments from the `gemini-code-assist` bot should not be taken as
authoritative feedback from the Flutter team. If you find its comments
useful you can update your code accordingly, but if you are unsure or
disagree with the feedback, please feel free to wait for a Flutter team
member's review for guidance on which automated comments should be
addressed.

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
2026-01-18 10:30:09 +00:00

572 lines
20 KiB
Dart

// 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/rendering.dart';
import 'package:flutter/widgets.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 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);
final String linkSymbol = switch ((before, after)) {
(null, null) => 'none',
(null, _) => '',
(_, null) => '',
(_, _) => '',
};
description.add('links: $linkSymbol');
}
}
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;
}
var 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);
}
var 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 activity = this.activity as LinkedScrollActivity?;
activity!.link(driver);
return activity;
}
void unlink(LinkedScrollActivity activity) {
_beforeActivities?.remove(activity);
_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')),
),
],
),
),
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));
});
}