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

5
import 'package:flutter/gestures.dart' show DragStartBehavior;
6
import 'package:flutter/material.dart';
7
import 'package:flutter/rendering.dart';
8
import 'package:flutter_test/flutter_test.dart';
9 10 11 12

import 'semantics_tester.dart';

void main() {
13 14
  SemanticsTester semantics;

15 16 17 18
  setUp(() {
    debugResetSemanticsIdCounter();
  });

19
  testWidgets('scrollable exposes the correct semantic actions', (WidgetTester tester) async {
20
    semantics = SemanticsTester(tester);
21
    await tester.pumpWidget(
22
      Directionality(
23
        textDirection: TextDirection.ltr,
24
        child: ListView(children: List<Widget>.generate(80, (int i) => Text('$i'))),
25 26
      ),
    );
27 28 29 30 31 32 33 34 35 36 37 38 39 40

    expect(semantics,includesNodeWith(actions: <SemanticsAction>[SemanticsAction.scrollUp]));

    await flingUp(tester);
    expect(semantics, includesNodeWith(actions: <SemanticsAction>[SemanticsAction.scrollUp, SemanticsAction.scrollDown]));

    await flingDown(tester, repetitions: 2);
    expect(semantics, includesNodeWith(actions: <SemanticsAction>[SemanticsAction.scrollUp]));

    await flingUp(tester, repetitions: 5);
    expect(semantics, includesNodeWith(actions: <SemanticsAction>[SemanticsAction.scrollDown]));

    await flingDown(tester);
    expect(semantics, includesNodeWith(actions: <SemanticsAction>[SemanticsAction.scrollUp, SemanticsAction.scrollDown]));
41 42

    semantics.dispose();
43 44
  });

45
  testWidgets('showOnScreen works in scrollable', (WidgetTester tester) async {
46
    semantics = SemanticsTester(tester); // enables semantics tree generation
47 48 49

    const double kItemHeight = 40.0;

50
    final List<Widget> containers = List<Widget>.generate(80, (int i) => MergeSemantics(
51
      child: SizedBox(
52
        height: kItemHeight,
53
        child: Text('container $i', textDirection: TextDirection.ltr),
54 55
      ),
    ));
56

57
    final ScrollController scrollController = ScrollController(
58 59
      initialScrollOffset: kItemHeight / 2,
    );
60
    addTearDown(scrollController.dispose);
61

62
    await tester.pumpWidget(
63
      Directionality(
64
        textDirection: TextDirection.ltr,
65
        child: ListView(
66 67 68 69 70
          controller: scrollController,
          children: containers,
        ),
      ),
    );
71 72 73

    expect(scrollController.offset, kItemHeight / 2);

74 75
    final int firstContainerId = tester.renderObject(find.byWidget(containers.first)).debugSemantics!.id;
    tester.binding.pipelineOwner.semanticsOwner!.performAction(firstContainerId, SemanticsAction.showOnScreen);
76 77
    await tester.pump();
    await tester.pump(const Duration(seconds: 5));
78

79
    expect(scrollController.offset, 0.0);
80 81

    semantics.dispose();
82
  });
83

84
  testWidgets('showOnScreen works with pinned app bar and sliver list', (WidgetTester tester) async {
85
    semantics = SemanticsTester(tester); // enables semantics tree generation
86 87 88 89

    const double kItemHeight = 100.0;
    const double kExpandedAppBarHeight = 56.0;

90
    final List<Widget> containers = List<Widget>.generate(80, (int i) => MergeSemantics(
91
      child: SizedBox(
92
        height: kItemHeight,
93
        child: Text('container $i'),
94 95
      ),
    ));
96

97
    final ScrollController scrollController = ScrollController(
98 99
      initialScrollOffset: kItemHeight / 2,
    );
100
    addTearDown(scrollController.dispose);
101

102
    await tester.pumpWidget(Directionality(
103
      textDirection: TextDirection.ltr,
104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126
      child: Localizations(
        locale: const Locale('en', 'us'),
        delegates: const <LocalizationsDelegate<dynamic>>[
          DefaultWidgetsLocalizations.delegate,
          DefaultMaterialLocalizations.delegate,
        ],
        child: MediaQuery(
          data: const MediaQueryData(),
            child: Scrollable(
            controller: scrollController,
            viewportBuilder: (BuildContext context, ViewportOffset offset) {
              return Viewport(
                offset: offset,
                slivers: <Widget>[
                  const SliverAppBar(
                    pinned: true,
                    expandedHeight: kExpandedAppBarHeight,
                    flexibleSpace: FlexibleSpaceBar(
                      title: Text('App Bar'),
                    ),
                  ),
                  SliverList(
                    delegate: SliverChildListDelegate(containers),
127
                  ),
128 129
                ],
              );
130
            },
131
          ),
132
        ),
133 134 135 136 137
      ),
    ));

    expect(scrollController.offset, kItemHeight / 2);

138 139
    final int firstContainerId = tester.renderObject(find.byWidget(containers.first)).debugSemantics!.id;
    tester.binding.pipelineOwner.semanticsOwner!.performAction(firstContainerId, SemanticsAction.showOnScreen);
140 141 142 143
    await tester.pump();
    await tester.pump(const Duration(seconds: 5));
    expect(tester.getTopLeft(find.byWidget(containers.first)).dy, kExpandedAppBarHeight);

144
    semantics.dispose();
145 146
  });

147
  testWidgets('showOnScreen works with pinned app bar and individual slivers', (WidgetTester tester) async {
148
    semantics = SemanticsTester(tester); // enables semantics tree generation
149 150 151 152 153

    const double kItemHeight = 100.0;
    const double kExpandedAppBarHeight = 256.0;


154
    final List<Widget> children = <Widget>[];
155 156
    final List<Widget> slivers = List<Widget>.generate(30, (int i) {
      final Widget child = MergeSemantics(
157
        child: SizedBox(
158
          height: 72.0,
159
          child: Text('Item $i'),
160 161
        ),
      );
162
      children.add(child);
163
      return SliverToBoxAdapter(
164 165 166 167
        child: child,
      );
    });

168
    final ScrollController scrollController = ScrollController(
169
      initialScrollOffset: 2.5 * kItemHeight,
170
    );
171
    addTearDown(scrollController.dispose);
172

173
    await tester.pumpWidget(Directionality(
174
      textDirection: TextDirection.ltr,
175
      child: MediaQuery(
176
        data: const MediaQueryData(),
177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194
        child: Localizations(
          locale: const Locale('en', 'us'),
          delegates: const <LocalizationsDelegate<dynamic>>[
            DefaultWidgetsLocalizations.delegate,
            DefaultMaterialLocalizations.delegate,
          ],
          child: Scrollable(
            controller: scrollController,
            viewportBuilder: (BuildContext context, ViewportOffset offset) {
              return Viewport(
                offset: offset,
                slivers: <Widget>[
                  const SliverAppBar(
                    pinned: true,
                    expandedHeight: kExpandedAppBarHeight,
                    flexibleSpace: FlexibleSpaceBar(
                      title: Text('App Bar'),
                    ),
195
                  ),
196 197
                  ...slivers,
                ],
198 199 200
              );
            },
          ),
201 202 203 204
        ),
      ),
    ));

205
    expect(scrollController.offset, 2.5 * kItemHeight);
206

207 208
    final int id0 = tester.renderObject(find.byWidget(children[0])).debugSemantics!.id;
    tester.binding.pipelineOwner.semanticsOwner!.performAction(id0, SemanticsAction.showOnScreen);
209 210
    await tester.pump();
    await tester.pump(const Duration(seconds: 5));
211
    expect(tester.getTopLeft(find.byWidget(children[0])).dy, kToolbarHeight);
212

213
    semantics.dispose();
214
  });
215

216
  testWidgets('correct scrollProgress', (WidgetTester tester) async {
217
    semantics = SemanticsTester(tester);
218

219
    await tester.pumpWidget(Directionality(
220
      textDirection: TextDirection.ltr,
221
      child: ListView(children: List<Widget>.generate(80, (int i) => Text('$i'))),
222 223
    ));

224 225 226 227 228 229 230 231
    expect(semantics, includesNodeWith(
      scrollExtentMin: 0.0,
      scrollPosition: 0.0,
      scrollExtentMax: 520.0,
      actions: <SemanticsAction>[
        SemanticsAction.scrollUp,
      ],
    ));
232

233
    await flingUp(tester);
234

235 236
    expect(semantics, includesNodeWith(
      scrollExtentMin: 0.0,
237
      scrollPosition: 394.3,
238 239 240 241 242 243
      scrollExtentMax: 520.0,
      actions: <SemanticsAction>[
        SemanticsAction.scrollUp,
        SemanticsAction.scrollDown,
      ],
    ));
244

245
    await flingUp(tester);
246

247 248 249 250 251 252 253 254
    expect(semantics, includesNodeWith(
      scrollExtentMin: 0.0,
      scrollPosition: 520.0,
      scrollExtentMax: 520.0,
      actions: <SemanticsAction>[
        SemanticsAction.scrollDown,
      ],
    ));
255 256

    semantics.dispose();
257
  });
258

259
  testWidgets('correct scrollProgress for unbound', (WidgetTester tester) async {
260
    semantics = SemanticsTester(tester);
261

262
    await tester.pumpWidget(Directionality(
263
      textDirection: TextDirection.ltr,
264
      child: ListView.builder(
265
        dragStartBehavior: DragStartBehavior.down,
266 267
        itemExtent: 20.0,
        itemBuilder: (BuildContext context, int index) {
268
          return Text('entry $index');
269
        },
270 271 272
      ),
    ));

273 274 275 276 277 278 279 280
    expect(semantics, includesNodeWith(
      scrollExtentMin: 0.0,
      scrollPosition: 0.0,
      scrollExtentMax: double.infinity,
      actions: <SemanticsAction>[
        SemanticsAction.scrollUp,
      ],
    ));
281

282
    await flingUp(tester);
283

284 285
    expect(semantics, includesNodeWith(
      scrollExtentMin: 0.0,
286
      scrollPosition: 394.3,
287 288 289 290 291 292
      scrollExtentMax: double.infinity,
      actions: <SemanticsAction>[
        SemanticsAction.scrollUp,
        SemanticsAction.scrollDown,
      ],
    ));
293

294
    await flingUp(tester);
295

296 297
    expect(semantics, includesNodeWith(
      scrollExtentMin: 0.0,
298
      scrollPosition: 788.6,
299 300 301 302 303 304
      scrollExtentMax: double.infinity,
      actions: <SemanticsAction>[
        SemanticsAction.scrollUp,
        SemanticsAction.scrollDown,
      ],
    ));
305 306

    semantics.dispose();
307
  });
308

309
  testWidgets('Semantics tree is populated mid-scroll', (WidgetTester tester) async {
310
    semantics = SemanticsTester(tester);
311

312
    final List<Widget> children = List<Widget>.generate(80, (int i) => SizedBox(
313
      height: 40.0,
314
      child: Text('Item $i'),
315
    ));
316
    await tester.pumpWidget(
317
      Directionality(
318
        textDirection: TextDirection.ltr,
319
        child: ListView(children: children),
320 321 322 323 324 325 326 327 328 329
      ),
    );

    final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(ListView)));
    await gesture.moveBy(const Offset(0.0, -40.0));
    await tester.pump();

    expect(semantics, includesNodeWith(label: 'Item 1'));
    expect(semantics, includesNodeWith(label: 'Item 2'));
    expect(semantics, includesNodeWith(label: 'Item 3'));
330 331

    semantics.dispose();
332
  });
