// 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()); } }