blob: 75d5c440ae4d8e1d58247c0ea0037f6dcfc0f2b6 [file] [log] [blame]
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:ui';
import 'package:flutter/gestures.dart' show DragStartBehavior;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'semantics_tester.dart';
void main() {
SemanticsTester semantics;
setUp(() {
debugResetSemanticsIdCounter();
});
testWidgets('scrollable exposes the correct semantic actions', (WidgetTester tester) async {
semantics = SemanticsTester(tester);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: ListView(children: List<Widget>.generate(80, (int i) => Text('$i'))),
),
);
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]));
semantics.dispose();
});
testWidgets('showOnScreen works in scrollable', (WidgetTester tester) async {
semantics = SemanticsTester(tester); // enables semantics tree generation
const double kItemHeight = 40.0;
final List<Widget> containers = List<Widget>.generate(80, (int i) => MergeSemantics(
child: SizedBox(
height: kItemHeight,
child: Text('container $i', textDirection: TextDirection.ltr),
),
));
final ScrollController scrollController = ScrollController(
initialScrollOffset: kItemHeight / 2,
);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: ListView(
controller: scrollController,
children: containers,
),
),
);
expect(scrollController.offset, kItemHeight / 2);
final int firstContainerId = tester.renderObject(find.byWidget(containers.first)).debugSemantics!.id;
tester.binding.pipelineOwner.semanticsOwner!.performAction(firstContainerId, SemanticsAction.showOnScreen);
await tester.pump();
await tester.pump(const Duration(seconds: 5));
expect(scrollController.offset, 0.0);
semantics.dispose();
});
testWidgets('showOnScreen works with pinned app bar and sliver list', (WidgetTester tester) async {
semantics = SemanticsTester(tester); // enables semantics tree generation
const double kItemHeight = 100.0;
const double kExpandedAppBarHeight = 56.0;
final List<Widget> containers = List<Widget>.generate(80, (int i) => MergeSemantics(
child: SizedBox(
height: kItemHeight,
child: Text('container $i'),
),
));
final ScrollController scrollController = ScrollController(
initialScrollOffset: kItemHeight / 2,
);
await tester.pumpWidget(Directionality(
textDirection: TextDirection.ltr,
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),
),
],
);
},
),
),
),
));
expect(scrollController.offset, kItemHeight / 2);
final int firstContainerId = tester.renderObject(find.byWidget(containers.first)).debugSemantics!.id;
tester.binding.pipelineOwner.semanticsOwner!.performAction(firstContainerId, SemanticsAction.showOnScreen);
await tester.pump();
await tester.pump(const Duration(seconds: 5));
expect(tester.getTopLeft(find.byWidget(containers.first)).dy, kExpandedAppBarHeight);
semantics.dispose();
});
testWidgets('showOnScreen works with pinned app bar and individual slivers', (WidgetTester tester) async {
semantics = SemanticsTester(tester); // enables semantics tree generation
const double kItemHeight = 100.0;
const double kExpandedAppBarHeight = 256.0;
final List<Widget> children = <Widget>[];
final List<Widget> slivers = List<Widget>.generate(30, (int i) {
final Widget child = MergeSemantics(
child: SizedBox(
height: 72.0,
child: Text('Item $i'),
),
);
children.add(child);
return SliverToBoxAdapter(
child: child,
);
});
final ScrollController scrollController = ScrollController(
initialScrollOffset: 2.5 * kItemHeight,
);
await tester.pumpWidget(Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(
data: const MediaQueryData(),
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'),
),
),
...slivers,
],
);
},
),
),
),
));
expect(scrollController.offset, 2.5 * kItemHeight);
final int id0 = tester.renderObject(find.byWidget(children[0])).debugSemantics!.id;
tester.binding.pipelineOwner.semanticsOwner!.performAction(id0, SemanticsAction.showOnScreen);
await tester.pump();
await tester.pump(const Duration(seconds: 5));
expect(tester.getTopLeft(find.byWidget(children[0])).dy, kToolbarHeight);
semantics.dispose();
});
testWidgets('correct scrollProgress', (WidgetTester tester) async {
semantics = SemanticsTester(tester);
await tester.pumpWidget(Directionality(
textDirection: TextDirection.ltr,
child: ListView(children: List<Widget>.generate(80, (int i) => Text('$i'))),
));
expect(semantics, includesNodeWith(
scrollExtentMin: 0.0,
scrollPosition: 0.0,
scrollExtentMax: 520.0,
actions: <SemanticsAction>[
SemanticsAction.scrollUp,
],
));
await flingUp(tester);
expect(semantics, includesNodeWith(
scrollExtentMin: 0.0,
scrollPosition: 380.2,
scrollExtentMax: 520.0,
actions: <SemanticsAction>[
SemanticsAction.scrollUp,
SemanticsAction.scrollDown,
],
));
await flingUp(tester);
expect(semantics, includesNodeWith(
scrollExtentMin: 0.0,
scrollPosition: 520.0,
scrollExtentMax: 520.0,
actions: <SemanticsAction>[
SemanticsAction.scrollDown,
],
));
semantics.dispose();
});
testWidgets('correct scrollProgress for unbound', (WidgetTester tester) async {
semantics = SemanticsTester(tester);
await tester.pumpWidget(Directionality(
textDirection: TextDirection.ltr,
child: ListView.builder(
dragStartBehavior: DragStartBehavior.down,
itemExtent: 20.0,
itemBuilder: (BuildContext context, int index) {
return Text('entry $index');
},
),
));
expect(semantics, includesNodeWith(
scrollExtentMin: 0.0,
scrollPosition: 0.0,
scrollExtentMax: double.infinity,
actions: <SemanticsAction>[
SemanticsAction.scrollUp,
],
));
await flingUp(tester);
expect(semantics, includesNodeWith(
scrollExtentMin: 0.0,
scrollPosition: 380.2,
scrollExtentMax: double.infinity,
actions: <SemanticsAction>[
SemanticsAction.scrollUp,
SemanticsAction.scrollDown,
],
));
await flingUp(tester);
expect(semantics, includesNodeWith(
scrollExtentMin: 0.0,
scrollPosition: 760.4,
scrollExtentMax: double.infinity,
actions: <SemanticsAction>[
SemanticsAction.scrollUp,
SemanticsAction.scrollDown,
],
));
semantics.dispose();
});
testWidgets('Semantics tree is populated mid-scroll', (WidgetTester tester) async {
semantics = SemanticsTester(tester);
final List<Widget> children = List<Widget>.generate(80, (int i) => SizedBox(
height: 40.0,
child: Text('Item $i'),
));
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: ListView(children: children),
),
);
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'));
semantics.dispose();
});
testWidgets('Can toggle semantics on, off, on without crash', (WidgetTester tester) async {
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: ListView(
children: List<Widget>.generate(40, (int i) {
return SizedBox(
height: 400.0,
child: Text('item $i'),
);
}),
),
),
);
final TestSemantics expectedSemantics = TestSemantics.root(
children: <TestSemantics>[
TestSemantics.rootChild(
children: <TestSemantics>[
TestSemantics(
flags: <SemanticsFlag>[
SemanticsFlag.hasImplicitScrolling,
],
actions: <SemanticsAction>[SemanticsAction.scrollUp],
children: <TestSemantics>[
TestSemantics(
label: r'item 0',
textDirection: TextDirection.ltr,
),
TestSemantics(
label: r'item 1',
textDirection: TextDirection.ltr,
),
TestSemantics(
flags: <SemanticsFlag>[
SemanticsFlag.isHidden,
],
label: r'item 2',
),
],
),
],
),
],
);
// Start with semantics off.
expect(tester.binding.pipelineOwner.semanticsOwner, isNull);
// Semantics on
semantics = SemanticsTester(tester);
await tester.pumpAndSettle();
expect(tester.binding.pipelineOwner.semanticsOwner, isNotNull);
expect(semantics, hasSemantics(expectedSemantics, ignoreId: true, ignoreRect: true, ignoreTransform: true));
// Semantics off
semantics.dispose();
await tester.pumpAndSettle();
expect(tester.binding.pipelineOwner.semanticsOwner, isNull);
// Semantics on
semantics = SemanticsTester(tester);
await tester.pumpAndSettle();
expect(tester.binding.pipelineOwner.semanticsOwner, isNotNull);
expect(semantics, hasSemantics(expectedSemantics, ignoreId: true, ignoreRect: true, ignoreTransform: true));
semantics.dispose();
}, semanticsEnabled: false);
group('showOnScreen', () {
const double kItemHeight = 100.0;
late List<Widget> children;
late ScrollController scrollController;
late Widget widgetUnderTest;
setUp(() {
children = List<Widget>.generate(10, (int i) {
return MergeSemantics(
child: SizedBox(
height: kItemHeight,
child: Text('container $i'),
),
);
});
scrollController = ScrollController(
initialScrollOffset: kItemHeight / 2,
);
widgetUnderTest = Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: SizedBox(
height: 2 * kItemHeight,
child: ListView(
controller: scrollController,
children: children,
),
),
),
);
});
testWidgets('brings item above leading edge to leading edge', (WidgetTester tester) async {
semantics = SemanticsTester(tester); // enables semantics tree generation
await tester.pumpWidget(widgetUnderTest);
expect(scrollController.offset, kItemHeight / 2);
final int firstContainerId = tester.renderObject(find.byWidget(children.first)).debugSemantics!.id;
tester.binding.pipelineOwner.semanticsOwner!.performAction(firstContainerId, SemanticsAction.showOnScreen);
await tester.pumpAndSettle();
expect(scrollController.offset, 0.0);
semantics.dispose();
});
testWidgets('brings item below trailing edge to trailing edge', (WidgetTester tester) async {
semantics = SemanticsTester(tester); // enables semantics tree generation
await tester.pumpWidget(widgetUnderTest);
expect(scrollController.offset, kItemHeight / 2);
final int firstContainerId = tester.renderObject(find.byWidget(children[2])).debugSemantics!.id;
tester.binding.pipelineOwner.semanticsOwner!.performAction(firstContainerId, SemanticsAction.showOnScreen);
await tester.pumpAndSettle();
expect(scrollController.offset, kItemHeight);
semantics.dispose();
});
testWidgets('does not change position of items already fully on-screen', (WidgetTester tester) async {
semantics = SemanticsTester(tester); // enables semantics tree generation
await tester.pumpWidget(widgetUnderTest);
expect(scrollController.offset, kItemHeight / 2);
final int firstContainerId = tester.renderObject(find.byWidget(children[1])).debugSemantics!.id;
tester.binding.pipelineOwner.semanticsOwner!.performAction(firstContainerId, SemanticsAction.showOnScreen);
await tester.pumpAndSettle();
expect(scrollController.offset, kItemHeight / 2);
semantics.dispose();
});
});
group('showOnScreen with negative children', () {
const double kItemHeight = 100.0;
late List<Widget> children;
late ScrollController scrollController;
late Widget widgetUnderTest;
setUp(() {
final Key center = GlobalKey();
children = List<Widget>.generate(10, (int i) {
return SliverToBoxAdapter(
key: i == 5 ? center : null,
child: MergeSemantics(
key: ValueKey<int>(i),
child: SizedBox(
height: kItemHeight,
child: Text('container $i'),
),
),
);
});
scrollController = ScrollController(
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
widgetUnderTest = Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: SizedBox(
height: 2 * kItemHeight,
child: Scrollable(
controller: scrollController,
viewportBuilder: (BuildContext context, ViewportOffset offset) {
return Viewport(
cacheExtent: 0.0,
offset: offset,
center: center,
slivers: children,
);
},
),
),
),
);
});
testWidgets('brings item above leading edge to leading edge', (WidgetTester tester) async {
semantics = SemanticsTester(tester); // enables semantics tree generation
await tester.pumpWidget(widgetUnderTest);
expect(scrollController.offset, -250.0);
final int firstContainerId = tester.renderObject(find.byKey(const ValueKey<int>(2))).debugSemantics!.id;
tester.binding.pipelineOwner.semanticsOwner!.performAction(firstContainerId, SemanticsAction.showOnScreen);
await tester.pumpAndSettle();
expect(scrollController.offset, -300.0);
semantics.dispose();
});
testWidgets('brings item below trailing edge to trailing edge', (WidgetTester tester) async {
semantics = SemanticsTester(tester); // enables semantics tree generation
await tester.pumpWidget(widgetUnderTest);
expect(scrollController.offset, -250.0);
final int firstContainerId = tester.renderObject(find.byKey(const ValueKey<int>(4))).debugSemantics!.id;
tester.binding.pipelineOwner.semanticsOwner!.performAction(firstContainerId, SemanticsAction.showOnScreen);
await tester.pumpAndSettle();
expect(scrollController.offset, -200.0);
semantics.dispose();
});
testWidgets('does not change position of items already fully on-screen', (WidgetTester tester) async {
semantics = SemanticsTester(tester); // enables semantics tree generation
await tester.pumpWidget(widgetUnderTest);
expect(scrollController.offset, -250.0);
final int firstContainerId = tester.renderObject(find.byKey(const ValueKey<int>(3))).debugSemantics!.id;
tester.binding.pipelineOwner.semanticsOwner!.performAction(firstContainerId, SemanticsAction.showOnScreen);
await tester.pumpAndSettle();
expect(scrollController.offset, -250.0);
semantics.dispose();
});
});
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;
final SemanticsNode outerListPane = innerListPane.parent!;
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);
// 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) {
tester.binding.pipelineOwner.semanticsOwner!.performAction(node.id, SemanticsAction.showOnScreen);
await tester.pumpAndSettle();
expect(nodeGlobalRect(innerListPane), nodeGlobalRect(outerListPane));
}
semantics.dispose();
});
}
Future<void> flingUp(WidgetTester tester, { int repetitions = 1 }) => fling(tester, const Offset(0.0, -200.0), repetitions);
Future<void> flingDown(WidgetTester tester, { int repetitions = 1 }) => fling(tester, const Offset(0.0, 200.0), repetitions);
Future<void> flingRight(WidgetTester tester, { int repetitions = 1 }) => fling(tester, const Offset(200.0, 0.0), repetitions);
Future<void> flingLeft(WidgetTester tester, { int repetitions = 1 }) => fling(tester, const Offset(-200.0, 0.0), repetitions);
Future<void> fling(WidgetTester tester, Offset offset, int repetitions) async {
while (repetitions-- > 0) {
await tester.fling(find.byType(ListView), offset, 1000.0);
await tester.pump();
await tester.pump(const Duration(seconds: 5));
}
}
Rect nodeGlobalRect(SemanticsNode node) {
Matrix4 globalTransform = node.transform ?? Matrix4.identity();
for (SemanticsNode? parent = node.parent; parent != null; parent = parent.parent) {
if (parent.transform != null) {
globalTransform = parent.transform!.multiplied(globalTransform);
}
}
return MatrixUtils.transformRect(globalTransform, node.rect);
}