333

334
  testWidgets('Can toggle semantics on, off, on without crash', (WidgetTester tester) async {
335
    await tester.pumpWidget(
336
      Directionality(
337
        textDirection: TextDirection.ltr,
338 339
        child: ListView(
          children: List<Widget>.generate(40, (int i) {
340
            return SizedBox(
341
              height: 400.0,
342
              child: Text('item $i'),
343 344 345 346 347 348
            );
          }),
        ),
      ),
    );

349
    final TestSemantics expectedSemantics = TestSemantics.root(
350
      children: <TestSemantics>[
351
        TestSemantics.rootChild(
352
          children: <TestSemantics>[
353
            TestSemantics(
354 355 356
              flags: <SemanticsFlag>[
                SemanticsFlag.hasImplicitScrolling,
              ],
357 358
              actions: <SemanticsAction>[SemanticsAction.scrollUp],
              children: <TestSemantics>[
359
                TestSemantics(
360 361 362
                  label: r'item 0',
                  textDirection: TextDirection.ltr,
                ),
363
                TestSemantics(
364 365 366
                  label: r'item 1',
                  textDirection: TextDirection.ltr,
                ),
367
                TestSemantics(
368 369 370 371 372
                  flags: <SemanticsFlag>[
                    SemanticsFlag.isHidden,
                  ],
                  label: r'item 2',
                ),
373 374 375 376 377 378 379
              ],
            ),
          ],
        ),
      ],
    );

380 381 382 383
    // Start with semantics off.
    expect(tester.binding.pipelineOwner.semanticsOwner, isNull);

    // Semantics on
384
    semantics = SemanticsTester(tester);
385 386
    await tester.pumpAndSettle();
    expect(tester.binding.pipelineOwner.semanticsOwner, isNotNull);
387
    expect(semantics, hasSemantics(expectedSemantics, ignoreId: true, ignoreRect: true, ignoreTransform: true));
388 389

    // Semantics off
390
    semantics.dispose();
391 392 393 394
    await tester.pumpAndSettle();
    expect(tester.binding.pipelineOwner.semanticsOwner, isNull);

    // Semantics on
395
    semantics = SemanticsTester(tester);
396 397
    await tester.pumpAndSettle();
    expect(tester.binding.pipelineOwner.semanticsOwner, isNotNull);
398
    expect(semantics, hasSemantics(expectedSemantics, ignoreId: true, ignoreRect: true, ignoreTransform: true));
399 400

    semantics.dispose();
401
  }, semanticsEnabled: false);
