scrollable_semantics_test.dart 21.1 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 6
import 'dart:ui';

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

import 'semantics_tester.dart';

void main() {
15 16
  SemanticsTester semantics;

17 18 19 20
  setUp(() {
    debugResetSemanticsIdCounter();
  });

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

    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]));
43 44

    semantics.dispose();
45 46 47
  });

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

    const double kItemHeight = 40.0;

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

59
    final ScrollController scrollController = ScrollController(
60 61 62
      initialScrollOffset: kItemHeight / 2,
    );

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

    expect(scrollController.offset, kItemHeight / 2);

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

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

    semantics.dispose();
83
  });
84 85

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

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

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

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

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
    );

172
    await tester.pumpWidget(Directionality(
173
      textDirection: TextDirection.ltr,
174
      child: MediaQuery(
175
        data: const MediaQueryData(),
176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193
        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'),
                    ),
194
                  ),
195 196
                  ...slivers,
                ],
197 198 199
              );
            },
          ),
200 201 202 203
        ),
      ),
    ));

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

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

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

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

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

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

232
    await flingUp(tester);
233

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

244
    await flingUp(tester);
245

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

    semantics.dispose();
256
  });
257

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

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

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

281
    await flingUp(tester);
282

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

293
    await flingUp(tester);
294

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

    semantics.dispose();
306
  });
307 308

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

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

    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'));
329 330

    semantics.dispose();
331
  });
332

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

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

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

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

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

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

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

  group('showOnScreen', () {

    const double kItemHeight = 100.0;

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

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

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

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

    });

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

      await tester.pumpWidget(widgetUnderTest);

      expect(scrollController.offset, kItemHeight / 2);

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

      expect(scrollController.offset, 0.0);

      semantics.dispose();
    });

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

      await tester.pumpWidget(widgetUnderTest);

      expect(scrollController.offset, kItemHeight / 2);

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

      expect(scrollController.offset, kItemHeight);

      semantics.dispose();
    });

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

      await tester.pumpWidget(widgetUnderTest);

      expect(scrollController.offset, kItemHeight / 2);

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

      expect(scrollController.offset, kItemHeight / 2);

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

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

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

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

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

511
      scrollController = ScrollController(
512 513 514 515 516 517 518 519 520 521
        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

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

    });

    testWidgets('brings item above leading edge to leading edge', (WidgetTester tester) async {
545
      semantics = SemanticsTester(tester); // enables semantics tree generation
546 547 548 549 550

      await tester.pumpWidget(widgetUnderTest);

      expect(scrollController.offset, -250.0);

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

      expect(scrollController.offset, -300.0);

      semantics.dispose();
    });

    testWidgets('brings item below trailing edge to trailing edge', (WidgetTester tester) async {
561
      semantics = SemanticsTester(tester); // enables semantics tree generation
562 563 564 565 566

      await tester.pumpWidget(widgetUnderTest);

      expect(scrollController.offset, -250.0);

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

      expect(scrollController.offset, -200.0);

      semantics.dispose();
    });

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

      await tester.pumpWidget(widgetUnderTest);

      expect(scrollController.offset, -250.0);

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

      expect(scrollController.offset, -250.0);

      semantics.dispose();
    });

  });

594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614
  testWidgets('transform of inner node from useTwoPaneSemantics scrolls correctly with nested scrollables', (WidgetTester tester) async {
    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;
615
    final SemanticsNode outerListPane = innerListPane.parent!;
616 617 618 619 620
    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);
621

622 623 624 625 626 627 628 629 630 631
    // 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) {
632
      tester.binding.pipelineOwner.semanticsOwner!.performAction(node.id, SemanticsAction.showOnScreen);
633 634 635 636 637 638 639
      await tester.pumpAndSettle();

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

    semantics.dispose();
  });
640 641
}

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

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

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

648
Future<void> flingLeft(WidgetTester tester, { int repetitions = 1 }) => fling(tester, const Offset(-200.0, 0.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);
}