list_view_test.dart 19.3 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
Adam Barth's avatar
Adam Barth committed
2 3 4
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5 6
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
7
import 'package:flutter_test/flutter_test.dart';
Adam Barth's avatar
Adam Barth committed
8

9
import '../rendering/mock_canvas.dart';
10
import '../rendering/rendering_tester.dart';
11

12 13 14 15 16 17 18 19 20 21 22
class TestSliverChildListDelegate extends SliverChildListDelegate {
  TestSliverChildListDelegate(List<Widget> children) : super(children);

  final List<String> log = <String>[];

  @override
  void didFinishLayout(int firstIndex, int lastIndex) {
    log.add('didFinishLayout firstIndex=$firstIndex lastIndex=$lastIndex');
  }
}

23
class Alive extends StatefulWidget {
24
  const Alive(this.alive, this.index, { Key? key }) : super(key: key);
25 26 27 28
  final bool alive;
  final int index;

  @override
29
  AliveState createState() => AliveState();
30 31

  @override
32
  String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) => '$index $alive';
33 34 35 36 37 38 39
}

class AliveState extends State<Alive> with AutomaticKeepAliveClientMixin {
  @override
  bool get wantKeepAlive => widget.alive;

  @override
40 41 42 43
  Widget build(BuildContext context) {
    super.build(context);
    return Text('${widget.index}:$wantKeepAlive');
  }
44 45 46 47 48 49 50 51
}

typedef WhetherToKeepAlive = bool Function(int);
class _StatefulListView extends StatefulWidget {
  const _StatefulListView(this.aliveCallback);

  final WhetherToKeepAlive aliveCallback;
  @override
52
  _StatefulListViewState createState() => _StatefulListViewState();
53 54 55 56 57
}

class _StatefulListViewState extends State<_StatefulListView> {
  @override
  Widget build(BuildContext context) {
58
    return GestureDetector(
59 60 61
      // force a rebuild - the test(s) using this are verifying that the list is
      // still correct after rebuild
      onTap: () => setState,
62
      child: Directionality(
63
        textDirection: TextDirection.ltr,
64 65 66
        child: ListView(
          children: List<Widget>.generate(200, (int i) {
            return Builder(
67
              builder: (BuildContext context) {
68
                return Alive(widget.aliveCallback(i), i);
69 70 71 72 73 74 75 76 77
              },
            );
          }),
        ),
      ),
    );
  }
}

