blob: dd571a406119e53111834eb1d2114fa0dcc5a9f0 [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.
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
class ExpandingBox extends StatefulWidget {
const ExpandingBox({ Key? key, required this.collapsedSize, required this.expandedSize }) : super(key: key);
final double collapsedSize;
final double expandedSize;
@override
State<ExpandingBox> createState() => _ExpandingBoxState();
}
class _ExpandingBoxState extends State<ExpandingBox> with AutomaticKeepAliveClientMixin<ExpandingBox> {
late double _height;
@override
void initState() {
super.initState();
_height = widget.collapsedSize;
}
void toggleSize() {
setState(() {
_height = _height == widget.collapsedSize ? widget.expandedSize : widget.collapsedSize;
});
}
@override
Widget build(BuildContext context) {
super.build(context);
return Container(
height: _height,
color: Colors.green,
child: Align(
alignment: Alignment.bottomCenter,
child: TextButton(
onPressed: toggleSize,
child: const Text('Collapse'),
),
),
);
}
@override
bool get wantKeepAlive => true;
}
void main() {
testWidgets('shrink listview', (WidgetTester tester) async {
await tester.pumpWidget(MaterialApp(
home: ListView.builder(
itemBuilder: (BuildContext context, int index) => index == 0
? const ExpandingBox(collapsedSize: 400, expandedSize: 1200)
: Container(height: 300, color: Colors.red),
itemCount: 2,
),
));
final ScrollPosition position = tester.state<ScrollableState>(find.byType(Scrollable)).position;
expect(position.activity, isInstanceOf<IdleScrollActivity>());
expect(position.minScrollExtent, 0.0);
expect(position.maxScrollExtent, 100.0);
expect(position.pixels, 0.0);
await tester.tap(find.byType(TextButton));
await tester.pump();
final TestGesture drag1 = await tester.startGesture(const Offset(10.0, 500.0));
await tester.pump();
await drag1.moveTo(const Offset(10.0, 0.0));
await tester.pump();
await drag1.up();
await tester.pump();
expect(position.pixels, moreOrLessEquals(500.0));
expect(position.minScrollExtent, 0.0);
expect(position.maxScrollExtent, 900.0);
final TestGesture drag2 = await tester.startGesture(const Offset(10.0, 500.0));
await tester.pump();
await drag2.moveTo(const Offset(10.0, 100.0));
await tester.pump();
await drag2.up();
await tester.pump();
expect(position.maxScrollExtent, 900.0);
expect(position.pixels, moreOrLessEquals(900.0));
await tester.pump();
await tester.tap(find.byType(TextButton));
await tester.pump();
expect(position.minScrollExtent, 0.0);
expect(position.maxScrollExtent, 100.0);
expect(position.pixels, 100.0);
});
testWidgets('shrink listview while dragging', (WidgetTester tester) async {
await tester.pumpWidget(MaterialApp(
home: ListView.builder(
itemBuilder: (BuildContext context, int index) => index == 0
? const ExpandingBox(collapsedSize: 400, expandedSize: 1200)
: Container(height: 300, color: Colors.red),
itemCount: 2,
),
));
final ScrollPosition position = tester.state<ScrollableState>(find.byType(Scrollable)).position;
expect(position.activity, isInstanceOf<IdleScrollActivity>());
expect(position.minScrollExtent, 0.0);
expect(position.maxScrollExtent, 100.0);
expect(position.pixels, 0.0);
await tester.tap(find.byType(TextButton));
await tester.pump(); // start button animation
await tester.pump(const Duration(seconds: 1)); // finish button animation
expect(position.minScrollExtent, 0.0);
expect(position.maxScrollExtent, 1800.0);
expect(position.pixels, 0.0);
final TestGesture drag1 = await tester.startGesture(const Offset(10.0, 500.0));
expect(await tester.pumpAndSettle(), 1); // Nothing to animate
await drag1.moveTo(const Offset(10.0, 0.0));
expect(await tester.pumpAndSettle(), 2); // Nothing to animate, only one semantics update
await drag1.up();
expect(await tester.pumpAndSettle(), 1); // Nothing to animate
expect(position.pixels, moreOrLessEquals(500.0));
expect(position.minScrollExtent, 0.0);
expect(position.maxScrollExtent, 900.0);
final TestGesture drag2 = await tester.startGesture(const Offset(10.0, 500.0));
expect(await tester.pumpAndSettle(), 1); // Nothing to animate
await drag2.moveTo(const Offset(10.0, 100.0));
expect(await tester.pumpAndSettle(), 2); // Nothing to animate, only one semantics update
expect(position.maxScrollExtent, 900.0);
expect(position.pixels, lessThanOrEqualTo(900.0));
expect(position.activity, isInstanceOf<DragScrollActivity>());
final _ExpandingBoxState expandingBoxState = tester.state<_ExpandingBoxState>(find.byType(ExpandingBox));
expandingBoxState.toggleSize();
expect(await tester.pumpAndSettle(), 2); // Nothing to animate, only one semantics update
expect(position.activity, isInstanceOf<DragScrollActivity>());
expect(position.minScrollExtent, 0.0);
expect(position.maxScrollExtent, 100.0);
expect(position.pixels, 100.0);
await drag2.moveTo(const Offset(10.0, 150.0));
await drag2.up();
expect(position.minScrollExtent, 0.0);
expect(position.maxScrollExtent, 100.0);
expect(position.pixels, 50.0);
expect(await tester.pumpAndSettle(), 2); // Nothing to animate, only one semantics update
expect(position.minScrollExtent, 0.0);
expect(position.maxScrollExtent, 100.0);
expect(position.pixels, 50.0);
});
testWidgets('shrink listview while ballistic', (WidgetTester tester) async {
await tester.pumpWidget(MaterialApp(
home: GestureDetector(
onTap: () { assert(false); },
child: ListView.builder(
physics: const RangeMaintainingScrollPhysics(parent: BouncingScrollPhysics()),
itemBuilder: (BuildContext context, int index) => index == 0
? const ExpandingBox(collapsedSize: 400, expandedSize: 1200)
: Container(height: 300, color: Colors.red),
itemCount: 2,
),
),
));
final _ExpandingBoxState expandingBoxState = tester.state<_ExpandingBoxState>(find.byType(ExpandingBox));
expandingBoxState.toggleSize();
final ScrollPosition position = tester.state<ScrollableState>(find.byType(Scrollable)).position;
expect(position.activity, isInstanceOf<IdleScrollActivity>());
expect(position.minScrollExtent, 0.0);
expect(position.maxScrollExtent, 100.0);
expect(position.pixels, 0.0);
await tester.pump();
expect(position.minScrollExtent, 0.0);
expect(position.maxScrollExtent, 1800.0);
expect(position.pixels, 0.0);
final TestGesture drag1 = await tester.startGesture(const Offset(10.0, 10.0));
await tester.pump();
expect(position.activity, isInstanceOf<HoldScrollActivity>());
expect(position.minScrollExtent, 0.0);
expect(position.maxScrollExtent, 1800.0);
expect(position.pixels, 0.0);
await drag1.moveTo(const Offset(10.0, 50.0)); // to get past the slop and trigger the drag
await drag1.moveTo(const Offset(10.0, 550.0));
expect(position.pixels, -500.0);
await tester.pump();
expect(position.activity, isInstanceOf<DragScrollActivity>());
expect(position.minScrollExtent, 0.0);
expect(position.maxScrollExtent, 1800.0);
expect(position.pixels, -500.0);
await drag1.up();
await tester.pump();
expect(position.activity, isInstanceOf<BallisticScrollActivity>());
expect(position.minScrollExtent, 0.0);
expect(position.maxScrollExtent, 1800.0);
expect(position.pixels, -500.0);
expandingBoxState.toggleSize();
await tester.pump(); // apply physics without moving clock forward
expect(position.activity, isInstanceOf<BallisticScrollActivity>());
// TODO(ianh): Determine why the maxScrollOffset is 200.0 here instead of 100.0 or double.infinity.
// expect(position.minScrollExtent, 0.0);
// expect(position.maxScrollExtent, 100.0);
expect(position.pixels, -500.0);
await tester.pumpAndSettle(); // ignoring the exact effects of the animation
expect(position.activity, isInstanceOf<IdleScrollActivity>());
expect(position.minScrollExtent, 0.0);
expect(position.maxScrollExtent, 100.0);
expect(position.pixels, 0.0);
});
testWidgets('expanding page views', (WidgetTester tester) async {
await tester.pumpWidget(const Padding(padding: EdgeInsets.only(right: 200.0), child: TabBarDemo()));
await tester.tap(find.text('bike'));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
final Rect bike1 = tester.getRect(find.byIcon(Icons.directions_bike));
await tester.pumpWidget(const Padding(padding: EdgeInsets.zero, child: TabBarDemo()));
final Rect bike2 = tester.getRect(find.byIcon(Icons.directions_bike));
expect(bike2.center, bike1.shift(const Offset(100.0, 0.0)).center);
});
testWidgets('changing the size of the viewport when overscrolled', (WidgetTester tester) async {
Widget build(double height) {
return Directionality(
textDirection: TextDirection.rtl,
child: ScrollConfiguration(
behavior: const RangeMaintainingTestScrollBehavior(),
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(
height: height,
width: 100.0,
child: ListView(
children: const <Widget>[SizedBox(height: 100.0, child: Placeholder())],
),
),
),
),
);
}
await tester.pumpWidget(build(200.0));
// to verify that changing the size of the viewport while you are overdragged does not change the
// scroll position, we must ensure that:
// - velocity is zero
// - scroll extents have changed
// - position does not change at the same time
// - old position is out of old range AND new range
await tester.drag(find.byType(Placeholder), const Offset(0.0, 100.0), touchSlopY: 0.0, warnIfMissed: false); // it'll hit the scrollable
await tester.pump();
final Rect oldPosition = tester.getRect(find.byType(Placeholder));
await tester.pumpWidget(build(220.0));
final Rect newPosition = tester.getRect(find.byType(Placeholder));
expect(oldPosition, newPosition);
});
testWidgets('inserting and removing an item when overscrolled', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/62890
const double itemExtent = 100.0;
final UniqueKey key = UniqueKey();
final Finder finder = find.byKey(key);
Widget build({required bool twoItems}) {
return Directionality(
textDirection: TextDirection.rtl,
child: ScrollConfiguration(
behavior: const RangeMaintainingTestScrollBehavior(),
child: Align(
child: SizedBox(
width: 100.0,
height: 100.0,
child: ListView(
children: <Widget>[
SizedBox(height: itemExtent, child: Placeholder(key: key)),
if (twoItems)
const SizedBox(height: itemExtent, child: Placeholder()),
],
),
),
),
),
);
}
await tester.pumpWidget(build(twoItems: false));
final ScrollPosition position = tester.state<ScrollableState>(find.byType(Scrollable)).position;
// overscroll bottom
final TestGesture drag1 = await tester.startGesture(tester.getCenter(finder));
await tester.pump();
await drag1.moveBy(const Offset(0.0, -50.0));
await tester.pump();
final double oldOverscroll1 = position.pixels - position.maxScrollExtent;
final Rect oldPosition1 = tester.getRect(finder);
await tester.pumpWidget(build(twoItems: true));
// verify inserting new item didn't change the position of the first one
expect(oldPosition1, tester.getRect(finder));
// verify the overscroll changed by the size of the added item
final double newOverscroll1 = position.pixels - position.maxScrollExtent;
expect(oldOverscroll1, isPositive);
expect(newOverscroll1, isNegative);
expect(newOverscroll1, oldOverscroll1 - itemExtent);
await drag1.up();
// verify there's no ballistic animation, because we weren't overscrolled
expect(await tester.pumpAndSettle(), 1);
// overscroll bottom
final TestGesture drag2 = await tester.startGesture(tester.getCenter(finder));
await tester.pump();
await drag2.moveBy(const Offset(0.0, -100.0));
await tester.pump();
final double oldOverscroll2 = position.pixels - position.maxScrollExtent;
// should find nothing because item is not visible
expect(finder, findsNothing);
await tester.pumpWidget(build(twoItems: false));
// verify removing an item changed the position of the first one, because prior it was not visible
expect(oldPosition1, tester.getRect(finder));
// verify the overscroll was maintained
final double newOverscroll2 = position.pixels - position.maxScrollExtent;
expect(oldOverscroll2, isPositive);
expect(oldOverscroll2, newOverscroll2);
await drag2.up();
// verify there's a ballistic animation from overscroll
expect(await tester.pumpAndSettle(), 9);
});
}
class TabBarDemo extends StatelessWidget {
const TabBarDemo({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
home: DefaultTabController(
length: 3,
child: Scaffold(
appBar: AppBar(
bottom: const TabBar(
tabs: <Widget>[
Tab(text: 'car'),
Tab(text: 'transit'),
Tab(text: 'bike'),
],
),
title: const Text('Tabs Demo'),
),
body: const TabBarView(
children: <Widget>[
Icon(Icons.directions_car),
Icon(Icons.directions_transit),
Icon(Icons.directions_bike),
],
),
),
),
);
}
}
class RangeMaintainingTestScrollBehavior extends ScrollBehavior {
const RangeMaintainingTestScrollBehavior();
@override
TargetPlatform getPlatform(BuildContext context) => throw 'should not be called';
@override
Widget buildOverscrollIndicator(BuildContext context, Widget child, ScrollableDetails details) {
return child;
}
@override
Widget buildScrollbar(BuildContext context, Widget child, ScrollableDetails details) {
return child;
}
@override
GestureVelocityTrackerBuilder velocityTrackerBuilder(BuildContext context) {
return (PointerEvent event) => VelocityTracker.withKind(event.kind);
}
@override
ScrollPhysics getScrollPhysics(BuildContext context) {
return const BouncingScrollPhysics(parent: RangeMaintainingScrollPhysics());
}
}