402 403 404 405 406

  group('showOnScreen', () {

    const double kItemHeight = 100.0;

407 408 409
    late List<Widget> children;
    late ScrollController scrollController;
    late Widget widgetUnderTest;
410 411

    setUp(() {
412 413
      children = List<Widget>.generate(10, (int i) {
        return MergeSemantics(
414
          child: SizedBox(
415
            height: kItemHeight,
416
            child: Text('container $i'),
417 418 419 420
          ),
        );
      });

421
      scrollController = ScrollController(
422 423 424
        initialScrollOffset: kItemHeight / 2,
      );

425
      widgetUnderTest = Directionality(
426
        textDirection: TextDirection.ltr,
427
        child: Center(
428
          child: SizedBox(
429
            height: 2 * kItemHeight,
430
            child: ListView(
431 432 433 434 435 436 437 438 439
              controller: scrollController,
              children: children,
            ),
          ),
        ),
      );

    });

440
    testWidgets('brings item above leading edge to leading edge', (WidgetTester tester) async {
441
      semantics = SemanticsTester(tester); // enables semantics tree generation
442 443 444 445 446

      await tester.pumpWidget(widgetUnderTest);

      expect(scrollController.offset, kItemHeight / 2);

447 448
      final int firstContainerId = tester.renderObject(find.byWidget(children.first)).debugSemantics!.id;
      tester.binding.pipelineOwner.semanticsOwner!.performAction(firstContainerId, SemanticsAction.showOnScreen);
449 450 451 452 453 454 455
      await tester.pumpAndSettle();

      expect(scrollController.offset, 0.0);

      semantics.dispose();
    });

456
    testWidgets('brings item below trailing edge to trailing edge', (WidgetTester tester) async {
457
      semantics = SemanticsTester(tester); // enables semantics tree generation
458 459 460 461 462

      await tester.pumpWidget(widgetUnderTest);

      expect(scrollController.offset, kItemHeight / 2);

463 464
      final int firstContainerId = tester.renderObject(find.byWidget(children[2])).debugSemantics!.id;
      tester.binding.pipelineOwner.semanticsOwner!.performAction(firstContainerId, SemanticsAction.showOnScreen);
465 466 467 468 469 470 471
      await tester.pumpAndSettle();

      expect(scrollController.offset, kItemHeight);

      semantics.dispose();
    });

472
    testWidgets('does not change position of items already fully on-screen', (WidgetTester tester) async {
473
      semantics = SemanticsTester(tester); // enables semantics tree generation
474 475 476 477 478

      await tester.pumpWidget(widgetUnderTest);

      expect(scrollController.offset, kItemHeight / 2);

479 480
      final int firstContainerId = tester.renderObject(find.byWidget(children[1])).debugSemantics!.id;
      tester.binding.pipelineOwner.semanticsOwner!.performAction(firstContainerId, SemanticsAction.showOnScreen);
481 482 483 484 485 486 487 488 489 490 491
      await tester.pumpAndSettle();

      expect(scrollController.offset, kItemHeight / 2);

      semantics.dispose();
    });
  });

  group('showOnScreen with negative children', () {
    const double kItemHeight = 100.0;

492 493 494
    late List<Widget> children;
    late ScrollController scrollController;
    late Widget widgetUnderTest;
495 496

    setUp(() {
497
      final Key center = GlobalKey();
498

499 500
      children = List<Widget>.generate(10, (int i) {
        return SliverToBoxAdapter(
501
          key: i == 5 ? center : null,
502 503
          child: MergeSemantics(
            key: ValueKey<int>(i),
504
            child: SizedBox(
505
              height: kItemHeight,
506
              child: Text('container $i'),
507 508 509 510 511
            ),
          ),
        );
      });

512
      scrollController = ScrollController(
513 514 515 516 517 518 519 520 521 522
        initialScrollOffset: -2.5 * kItemHeight,
      );

      // 'container 0' is at offset -500
      // 'container 1' is at offset -400
      // 'container 2' is at offset -300
      // 'container 3' is at offset -200
      // 'container 4' is at offset -100
      // 'container 5' is at offset 0

523
      widgetUnderTest = Directionality(
524
        textDirection: TextDirection.ltr,
525
        child: Center(
526
          child: SizedBox(
527
            height: 2 * kItemHeight,
528
            child: Scrollable(
529 530
              controller: scrollController,
              viewportBuilder: (BuildContext context, ViewportOffset offset) {
531
                return Viewport(
532 533 534
                  cacheExtent: 0.0,
                  offset: offset,
                  center: center,
535
                  slivers: children,
536 537 538 539 540 541
                );
              },
            ),
          ),
        ),
      );
542
    });
543

544 545
    tearDown(() {
      scrollController.dispose();
546 547
    });

548
    testWidgets('brings item above leading edge to leading edge', (WidgetTester tester) async {
549
      semantics = SemanticsTester(tester); // enables semantics tree generation
550 551 552 553 554

      await tester.pumpWidget(widgetUnderTest);

      expect(scrollController.offset, -250.0);

555 556
      final int firstContainerId = tester.renderObject(find.byKey(const ValueKey<int>(2))).debugSemantics!.id;
      tester.binding.pipelineOwner.semanticsOwner!.performAction(firstContainerId, SemanticsAction.showOnScreen);
557 558 559 560 561 562 563
      await tester.pumpAndSettle();

      expect(scrollController.offset, -300.0);

      semantics.dispose();
    });

564
    testWidgets('brings item below trailing edge to trailing edge', (WidgetTester tester) async {
565
      semantics = SemanticsTester(tester); // enables semantics tree generation
566 567 568 569 570

      await tester.pumpWidget(widgetUnderTest);

      expect(scrollController.offset, -250.0);

571 572
      final int firstContainerId = tester.renderObject(find.byKey(const ValueKey<int>(4))).debugSemantics!.id;
      tester.binding.pipelineOwner.semanticsOwner!.performAction(firstContainerId, SemanticsAction.showOnScreen);
573 574 575 576 577 578 579
      await tester.pumpAndSettle();

      expect(scrollController.offset, -200.0);

      semantics.dispose();
    });

580
    testWidgets('does not change position of items already fully on-screen', (WidgetTester tester) async {
581
      semantics = SemanticsTester(tester); // enables semantics tree generation
582 583 584 585 586

      await tester.pumpWidget(widgetUnderTest);

      expect(scrollController.offset, -250.0);

587 588
      final int firstContainerId = tester.renderObject(find.byKey(const ValueKey<int>(3))).debugSemantics!.id;
      tester.binding.pipelineOwner.semanticsOwner!.performAction(firstContainerId, SemanticsAction.showOnScreen);
589 590 591 592 593 594 595 596 597
      await tester.pumpAndSettle();

      expect(scrollController.offset, -250.0);

      semantics.dispose();
    });

  });