Adam Barth's avatar
Adam Barth committed
78
void main() {
79
  testWidgets('ListView default control', (WidgetTester tester) async {
80
    await tester.pumpWidget(
81
      Directionality(
82
        textDirection: TextDirection.ltr,
83 84
        child: Center(
          child: ListView(itemExtent: 100.0),
85 86 87
        ),
      ),
    );
88 89
  });

90
  testWidgets('ListView itemExtent control test', (WidgetTester tester) async {
Adam Barth's avatar
Adam Barth committed
91
    await tester.pumpWidget(
92
      Directionality(
93
        textDirection: TextDirection.ltr,
94
        child: ListView(
95
          itemExtent: 200.0,
96 97
          children: List<Widget>.generate(20, (int i) {
            return Container(
98
              color: Colors.green,
99
              child: Text('$i'),
100 101 102
            );
          }),
        ),
Adam Barth's avatar
Adam Barth committed
103 104 105
      ),
    );

106
    final RenderBox box = tester.renderObject<RenderBox>(find.byType(Container).first);
Adam Barth's avatar
Adam Barth committed
107 108 109 110 111
    expect(box.size.height, equals(200.0));

    expect(find.text('0'), findsOneWidget);
    expect(find.text('1'), findsOneWidget);
    expect(find.text('2'), findsOneWidget);
Adam Barth's avatar
Adam Barth committed
112
    expect(find.text('3'), findsNothing);
Adam Barth's avatar
Adam Barth committed
113 114
    expect(find.text('4'), findsNothing);

115
    await tester.drag(find.byType(ListView), const Offset(0.0, -250.0));
Adam Barth's avatar
Adam Barth committed
116 117 118 119 120 121 122
    await tester.pump();

    expect(find.text('0'), findsNothing);
    expect(find.text('1'), findsOneWidget);
    expect(find.text('2'), findsOneWidget);
    expect(find.text('3'), findsOneWidget);
    expect(find.text('4'), findsOneWidget);
Adam Barth's avatar
Adam Barth committed
123
    expect(find.text('5'), findsNothing);
Adam Barth's avatar
Adam Barth committed
124 125
    expect(find.text('6'), findsNothing);

126
    await tester.drag(find.byType(ListView), const Offset(0.0, 200.0));
Adam Barth's avatar
Adam Barth committed
127 128 129 130 131 132
    await tester.pump();

    expect(find.text('0'), findsOneWidget);
    expect(find.text('1'), findsOneWidget);
    expect(find.text('2'), findsOneWidget);
    expect(find.text('3'), findsOneWidget);
Adam Barth's avatar
Adam Barth committed
133
    expect(find.text('4'), findsNothing);
Adam Barth's avatar
Adam Barth committed
134 135 136
    expect(find.text('5'), findsNothing);
  });

137
  testWidgets('ListView large scroll jump', (WidgetTester tester) async {
138
    final List<int> log = <int>[];
Adam Barth's avatar
Adam Barth committed
139 140

    await tester.pumpWidget(
141
      Directionality(
142
        textDirection: TextDirection.ltr,
143
        child: ListView(
144
          itemExtent: 200.0,
145 146
          children: List<Widget>.generate(20, (int i) {
            return Builder(
147 148
              builder: (BuildContext context) {
                log.add(i);
149
                return Text('$i');
150
              },
151 152 153
            );
          }),
        ),
Adam Barth's avatar
Adam Barth committed
154 155 156
      ),
    );

157
    expect(log, equals(<int>[0, 1, 2, 3, 4]));
Adam Barth's avatar
Adam Barth committed
158 159
    log.clear();

160 161
    final ScrollableState state = tester.state(find.byType(Scrollable));
    final ScrollPosition position = state.position;
Adam Barth's avatar
Adam Barth committed
162 163 164 165 166
    position.jumpTo(2025.0);

    expect(log, isEmpty);
    await tester.pump();

167
    expect(log, equals(<int>[8, 9, 10, 11, 12, 13, 14]));
Adam Barth's avatar
Adam Barth committed
168 169 170 171 172 173 174
    log.clear();

    position.jumpTo(975.0);

    expect(log, isEmpty);
    await tester.pump();

175
    expect(log, equals(<int>[7, 6, 5, 4, 3]));
Adam Barth's avatar
Adam Barth committed
176 177
    log.clear();
  });
178

179
  testWidgets('ListView large scroll jump and keepAlive first child not keepAlive', (WidgetTester tester) async {
180
    Future<void> checkAndScroll([ String zero = '0:false' ]) async {
181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208
      expect(find.text(zero), findsOneWidget);
      expect(find.text('1:false'), findsOneWidget);
      expect(find.text('2:false'), findsOneWidget);
      expect(find.text('3:true'), findsOneWidget);
      expect(find.text('116:false'), findsNothing);
      final ScrollableState state = tester.state(find.byType(Scrollable));
      final ScrollPosition position = state.position;
      position.jumpTo(1025.0);

      await tester.pump();

      expect(find.text(zero), findsNothing);
      expect(find.text('1:false'), findsNothing);
      expect(find.text('2:false'), findsNothing);
      expect(find.text('3:true', skipOffstage: false), findsOneWidget);
      expect(find.text('116:false'), findsOneWidget);

      await tester.tapAt(const Offset(100.0, 100.0));
      position.jumpTo(0.0);
      await tester.pump();
      await tester.pump();

      expect(find.text(zero), findsOneWidget);
      expect(find.text('1:false'), findsOneWidget);
      expect(find.text('2:false'), findsOneWidget);
      expect(find.text('3:true'), findsOneWidget);
    }

209
    await tester.pumpWidget(_StatefulListView((int i) => i > 2 && i % 3 == 0));
210 211
    await checkAndScroll();

212
    await tester.pumpWidget(_StatefulListView((int i) => i % 3 == 0));
213
    await checkAndScroll('0:true');
214
  });
215

216 217
  testWidgets('ListView can build out of underflow', (WidgetTester tester) async {
    await tester.pumpWidget(
218
      Directionality(
219
        textDirection: TextDirection.ltr,
220
        child: ListView(
221 222
          itemExtent: 100.0,
        ),
223 224 225 226 227 228 229 230 231 232 233
      ),
    );

    expect(find.text('0'), findsNothing);
    expect(find.text('1'), findsNothing);
    expect(find.text('2'), findsNothing);
    expect(find.text('3'), findsNothing);
    expect(find.text('4'), findsNothing);
    expect(find.text('5'), findsNothing);

    await tester.pumpWidget(
234
      Directionality(
235
        textDirection: TextDirection.ltr,
236
        child: ListView(
237
          itemExtent: 100.0,
238
          children: List<Widget>.generate(2, (int i) {
239
            return Text('$i');
240 241
          }),
        ),
242 243 244 245 246 247 248 249 250 251 252
      ),
    );

    expect(find.text('0'), findsOneWidget);
    expect(find.text('1'), findsOneWidget);
    expect(find.text('2'), findsNothing);
    expect(find.text('3'), findsNothing);
    expect(find.text('4'), findsNothing);
    expect(find.text('5'), findsNothing);

    await tester.pumpWidget(
253
      Directionality(
254
        textDirection: TextDirection.ltr,
255
        child: ListView(
256
          itemExtent: 100.0,
257
          children: List<Widget>.generate(5, (int i) {
258
            return Text('$i');
259 260
          }),
        ),
261 262 263 264 265 266 267 268 269 270
      ),
    );

    expect(find.text('0'), findsOneWidget);
    expect(find.text('1'), findsOneWidget);
    expect(find.text('2'), findsOneWidget);
    expect(find.text('3'), findsOneWidget);
    expect(find.text('4'), findsOneWidget);
    expect(find.text('5'), findsNothing);
  });
271

272 273
  testWidgets('ListView can build out of overflow padding', (WidgetTester tester) async {
    await tester.pumpWidget(
274
      Directionality(
275
        textDirection: TextDirection.ltr,
276 277
        child: Center(
          child: SizedBox(
278 279
            width: 0.0,
            height: 0.0,
280
            child: ListView(
281
              padding: const EdgeInsets.all(8.0),
282
              children: const <Widget>[
283
                Text('padded', textDirection: TextDirection.ltr),
284 285
              ],
            ),
286 287 288 289
          ),
        ),
      ),
    );
290
    expect(find.text('padded', skipOffstage: false), findsOneWidget);
291 292
  });

293 294
  testWidgets('ListView with itemExtent in unbounded context', (WidgetTester tester) async {
    await tester.pumpWidget(
295
      Directionality(
296
        textDirection: TextDirection.ltr,
297 298
        child: SingleChildScrollView(
          child: ListView(
299 300
            itemExtent: 100.0,
            shrinkWrap: true,
301
            children: List<Widget>.generate(20, (int i) {
302
              return Text('$i');
303 304
            }),
          ),
305 306 307 308 309 310 311
        ),
      ),
    );

    expect(find.text('0'), findsOneWidget);
    expect(find.text('19'), findsOneWidget);
  });
312

Josh Soref's avatar
Josh Soref committed
313
  testWidgets('didFinishLayout has correct indices', (WidgetTester tester) async {
314 315
    final TestSliverChildListDelegate delegate = TestSliverChildListDelegate(
      List<Widget>.generate(
316 317
        20,
        (int i) {
318
          return Text('$i', textDirection: TextDirection.ltr);
319
        },
320
      ),
321 322 323
    );

    await tester.pumpWidget(
324
      Directionality(
325
        textDirection: TextDirection.ltr,
326
        child: ListView.custom(
327 328 329
          itemExtent: 110.0,
          childrenDelegate: delegate,
        ),
330 331 332
      ),
    );

333
    expect(delegate.log, equals(<String>['didFinishLayout firstIndex=0 lastIndex=7']));
334 335 336
    delegate.log.clear();

    await tester.pumpWidget(
337
      Directionality(
338
        textDirection: TextDirection.ltr,
339
        child: ListView.custom(
340 341 342
          itemExtent: 210.0,
          childrenDelegate: delegate,
        ),
343 344 345
      ),
    );

346
    expect(delegate.log, equals(<String>['didFinishLayout firstIndex=0 lastIndex=4']));
347 348 349 350 351 352 353 354
    delegate.log.clear();

    await tester.drag(find.byType(ListView), const Offset(0.0, -600.0));

    expect(delegate.log, isEmpty);

    await tester.pump();

355
    expect(delegate.log, equals(<String>['didFinishLayout firstIndex=1 lastIndex=6']));
356 357
    delegate.log.clear();
  });
358 359

  testWidgets('ListView automatically pad MediaQuery on axis', (WidgetTester tester) async {
360
    EdgeInsets? innerMediaQueryPadding;
361 362

    await tester.pumpWidget(
363
      Directionality(
364
        textDirection: TextDirection.ltr,
365
        child: MediaQuery(
366
          data: const MediaQueryData(
367
            padding: EdgeInsets.all(30.0),
368
          ),
369
          child: ListView(
370 371
            children: <Widget>[
              const Text('top', textDirection: TextDirection.ltr),
372
              Builder(builder: (BuildContext context) {
373
                innerMediaQueryPadding = MediaQuery.of(context).padding;
374
                return Container();
375 376 377 378 379 380 381 382 383 384 385
              }),
            ],
          ),
        ),
      ),
    );
    // Automatically apply the top/bottom padding into sliver.
    expect(tester.getTopLeft(find.text('top')).dy, 30.0);
    // Leave left/right padding as is for children.
    expect(innerMediaQueryPadding, const EdgeInsets.symmetric(horizontal: 30.0));
  });
386 387 388 389 390

  testWidgets('ListView clips if overflow is smaller than cacheExtent', (WidgetTester tester) async {
    // Regression test for https://github.com/flutter/flutter/issues/17426.

    await tester.pumpWidget(
391
      Directionality(
392
        textDirection: TextDirection.ltr,
393
        child: Center(
394
          child: SizedBox(
395
            height: 200.0,
396
            child: ListView(
397 398
              cacheExtent: 500.0,
              children: <Widget>[
399
                Container(
400 401
                  height: 90.0,
                ),
402
                Container(
403 404
                  height: 110.0,
                ),
405
                Container(
406 407 408 409 410 411 412 413 414 415 416 417 418 419
                  height: 80.0,
                ),
              ],
            ),
          ),
        ),
      ),
    );

    expect(find.byType(Viewport), paints..clipRect());
  });

  testWidgets('ListView does not clips if no overflow', (WidgetTester tester) async {
    await tester.pumpWidget(
420
      Directionality(
421
        textDirection: TextDirection.ltr,
422
        child: Center(
423
          child: SizedBox(
424
            height: 200.0,
425
            child: ListView(
426
              cacheExtent: 500.0,
427 428
              children: const <Widget>[
                SizedBox(
429 430 431 432 433 434 435
                  height: 100.0,
                ),
              ],
            ),
          ),
        ),
      ),
436
    );
437 438 439 440 441 442 443 444

    expect(find.byType(Viewport), isNot(paints..clipRect()));
  });

  testWidgets('ListView (fixed extent) clips if overflow is smaller than cacheExtent', (WidgetTester tester) async {
    // Regression test for https://github.com/flutter/flutter/issues/17426.

    await tester.pumpWidget(
445
      Directionality(
446
        textDirection: TextDirection.ltr,
447
        child: Center(
448
          child: SizedBox(
449
            height: 200.0,
450
            child: ListView(
451 452
              itemExtent: 100.0,
              cacheExtent: 500.0,
453 454
              children: const <Widget>[
                SizedBox(
455 456
                  height: 100.0,
                ),
457
                SizedBox(
458 459
                  height: 100.0,
                ),
460
                SizedBox(
461 462 463 464 465 466 467 468 469 470 471 472 473 474
                  height: 100.0,
                ),
              ],
            ),
          ),
        ),
      ),
    );

    expect(find.byType(Viewport), paints..clipRect());
  });

  testWidgets('ListView (fixed extent) does not clips if no overflow', (WidgetTester tester) async {
    await tester.pumpWidget(
475
      Directionality(
476
        textDirection: TextDirection.ltr,
477
        child: Center(
478
          child: SizedBox(
479
            height: 200.0,
480
            child: ListView(
481 482
              itemExtent: 100.0,
              cacheExtent: 500.0,
483 484
              children: const <Widget>[
                SizedBox(
485 486 487 488 489 490 491 492 493 494 495
                  height: 100.0,
                ),
              ],
            ),
          ),
        ),
      ),
    );

    expect(find.byType(Viewport), isNot(paints..clipRect()));
  });
496 497 498 499 500 501 502

  testWidgets('ListView.horizontal has implicit scrolling by default', (WidgetTester tester) async {
    final SemanticsHandle handle = tester.ensureSemantics();
    await tester.pumpWidget(
      Directionality(
        textDirection: TextDirection.ltr,
        child: Center(
503
          child: SizedBox(
504 505 506 507
            height: 200.0,
            child: ListView(
              scrollDirection: Axis.horizontal,
              itemExtent: 100.0,
508 509
              children: const <Widget>[
                SizedBox(
510 511 512 513 514 515 516 517 518 519 520 521
                  height: 100.0,
                ),
              ],
            ),
          ),
        ),
      ),
    );
    expect(tester.getSemantics(find.byType(Scrollable)), matchesSemantics(
      children: <Matcher>[
        matchesSemantics(
          children: <Matcher>[
522
            matchesSemantics(hasImplicitScrolling: true),
523 524 525 526 527 528
          ],
        ),
      ],
    ));
    handle.dispose();
  });
529 530 531 532 533

  testWidgets('Updates viewport dimensions when scroll direction changes', (WidgetTester tester) async {
    // Regression test for https://github.com/flutter/flutter/issues/43380.
    final ScrollController controller = ScrollController();

534
    Widget buildListView({ required Axis scrollDirection }) {
535 536 537 538
      assert(scrollDirection != null);
      return Directionality(
        textDirection: TextDirection.ltr,
        child: Center(
539
          child: SizedBox(
540 541 542 543 544 545
            height: 200.0,
            width: 100.0,
            child: ListView(
              controller: controller,
              scrollDirection: scrollDirection,
              itemExtent: 50.0,
546 547
              children: const <Widget>[
                SizedBox(
548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566
                  height: 50.0,
                  width: 50.0,
                ),
              ],
            ),
          ),
        ),
      );
    }

    await tester.pumpWidget(buildListView(scrollDirection: Axis.horizontal));
    expect(controller.position.viewportDimension, 100.0);

    await tester.pumpWidget(buildListView(scrollDirection: Axis.vertical));
    expect(controller.position.viewportDimension, 200.0);

    await tester.pumpWidget(buildListView(scrollDirection: Axis.horizontal));
    expect(controller.position.viewportDimension, 100.0);
  });
567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650

  testWidgets('ListView respects clipBehavior', (WidgetTester tester) async {
    await tester.pumpWidget(
      Directionality(
        textDirection: TextDirection.ltr,
        child: ListView(
          children: <Widget>[Container(height: 2000.0)],
        ),
      ),
    );

    // 1st, check that the render object has received the default clip behavior.
    final RenderViewport renderObject = tester.allRenderObjects.whereType<RenderViewport>().first;
    expect(renderObject.clipBehavior, equals(Clip.hardEdge));

    // 2nd, check that the painting context has received the default clip behavior.
    final TestClipPaintingContext context = TestClipPaintingContext();
    renderObject.paint(context, Offset.zero);
    expect(context.clipBehavior, equals(Clip.hardEdge));

    // 3rd, pump a new widget to check that the render object can update its clip behavior.
    await tester.pumpWidget(
      Directionality(
        textDirection: TextDirection.ltr,
        child: ListView(
          children: <Widget>[Container(height: 2000.0)],
          clipBehavior: Clip.antiAlias,
        ),
      ),
    );
    expect(renderObject.clipBehavior, equals(Clip.antiAlias));

    // 4th, check that a non-default clip behavior can be sent to the painting context.
    renderObject.paint(context, Offset.zero);
    expect(context.clipBehavior, equals(Clip.antiAlias));
  });

  testWidgets('ListView.builder respects clipBehavior', (WidgetTester tester) async {
    await tester.pumpWidget(
      Directionality(
        textDirection: TextDirection.ltr,
        child: ListView.builder(
          itemCount: 10,
          itemBuilder: (BuildContext _, int __) => Container(height: 2000.0),
          clipBehavior: Clip.antiAlias,
        ),
      ),
    );
    final RenderViewport renderObject = tester.allRenderObjects.whereType<RenderViewport>().first;
    expect(renderObject.clipBehavior, equals(Clip.antiAlias));
  });

  testWidgets('ListView.custom respects clipBehavior', (WidgetTester tester) async {
    await tester.pumpWidget(
      Directionality(
        textDirection: TextDirection.ltr,
        child: ListView.custom(
          childrenDelegate: SliverChildBuilderDelegate(
            (BuildContext context, int index) => Container(height: 2000.0),
            childCount: 1,
          ),
          clipBehavior: Clip.antiAlias,
        ),
      ),
    );
    final RenderViewport renderObject = tester.allRenderObjects.whereType<RenderViewport>().first;
    expect(renderObject.clipBehavior, equals(Clip.antiAlias));
  });

  testWidgets('ListView.separated respects clipBehavior', (WidgetTester tester) async {
    await tester.pumpWidget(
      Directionality(
        textDirection: TextDirection.ltr,
        child: ListView.separated(
          itemCount: 10,
          itemBuilder: (BuildContext _, int __) => Container(height: 2000.0),
          separatorBuilder: (BuildContext _, int __) => const Divider(),
          clipBehavior: Clip.antiAlias,
        ),
      ),
    );
    final RenderViewport renderObject = tester.allRenderObjects.whereType<RenderViewport>().first;
    expect(renderObject.clipBehavior, equals(Clip.antiAlias));
  });
Adam Barth's avatar
Adam Barth committed
651
}