scrollable_semantics_test.dart 20.9 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 60
      initialScrollOffset: kItemHeight / 2,
    );

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

    expect(scrollController.offset, kItemHeight / 2);

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

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

    semantics.dispose();
81
  });
82 83

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

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

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

96
    final ScrollController scrollController = ScrollController(
97 98 99
      initialScrollOffset: kItemHeight / 2,
    );

100
    await tester.pumpWidget(Directionality(
101
      textDirection: TextDirection.ltr,
102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124
      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),
125
                  ),
126 127
                ],
              );
128
            },
129
          ),
130
        ),
131 132 133 134 135
      ),
    ));

    expect(scrollController.offset, kItemHeight / 2);

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

142
    semantics.dispose();
143 144 145
  });

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

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


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

166
    final ScrollController scrollController = ScrollController(
167
      initialScrollOffset: 2.5 * kItemHeight,
168 169
    );

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

202
    expect(scrollController.offset, 2.5 * kItemHeight);
203

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

210
    semantics.dispose();
211
  });
212

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

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

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

230
    await flingUp(tester);
231

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

242
    await flingUp(tester);
243

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

    semantics.dispose();
254
  });
255

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

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

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

279
    await flingUp(tester);
280

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

291
    await flingUp(tester);
292

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

    semantics.dispose();
304
  });
305 306

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

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

    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'));
327 328

    semantics.dispose();
329
  });
330

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

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

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

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

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

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

    semantics.dispose();
398
  }, semanticsEnabled: false);
399 400 401 402 403

  group('showOnScreen', () {

    const double kItemHeight = 100.0;

404 405 406
    late List<Widget> children;
    late ScrollController scrollController;
    late Widget widgetUnderTest;
407 408

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

418
      scrollController = ScrollController(
419 420 421
        initialScrollOffset: kItemHeight / 2,
      );

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

    });

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

      await tester.pumpWidget(widgetUnderTest);

      expect(scrollController.offset, kItemHeight / 2);

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

      expect(scrollController.offset, 0.0);

      semantics.dispose();
    });

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

      await tester.pumpWidget(widgetUnderTest);

      expect(scrollController.offset, kItemHeight / 2);

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

      expect(scrollController.offset, kItemHeight);

      semantics.dispose();
    });

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

      await tester.pumpWidget(widgetUnderTest);

      expect(scrollController.offset, kItemHeight / 2);

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

      expect(scrollController.offset, kItemHeight / 2);

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

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

489 490 491
    late List<Widget> children;
    late ScrollController scrollController;
    late Widget widgetUnderTest;
492 493

    setUp(() {
494
      final Key center = GlobalKey();
495

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

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

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

    });

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

      await tester.pumpWidget(widgetUnderTest);

      expect(scrollController.offset, -250.0);

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

      expect(scrollController.offset, -300.0);

      semantics.dispose();
    });

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

      await tester.pumpWidget(widgetUnderTest);

      expect(scrollController.offset, -250.0);

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

      expect(scrollController.offset, -200.0);

      semantics.dispose();
    });

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

      await tester.pumpWidget(widgetUnderTest);

      expect(scrollController.offset, -250.0);

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

      expect(scrollController.offset, -250.0);

      semantics.dispose();
    });

  });

592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612
  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;
613
    final SemanticsNode outerListPane = innerListPane.parent!;
614 615 616 617 618
    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);
619

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

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

    semantics.dispose();
  });
638 639
}

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

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

644
Future<void> fling(WidgetTester tester, Offset offset, int repetitions) async {
645
  while (repetitions-- > 0) {
646
    await tester.fling(find.byType(ListView), offset, 1000.0);
647 648 649
    await tester.pump();
    await tester.pump(const Duration(seconds: 5));
  }
650
}
651 652 653

Rect nodeGlobalRect(SemanticsNode node) {
  Matrix4 globalTransform = node.transform ?? Matrix4.identity();
654
  for (SemanticsNode? parent = node.parent; parent != null; parent = parent.parent) {
655
    if (parent.transform != null) {
656
      globalTransform = parent.transform!.multiplied(globalTransform);
657 658 659 660
    }
  }
  return MatrixUtils.transformRect(globalTransform, node.rect);
}