598
  testWidgets('transform of inner node from useTwoPaneSemantics scrolls correctly with nested scrollables', (WidgetTester tester) async {
599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618
    semantics = SemanticsTester(tester); // enables semantics tree generation

    // Context: https://github.com/flutter/flutter/issues/61631
    await tester.pumpWidget(
      Directionality(
        textDirection: TextDirection.ltr,
        child: SingleChildScrollView(
          child: ListView(
            shrinkWrap: true,
            children: <Widget>[
              for (int i = 0; i < 50; ++i)
                Text('$i'),
            ],
          ),
        ),
      ),
    );

    final SemanticsNode rootScrollNode = semantics.nodesWith(actions: <SemanticsAction>[SemanticsAction.scrollUp]).single;
    final SemanticsNode innerListPane = semantics.nodesWith(ancestor: rootScrollNode, scrollExtentMax: 0).single;
619
    final SemanticsNode outerListPane = innerListPane.parent!;
620 621 622 623 624
    final List<SemanticsNode> hiddenNodes = semantics.nodesWith(flags: <SemanticsFlag>[SemanticsFlag.isHidden]).toList();

    // This test is only valid if some children are offscreen.
    // Increase the number of Text children if this assert fails.
    assert(hiddenNodes.length >= 3);
625

626 627 628 629 630 631 632 633 634 635
    // Scroll to end -> beginning -> middle to test both directions.
    final List<SemanticsNode> targetNodes = <SemanticsNode>[
      hiddenNodes.last,
      hiddenNodes.first,
      hiddenNodes[hiddenNodes.length ~/ 2],
    ];

    expect(nodeGlobalRect(innerListPane), nodeGlobalRect(outerListPane));

    for (final SemanticsNode node in targetNodes) {
636
      tester.binding.pipelineOwner.semanticsOwner!.performAction(node.id, SemanticsAction.showOnScreen);
637 638 639 640 641 642 643
      await tester.pumpAndSettle();

      expect(nodeGlobalRect(innerListPane), nodeGlobalRect(outerListPane));
    }

    semantics.dispose();
  });
644 645
}

646
Future<void> flingUp(WidgetTester tester, { int repetitions = 1 }) => fling(tester, const Offset(0.0, -200.0), repetitions);
647

648
Future<void> flingDown(WidgetTester tester, { int repetitions = 1 }) => fling(tester, const Offset(0.0, 200.0), repetitions);
649

650
Future<void> fling(WidgetTester tester, Offset offset, int repetitions) async {
651
  while (repetitions-- > 0) {
652
    await tester.fling(find.byType(ListView), offset, 1000.0);
653 654 655
    await tester.pump();
    await tester.pump(const Duration(seconds: 5));
  }
656
}
657 658 659

Rect nodeGlobalRect(SemanticsNode node) {
  Matrix4 globalTransform = node.transform ?? Matrix4.identity();
660
  for (SemanticsNode? parent = node.parent; parent != null; parent = parent.parent) {
661
    if (parent.transform != null) {
662
      globalTransform = parent.transform!.multiplied(globalTransform);
663 664 665 666
    }
  }
  return MatrixUtils.transformRect(globalTransform, node.rect);
}