flutter_flutter/packages/flutter/test/material/expansion_tile_test.dart
Hannah Jin a397a24f6c
Replace semantic announcements in expansion tile for Android (#179917)
fixes: https://github.com/flutter/flutter/issues/177785  
## Pre-launch Checklist

- [ ] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [ ] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [ ] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [ ] I signed the [CLA].
- [ ] I listed at least one issue that this PR fixes in the description
above.
- [ ] I updated/added relevant documentation (doc comments with `///`).
- [ ] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [ ] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [ ] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

**Note**: The Flutter team is currently trialing the use of [Gemini Code
Assist for
GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code).
Comments from the `gemini-code-assist` bot should not be taken as
authoritative feedback from the Flutter team. If you find its comments
useful you can update your code accordingly, but if you are unsure or
disagree with the feedback, please feel free to wait for a Flutter team
member's review for guidance on which automated comments should be
addressed.

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
2026-01-06 00:40:24 +00:00

2077 lines
70 KiB
Dart

// 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.
// This file is run as part of a reduced test set in CI on Mac and Windows
// machines.
@Tags(<String>['reduced-test-set'])
library;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
class TestIcon extends StatefulWidget {
const TestIcon({super.key});
@override
TestIconState createState() => TestIconState();
}
class TestIconState extends State<TestIcon> {
late IconThemeData iconTheme;
@override
Widget build(BuildContext context) {
iconTheme = IconTheme.of(context);
return const Icon(Icons.expand_more);
}
}
class TestText extends StatefulWidget {
const TestText(this.text, {super.key});
final String text;
@override
TestTextState createState() => TestTextState();
}
class TestTextState extends State<TestText> {
late TextStyle textStyle;
@override
Widget build(BuildContext context) {
textStyle = DefaultTextStyle.of(context).style;
return Text(widget.text);
}
}
void main() {
const dividerColor = Color(0x1f333333);
const Color foregroundColor = Colors.blueAccent;
const Color unselectedWidgetColor = Colors.black54;
const Color headerColor = Colors.black45;
Material getMaterial(WidgetTester tester) {
return tester.widget<Material>(
find.descendant(of: find.byType(ExpansionTile), matching: find.byType(Material)),
);
}
testWidgets(
'ExpansionTile initial state',
(WidgetTester tester) async {
final Key topKey = UniqueKey();
final Key tileKey = UniqueKey();
const Key expandedKey = PageStorageKey<String>('expanded');
const Key collapsedKey = PageStorageKey<String>('collapsed');
const Key defaultKey = PageStorageKey<String>('default');
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(dividerColor: dividerColor),
home: Material(
child: SingleChildScrollView(
child: Column(
children: <Widget>[
ListTile(title: const Text('Top'), key: topKey),
ExpansionTile(
key: expandedKey,
initiallyExpanded: true,
title: const Text('Expanded'),
backgroundColor: Colors.red,
children: <Widget>[ListTile(key: tileKey, title: const Text('0'))],
),
ExpansionTile(
key: collapsedKey,
title: const Text('Collapsed'),
children: <Widget>[ListTile(key: tileKey, title: const Text('0'))],
),
const ExpansionTile(
key: defaultKey,
title: Text('Default'),
children: <Widget>[ListTile(title: Text('0'))],
),
],
),
),
),
),
);
double getHeight(Key key) => tester.getSize(find.byKey(key)).height;
DecoratedBox getDecoratedBox(Key key) => tester.firstWidget(
find.descendant(of: find.byKey(key), matching: find.byType(DecoratedBox)),
);
expect(getHeight(topKey), getHeight(expandedKey) - getHeight(tileKey) - 2.0);
expect(getHeight(topKey), getHeight(collapsedKey) - 2.0);
expect(getHeight(topKey), getHeight(defaultKey) - 2.0);
var expandedContainerDecoration = getDecoratedBox(expandedKey).decoration as ShapeDecoration;
expect(expandedContainerDecoration.color, Colors.red);
expect((expandedContainerDecoration.shape as Border).top.color, dividerColor);
expect((expandedContainerDecoration.shape as Border).bottom.color, dividerColor);
var collapsedContainerDecoration =
getDecoratedBox(collapsedKey).decoration as ShapeDecoration;
expect(collapsedContainerDecoration.color, Colors.transparent);
expect((collapsedContainerDecoration.shape as Border).top.color, Colors.transparent);
expect((collapsedContainerDecoration.shape as Border).bottom.color, Colors.transparent);
await tester.tap(find.text('Expanded'));
await tester.tap(find.text('Collapsed'));
await tester.tap(find.text('Default'));
await tester.pump();
// Pump to the middle of the animation for expansion.
await tester.pump(const Duration(milliseconds: 100));
final collapsingContainerDecoration =
getDecoratedBox(collapsedKey).decoration as ShapeDecoration;
expect(collapsingContainerDecoration.color, Colors.transparent);
expect(
(collapsingContainerDecoration.shape as Border).top.color,
isSameColorAs(const Color(0x15222222)),
);
expect(
(collapsingContainerDecoration.shape as Border).bottom.color,
isSameColorAs(const Color(0x15222222)),
);
// Pump all the way to the end now.
await tester.pump(const Duration(seconds: 1));
expect(getHeight(topKey), getHeight(expandedKey) - 2.0);
expect(getHeight(topKey), getHeight(collapsedKey) - getHeight(tileKey) - 2.0);
expect(getHeight(topKey), getHeight(defaultKey) - getHeight(tileKey) - 2.0);
// Expanded should be collapsed now.
expandedContainerDecoration = getDecoratedBox(expandedKey).decoration as ShapeDecoration;
expect(expandedContainerDecoration.color, Colors.transparent);
expect((expandedContainerDecoration.shape as Border).top.color, Colors.transparent);
expect((expandedContainerDecoration.shape as Border).bottom.color, Colors.transparent);
// Collapsed should be expanded now.
collapsedContainerDecoration = getDecoratedBox(collapsedKey).decoration as ShapeDecoration;
expect(collapsedContainerDecoration.color, Colors.transparent);
expect((collapsedContainerDecoration.shape as Border).top.color, dividerColor);
expect((collapsedContainerDecoration.shape as Border).bottom.color, dividerColor);
},
variant: const TargetPlatformVariant(<TargetPlatform>{
TargetPlatform.iOS,
TargetPlatform.macOS,
}),
);
testWidgets(
'ExpansionTile Theme dependencies',
(WidgetTester tester) async {
final Key expandedTitleKey = UniqueKey();
final Key collapsedTitleKey = UniqueKey();
final Key expandedIconKey = UniqueKey();
final Key collapsedIconKey = UniqueKey();
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(
useMaterial3: false,
colorScheme: ColorScheme.fromSwatch().copyWith(primary: foregroundColor),
unselectedWidgetColor: unselectedWidgetColor,
textTheme: const TextTheme(titleMedium: TextStyle(color: headerColor)),
),
home: Material(
child: SingleChildScrollView(
child: Column(
children: <Widget>[
const ListTile(title: Text('Top')),
ExpansionTile(
initiallyExpanded: true,
title: TestText('Expanded', key: expandedTitleKey),
backgroundColor: Colors.red,
trailing: TestIcon(key: expandedIconKey),
children: const <Widget>[ListTile(title: Text('0'))],
),
ExpansionTile(
title: TestText('Collapsed', key: collapsedTitleKey),
trailing: TestIcon(key: collapsedIconKey),
children: const <Widget>[ListTile(title: Text('0'))],
),
],
),
),
),
),
);
Color iconColor(Key key) => tester.state<TestIconState>(find.byKey(key)).iconTheme.color!;
Color textColor(Key key) => tester.state<TestTextState>(find.byKey(key)).textStyle.color!;
expect(textColor(expandedTitleKey), foregroundColor);
expect(textColor(collapsedTitleKey), headerColor);
expect(iconColor(expandedIconKey), foregroundColor);
expect(iconColor(collapsedIconKey), unselectedWidgetColor);
// Tap both tiles to change their state: collapse and extend respectively
await tester.tap(find.text('Expanded'));
await tester.tap(find.text('Collapsed'));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
await tester.pump(const Duration(seconds: 1));
expect(textColor(expandedTitleKey), headerColor);
expect(textColor(collapsedTitleKey), foregroundColor);
expect(iconColor(expandedIconKey), unselectedWidgetColor);
expect(iconColor(collapsedIconKey), foregroundColor);
},
variant: const TargetPlatformVariant(<TargetPlatform>{
TargetPlatform.iOS,
TargetPlatform.macOS,
}),
);
testWidgets('ExpansionTile subtitle', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: ExpansionTile(
title: Text('Title'),
subtitle: Text('Subtitle'),
children: <Widget>[ListTile(title: Text('0'))],
),
),
),
);
expect(find.text('Subtitle'), findsOneWidget);
});
testWidgets('ExpansionTile maintainState', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(platform: TargetPlatform.iOS, dividerColor: dividerColor),
home: const Material(
child: SingleChildScrollView(
child: Column(
children: <Widget>[
ExpansionTile(
title: Text('Tile 1'),
maintainState: true,
children: <Widget>[Text('Maintaining State')],
),
ExpansionTile(title: Text('Title 2'), children: <Widget>[Text('Discarding State')]),
],
),
),
),
),
);
// This text should be offstage while ExpansionTile collapsed
expect(find.text('Maintaining State', skipOffstage: false), findsOneWidget);
expect(find.text('Maintaining State'), findsNothing);
// This text shouldn't be there while ExpansionTile collapsed
expect(find.text('Discarding State'), findsNothing);
});
testWidgets('ExpansionTile padding test', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Material(
child: Center(
child: ExpansionTile(
title: Text('Hello'),
tilePadding: EdgeInsets.fromLTRB(8, 12, 4, 10),
),
),
),
),
);
final Rect titleRect = tester.getRect(find.text('Hello'));
final Rect trailingRect = tester.getRect(find.byIcon(Icons.expand_more));
final Rect listTileRect = tester.getRect(find.byType(ListTile));
final tallerWidget = titleRect.height > trailingRect.height ? titleRect : trailingRect;
// Check the positions of title and trailing Widgets, after padding is applied.
expect(listTileRect.left, titleRect.left - 8);
expect(listTileRect.right, trailingRect.right + 4);
// Calculate the remaining height of ListTile from the default height.
final double remainingHeight = 56 - tallerWidget.height;
expect(listTileRect.top, tallerWidget.top - remainingHeight / 2 - 12);
expect(listTileRect.bottom, tallerWidget.bottom + remainingHeight / 2 + 10);
});
testWidgets('ExpansionTile expandedAlignment test', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Material(
child: Center(
child: ExpansionTile(
title: Text('title'),
expandedAlignment: Alignment.centerLeft,
children: <Widget>[
SizedBox(height: 100, width: 100),
SizedBox(height: 100, width: 80),
],
),
),
),
),
);
await tester.tap(find.text('title'));
await tester.pumpAndSettle();
final Rect columnRect = tester.getRect(find.byType(Column).last);
// The expandedAlignment is used to define the alignment of the Column widget in
// expanded tile, not the alignment of the children inside the Column.
expect(columnRect.left, 0.0);
// The width of the Column is the width of the largest child. The largest width
// being 100.0, the offset of the right edge of Column from X-axis should be 100.0.
expect(columnRect.right, 100.0);
});
testWidgets('ExpansionTile expandedCrossAxisAlignment test', (WidgetTester tester) async {
const child0Key = Key('child0');
const child1Key = Key('child1');
await tester.pumpWidget(
const MaterialApp(
home: Material(
child: Center(
child: ExpansionTile(
title: Text('title'),
// Set the column's alignment to Alignment.centerRight to test CrossAxisAlignment
// of children widgets. This helps distinguish the effect of expandedAlignment
// and expandedCrossAxisAlignment later in the test.
expandedAlignment: Alignment.centerRight,
expandedCrossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
SizedBox(height: 100, width: 100, key: child0Key),
SizedBox(height: 100, width: 80, key: child1Key),
],
),
),
),
),
);
await tester.tap(find.text('title'));
await tester.pumpAndSettle();
final Rect columnRect = tester.getRect(find.byType(Column).last);
final Rect child0Rect = tester.getRect(find.byKey(child0Key));
final Rect child1Rect = tester.getRect(find.byKey(child1Key));
// Since expandedAlignment is set to Alignment.centerRight, the column of children
// should be aligned to the center right of the expanded tile. This provides confirmation
// that the expandedCrossAxisAlignment.start is 700.0, where columnRect.left is.
expect(columnRect.right, 800.0);
// The width of the Column is the width of the largest child. The largest width
// being 100.0, the offset of the left edge of Column from X-axis should be 700.0.
expect(columnRect.left, 700.0);
// Considering the value of expandedCrossAxisAlignment is CrossAxisAlignment.start,
// the offset of the left edge of both the children from X-axis should be 700.0.
expect(child0Rect.left, 700.0);
expect(child1Rect.left, 700.0);
});
testWidgets('CrossAxisAlignment.baseline is not allowed', (WidgetTester tester) async {
expect(
() {
MaterialApp(
home: Material(
child: ExpansionTile(
initiallyExpanded: true,
title: const Text('title'),
expandedCrossAxisAlignment: CrossAxisAlignment.baseline,
),
),
);
},
throwsA(
isA<AssertionError>().having(
(AssertionError error) => error.toString(),
'.toString()',
contains(
'CrossAxisAlignment.baseline is not supported since the expanded'
' children are aligned in a column, not a row. Try to use another constant.',
),
),
),
);
});
testWidgets('expandedCrossAxisAlignment and expandedAlignment default values', (
WidgetTester tester,
) async {
const child1Key = Key('child1');
await tester.pumpWidget(
const MaterialApp(
home: Material(
child: Center(
child: ExpansionTile(
title: Text('title'),
children: <Widget>[
SizedBox(height: 100, width: 100),
SizedBox(height: 100, width: 80, key: child1Key),
],
),
),
),
),
);
await tester.tap(find.text('title'));
await tester.pumpAndSettle();
final Rect columnRect = tester.getRect(find.byType(Column).last);
final Rect child1Rect = tester.getRect(find.byKey(child1Key));
// The default viewport size is Size(800, 600).
// By default the value of extendedAlignment is Alignment.center, hence the offset
// of left and right edges from x axis should be equal.
expect(columnRect.left, 800 - columnRect.right);
// By default the value of extendedCrossAxisAlignment is CrossAxisAlignment.center, hence
// the offset of left and right edges from Column should be equal.
expect(child1Rect.left - columnRect.left, columnRect.right - child1Rect.right);
});
testWidgets('childrenPadding default value', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Material(
child: Center(
child: ExpansionTile(
title: Text('title'),
children: <Widget>[SizedBox(height: 100, width: 100)],
),
),
),
),
);
await tester.tap(find.text('title'));
await tester.pumpAndSettle();
final Rect columnRect = tester.getRect(find.byType(Column).last);
final Rect paddingRect = tester.getRect(find.byType(Padding).last);
// By default, the value of childrenPadding is EdgeInsets.zero, hence offset
// of all the edges from x-axis and y-axis should be equal for Padding and Column.
expect(columnRect.top, paddingRect.top);
expect(columnRect.left, paddingRect.left);
expect(columnRect.right, paddingRect.right);
expect(columnRect.bottom, paddingRect.bottom);
});
testWidgets('ExpansionTile childrenPadding test', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Material(
child: Center(
child: ExpansionTile(
title: Text('title'),
childrenPadding: EdgeInsets.fromLTRB(10, 8, 12, 4),
children: <Widget>[SizedBox(height: 100, width: 100)],
),
),
),
),
);
await tester.tap(find.text('title'));
await tester.pumpAndSettle();
final Rect columnRect = tester.getRect(find.byType(Column).last);
final Rect paddingRect = tester.getRect(find.byType(Padding).last);
// Check the offset of all the edges from x-axis and y-axis after childrenPadding
// is applied.
expect(columnRect.left, paddingRect.left + 10);
expect(columnRect.top, paddingRect.top + 8);
expect(columnRect.right, paddingRect.right - 12);
expect(columnRect.bottom, paddingRect.bottom - 4);
});
testWidgets('ExpansionTile.collapsedBackgroundColor', (WidgetTester tester) async {
const expansionTileKey = Key('expansionTileKey');
const Color backgroundColor = Colors.red;
const Color collapsedBackgroundColor = Colors.brown;
await tester.pumpWidget(
const MaterialApp(
home: Material(
child: ExpansionTile(
key: expansionTileKey,
title: Text('Title'),
backgroundColor: backgroundColor,
collapsedBackgroundColor: collapsedBackgroundColor,
children: <Widget>[SizedBox(height: 100, width: 100)],
),
),
),
);
var shapeDecoration =
tester
.firstWidget<DecoratedBox>(
find.descendant(
of: find.byKey(expansionTileKey),
matching: find.byType(DecoratedBox),
),
)
.decoration
as ShapeDecoration;
expect(shapeDecoration.color, collapsedBackgroundColor);
await tester.tap(find.text('Title'));
await tester.pumpAndSettle();
shapeDecoration =
tester
.firstWidget<DecoratedBox>(
find.descendant(
of: find.byKey(expansionTileKey),
matching: find.byType(DecoratedBox),
),
)
.decoration
as ShapeDecoration;
expect(shapeDecoration.color, backgroundColor);
});
testWidgets('ExpansionTile default iconColor, textColor', (WidgetTester tester) async {
final theme = ThemeData();
await tester.pumpWidget(
MaterialApp(
theme: theme,
home: const Material(
child: ExpansionTile(
title: TestText('title'),
trailing: TestIcon(),
children: <Widget>[SizedBox(height: 100, width: 100)],
),
),
),
);
Color getIconColor() => tester.state<TestIconState>(find.byType(TestIcon)).iconTheme.color!;
Color getTextColor() => tester.state<TestTextState>(find.byType(TestText)).textStyle.color!;
expect(getIconColor(), theme.colorScheme.onSurfaceVariant);
expect(getTextColor(), theme.colorScheme.onSurface);
await tester.tap(find.text('title'));
await tester.pumpAndSettle();
expect(getIconColor(), theme.colorScheme.primary);
expect(getTextColor(), theme.colorScheme.onSurface);
});
testWidgets('ExpansionTile iconColor, textColor', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/pull/78281
const iconColor = Color(0xff00ff00);
const collapsedIconColor = Color(0xff0000ff);
const textColor = Color(0xff00ffff);
const collapsedTextColor = Color(0xffff00ff);
await tester.pumpWidget(
const MaterialApp(
home: Material(
child: ExpansionTile(
iconColor: iconColor,
collapsedIconColor: collapsedIconColor,
textColor: textColor,
collapsedTextColor: collapsedTextColor,
title: TestText('title'),
trailing: TestIcon(),
children: <Widget>[SizedBox(height: 100, width: 100)],
),
),
),
);
Color getIconColor() => tester.state<TestIconState>(find.byType(TestIcon)).iconTheme.color!;
Color getTextColor() => tester.state<TestTextState>(find.byType(TestText)).textStyle.color!;
expect(getIconColor(), collapsedIconColor);
expect(getTextColor(), collapsedTextColor);
await tester.tap(find.text('title'));
await tester.pumpAndSettle();
expect(getIconColor(), iconColor);
expect(getTextColor(), textColor);
});
testWidgets('ExpansionTile Border', (WidgetTester tester) async {
const Key expansionTileKey = PageStorageKey<String>('expansionTile');
const collapsedShape = Border(
top: BorderSide(color: Colors.blue),
bottom: BorderSide(color: Colors.green),
);
final shape = Border.all(color: Colors.red);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: ExpansionTile(
key: expansionTileKey,
title: const Text('ExpansionTile'),
collapsedShape: collapsedShape,
shape: shape,
children: const <Widget>[ListTile(title: Text('0'))],
),
),
),
);
// When a custom shape is provided, ExpansionTile will use the
// Material widget to draw the shape and background color
// instead of a Container.
Material material = getMaterial(tester);
// ExpansionTile should be collapsed initially.
expect(material.shape, collapsedShape);
expect(material.clipBehavior, Clip.antiAlias);
await tester.tap(find.text('ExpansionTile'));
await tester.pumpAndSettle();
// ExpansionTile should be Expanded now.
material = getMaterial(tester);
expect(material.shape, shape);
expect(material.clipBehavior, Clip.antiAlias);
});
testWidgets('ExpansionTile platform controlAffinity test', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Material(child: ExpansionTile(title: Text('Title'))),
),
);
final ListTile listTile = tester.widget(find.byType(ListTile));
expect(listTile.leading, isNull);
expect(listTile.trailing.runtimeType, RotationTransition);
});
testWidgets('ExpansionTile trailing controlAffinity test', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Material(
child: ExpansionTile(
title: Text('Title'),
controlAffinity: ListTileControlAffinity.trailing,
),
),
),
);
final ListTile listTile = tester.widget(find.byType(ListTile));
expect(listTile.leading, isNull);
expect(listTile.trailing.runtimeType, RotationTransition);
});
testWidgets('ExpansionTile leading controlAffinity test', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Material(
child: ExpansionTile(
title: Text('Title'),
controlAffinity: ListTileControlAffinity.leading,
),
),
),
);
final ListTile listTile = tester.widget(find.byType(ListTile));
expect(listTile.leading.runtimeType, RotationTransition);
expect(listTile.trailing, isNull);
});
testWidgets('ExpansionTile override rotating icon test', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Material(
child: ExpansionTile(
title: Text('Title'),
leading: Icon(Icons.info),
controlAffinity: ListTileControlAffinity.leading,
),
),
),
);
final ListTile listTile = tester.widget(find.byType(ListTile));
expect(listTile.leading.runtimeType, Icon);
expect(listTile.trailing, isNull);
});
testWidgets('Nested ListTile Semantics', (WidgetTester tester) async {
tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
final SemanticsHandle handle = tester.ensureSemantics();
await tester.pumpWidget(
const MaterialApp(
home: Material(
child: Column(
children: <Widget>[
ExpansionTile(title: Text('First Expansion Tile'), internalAddSemanticForOnTap: true),
ExpansionTile(
initiallyExpanded: true,
title: Text('Second Expansion Tile'),
internalAddSemanticForOnTap: true,
),
],
),
),
),
);
await tester.pumpAndSettle();
// Focus the first ExpansionTile.
tester.binding.focusManager.primaryFocus?.nextFocus();
await tester.pumpAndSettle();
// The first list tile is focused.
expect(
tester.getSemantics(find.byType(ListTile).first),
matchesSemantics(
isButton: true,
hasTapAction: true,
hasFocusAction: true,
hasEnabledState: true,
hasSelectedState: true,
isEnabled: true,
isFocused: true,
isFocusable: true,
label: 'First Expansion Tile',
textDirection: TextDirection.ltr,
),
);
// The first list tile is not focused.
expect(
tester.getSemantics(find.byType(ListTile).last),
matchesSemantics(
isButton: true,
hasTapAction: true,
hasFocusAction: true,
hasEnabledState: true,
hasSelectedState: true,
isEnabled: true,
isFocusable: true,
label: 'Second Expansion Tile',
textDirection: TextDirection.ltr,
),
);
handle.dispose();
});
testWidgets(
'ExpansionTile Semantics announcement',
(WidgetTester tester) async {
final SemanticsHandle handle = tester.ensureSemantics();
const localizations = DefaultMaterialLocalizations();
await tester.pumpWidget(
const MaterialApp(
home: Material(
child: ExpansionTile(
title: Text('Title'),
children: <Widget>[SizedBox(height: 100, width: 100)],
),
),
),
);
// There is no semantics announcement without tap action.
expect(tester.takeAnnouncements(), isEmpty);
// Tap the title to expand ExpansionTile.
await tester.tap(find.text('Title'));
await tester.pumpAndSettle();
// The announcement should be the opposite of the current state.
// The ExpansionTile is expanded, so the announcement should be
// "Expanded".
expect(
tester.takeAnnouncements().first,
isAccessibilityAnnouncement(localizations.collapsedHint),
);
// Tap the title to collapse ExpansionTile.
await tester.tap(find.text('Title'));
await tester.pumpAndSettle();
// The announcement should be the opposite of the current state.
// The ExpansionTile is collapsed, so the announcement should be
// "Collapsed".
expect(
tester.takeAnnouncements().first,
isAccessibilityAnnouncement(localizations.expandedHint),
);
handle.dispose();
},
// [intended] iOS: https://github.com/flutter/flutter/issues/122101.
// android: https://github.com/flutter/flutter/issues/165510
skip:
defaultTargetPlatform == TargetPlatform.iOS ||
defaultTargetPlatform == TargetPlatform.android,
);
// This is a regression test for https://github.com/flutter/flutter/issues/132264.
testWidgets(
'ExpansionTile Semantics announcement is delayed on iOS',
(WidgetTester tester) async {
final SemanticsHandle handle = tester.ensureSemantics();
const localizations = DefaultMaterialLocalizations();
await tester.pumpWidget(
const MaterialApp(
home: Material(
child: ExpansionTile(
title: Text('Title'),
children: <Widget>[SizedBox(height: 100, width: 100)],
),
),
),
);
// There is no semantics announcement without tap action.
expect(tester.takeAnnouncements(), isEmpty);
// Tap the title to expand ExpansionTile.
await tester.tap(find.text('Title'));
await tester.pump(const Duration(seconds: 1)); // Wait for the announcement to be made.
expect(
tester.takeAnnouncements().first,
isAccessibilityAnnouncement(localizations.collapsedHint),
);
// Tap the title to collapse ExpansionTile.
await tester.tap(find.text('Title'));
await tester.pump(const Duration(seconds: 1)); // Wait for the announcement to be made.
expect(
tester.takeAnnouncements().first,
isAccessibilityAnnouncement(localizations.expandedHint),
);
handle.dispose();
},
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
);
testWidgets('Semantics with the onTapHint is an ancestor of ListTile', (
WidgetTester tester,
) async {
// This is a regression test for https://github.com/flutter/flutter/pull/121624
final SemanticsHandle handle = tester.ensureSemantics();
const localizations = DefaultMaterialLocalizations();
await tester.pumpWidget(
const MaterialApp(
home: Material(
child: Column(
children: <Widget>[
ExpansionTile(title: Text('First Expansion Tile')),
ExpansionTile(initiallyExpanded: true, title: Text('Second Expansion Tile')),
],
),
),
),
);
SemanticsNode semantics = tester.getSemantics(
find.ancestor(of: find.byType(ListTile).first, matching: find.byType(Semantics)).first,
);
expect(semantics, isNotNull);
// The onTapHint is passed to semantics properties's hintOverrides.
expect(semantics.hintOverrides, isNotNull);
// The hint should be the opposite of the current state.
// The first ExpansionTile is collapsed, so the hint should be
// "double tap to expand".
expect(semantics.hintOverrides!.onTapHint, localizations.expansionTileCollapsedTapHint);
semantics = tester.getSemantics(
find.ancestor(of: find.byType(ListTile).last, matching: find.byType(Semantics)).first,
);
expect(semantics, isNotNull);
// The onTapHint is passed to semantics properties's hintOverrides.
expect(semantics.hintOverrides, isNotNull);
// The hint should be the opposite of the current state.
// The second ExpansionTile is expanded, so the hint should be
// "double tap to collapse".
expect(semantics.hintOverrides!.onTapHint, localizations.expansionTileExpandedTapHint);
handle.dispose();
});
testWidgets(
'Semantics hint for iOS and macOS',
(WidgetTester tester) async {
final SemanticsHandle handle = tester.ensureSemantics();
const localizations = DefaultMaterialLocalizations();
await tester.pumpWidget(
const MaterialApp(
home: Material(
child: Column(
children: <Widget>[
ExpansionTile(title: Text('First Expansion Tile')),
ExpansionTile(initiallyExpanded: true, title: Text('Second Expansion Tile')),
],
),
),
),
);
SemanticsNode semantics = tester.getSemantics(
find.ancestor(of: find.byType(ListTile).first, matching: find.byType(Semantics)).first,
);
expect(semantics, isNotNull);
expect(
semantics.hint,
'${localizations.expandedHint}\n ${localizations.expansionTileCollapsedHint}',
);
semantics = tester.getSemantics(
find.ancestor(of: find.byType(ListTile).last, matching: find.byType(Semantics)).first,
);
expect(semantics, isNotNull);
expect(
semantics.hint,
'${localizations.collapsedHint}\n ${localizations.expansionTileExpandedHint}',
);
handle.dispose();
},
variant: const TargetPlatformVariant(<TargetPlatform>{
TargetPlatform.iOS,
TargetPlatform.macOS,
}),
);
testWidgets('Collapsed ExpansionTile properties can be updated with setState', (
WidgetTester tester,
) async {
const expansionTileKey = Key('expansionTileKey');
ShapeBorder collapsedShape = const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(4)),
);
var collapsedTextColor = const Color(0xffffffff);
var collapsedBackgroundColor = const Color(0xffff0000);
var collapsedIconColor = const Color(0xffffffff);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: StatefulBuilder(
builder: (BuildContext context, StateSetter setState) {
return Column(
children: <Widget>[
ExpansionTile(
key: expansionTileKey,
collapsedShape: collapsedShape,
collapsedTextColor: collapsedTextColor,
collapsedBackgroundColor: collapsedBackgroundColor,
collapsedIconColor: collapsedIconColor,
title: const TestText('title'),
trailing: const TestIcon(),
children: const <Widget>[SizedBox(height: 100, width: 100)],
),
// This button is used to update the ExpansionTile properties.
FilledButton(
onPressed: () {
setState(() {
collapsedShape = const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
);
collapsedTextColor = const Color(0xff000000);
collapsedBackgroundColor = const Color(0xffffff00);
collapsedIconColor = const Color(0xff000000);
});
},
child: const Text('Update collapsed properties'),
),
],
);
},
),
),
),
);
// When a custom shape is provided, ExpansionTile will use the
// Material widget to draw the shape and background color
// instead of a Container.
Material material = getMaterial(tester);
// Test initial ExpansionTile properties.
expect(
material.shape,
const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4))),
);
expect(material.color, const Color(0xffff0000));
expect(material.clipBehavior, Clip.antiAlias);
expect(
tester.state<TestIconState>(find.byType(TestIcon)).iconTheme.color,
const Color(0xffffffff),
);
expect(
tester.state<TestTextState>(find.byType(TestText)).textStyle.color,
const Color(0xffffffff),
);
// Tap the button to update the ExpansionTile properties.
await tester.tap(find.text('Update collapsed properties'));
await tester.pumpAndSettle();
material = getMaterial(tester);
// Test updated ExpansionTile properties.
expect(
material.shape,
const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(16))),
);
expect(material.color, const Color(0xffffff00));
expect(material.clipBehavior, Clip.antiAlias);
expect(
tester.state<TestIconState>(find.byType(TestIcon)).iconTheme.color,
const Color(0xff000000),
);
expect(
tester.state<TestTextState>(find.byType(TestText)).textStyle.color,
const Color(0xff000000),
);
});
testWidgets('Expanded ExpansionTile properties can be updated with setState', (
WidgetTester tester,
) async {
const expansionTileKey = Key('expansionTileKey');
ShapeBorder shape = const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
);
var textColor = const Color(0xff00ffff);
var backgroundColor = const Color(0xff0000ff);
var iconColor = const Color(0xff00ffff);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: StatefulBuilder(
builder: (BuildContext context, StateSetter setState) {
return Column(
children: <Widget>[
ExpansionTile(
key: expansionTileKey,
shape: shape,
textColor: textColor,
backgroundColor: backgroundColor,
iconColor: iconColor,
title: const TestText('title'),
trailing: const TestIcon(),
children: const <Widget>[SizedBox(height: 100, width: 100)],
),
// This button is used to update the ExpansionTile properties.
FilledButton(
onPressed: () {
setState(() {
shape = const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(6)),
);
textColor = const Color(0xffffffff);
backgroundColor = const Color(0xff123456);
iconColor = const Color(0xffffffff);
});
},
child: const Text('Update collapsed properties'),
),
],
);
},
),
),
),
);
// Tap to expand the ExpansionTile.
await tester.tap(find.text('title'));
await tester.pumpAndSettle();
// When a custom shape is provided, ExpansionTile will use the
// Material widget to draw the shape and background color
// instead of a Container.
Material material = getMaterial(tester);
// Test initial ExpansionTile properties.
expect(
material.shape,
const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
);
expect(material.color, const Color(0xff0000ff));
expect(material.clipBehavior, Clip.antiAlias);
expect(
tester.state<TestIconState>(find.byType(TestIcon)).iconTheme.color,
const Color(0xff00ffff),
);
expect(
tester.state<TestTextState>(find.byType(TestText)).textStyle.color,
const Color(0xff00ffff),
);
// Tap the button to update the ExpansionTile properties.
await tester.tap(find.text('Update collapsed properties'));
await tester.pumpAndSettle();
material = getMaterial(tester);
iconColor = tester.state<TestIconState>(find.byType(TestIcon)).iconTheme.color!;
textColor = tester.state<TestTextState>(find.byType(TestText)).textStyle.color!;
// Test updated ExpansionTile properties.
expect(
material.shape,
const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(6))),
);
expect(material.color, const Color(0xff123456));
expect(material.clipBehavior, Clip.antiAlias);
expect(
tester.state<TestIconState>(find.byType(TestIcon)).iconTheme.color,
const Color(0xffffffff),
);
expect(
tester.state<TestTextState>(find.byType(TestText)).textStyle.color,
const Color(0xffffffff),
);
});
testWidgets('Override ExpansionTile animation using AnimationStyle', (WidgetTester tester) async {
const expansionTileKey = Key('expansionTileKey');
Widget buildExpansionTile({AnimationStyle? animationStyle}) {
return MaterialApp(
home: Material(
child: Center(
child: ExpansionTile(
key: expansionTileKey,
expansionAnimationStyle: animationStyle,
title: const TestText('title'),
children: const <Widget>[SizedBox(height: 100, width: 100)],
),
),
),
);
}
await tester.pumpWidget(buildExpansionTile());
double getHeight(Key key) => tester.getSize(find.byKey(key)).height;
// Test initial ExpansionTile height.
expect(getHeight(expansionTileKey), 58.0);
// Test the default expansion animation.
await tester.tap(find.text('title'));
await tester.pump();
await tester.pump(
const Duration(milliseconds: 50),
); // Advance the animation by 1/4 of its duration.
expect(getHeight(expansionTileKey), closeTo(67.4, 0.1));
await tester.pump(
const Duration(milliseconds: 50),
); // Advance the animation by 2/4 of its duration.
expect(getHeight(expansionTileKey), closeTo(89.6, 0.1));
await tester.pumpAndSettle(); // Advance the animation to the end.
expect(getHeight(expansionTileKey), 158.0);
// Tap to collapse the ExpansionTile.
await tester.tap(find.text('title'));
await tester.pumpAndSettle();
// Override the animation duration.
await tester.pumpWidget(
buildExpansionTile(
animationStyle: const AnimationStyle(duration: Duration(milliseconds: 800)),
),
);
await tester.pumpAndSettle();
// Test the overridden animation duration.
await tester.tap(find.text('title'));
await tester.pump();
await tester.pump(
const Duration(milliseconds: 200),
); // Advance the animation by 1/4 of its duration.
expect(getHeight(expansionTileKey), closeTo(67.4, 0.1));
await tester.pump(
const Duration(milliseconds: 200),
); // Advance the animation by 2/4 of its duration.
expect(getHeight(expansionTileKey), closeTo(89.6, 0.1));
await tester.pumpAndSettle(); // Advance the animation to the end.
expect(getHeight(expansionTileKey), 158.0);
// Tap to collapse the ExpansionTile.
await tester.tap(find.text('title'));
await tester.pumpAndSettle();
// Override the animation curve.
await tester.pumpWidget(
buildExpansionTile(
animationStyle: const AnimationStyle(
curve: Easing.emphasizedDecelerate,
reverseCurve: Easing.emphasizedAccelerate,
),
),
);
await tester.pumpAndSettle();
// Test the overridden animation curve.
await tester.tap(find.text('title'));
await tester.pump();
await tester.pump(
const Duration(milliseconds: 50),
); // Advance the animation by 1/4 of its duration.
expect(getHeight(expansionTileKey), closeTo(141.2, 0.1));
await tester.pump(
const Duration(milliseconds: 50),
); // Advance the animation by 2/4 of its duration.
expect(getHeight(expansionTileKey), closeTo(153, 0.1));
await tester.pumpAndSettle(); // Advance the animation to the end.
expect(getHeight(expansionTileKey), 158.0);
// Test the overridden reverse (collapse) animation curve.
await tester.tap(find.text('title'));
await tester.pump();
await tester.pump(
const Duration(milliseconds: 50),
); // Advance the animation by 1/4 of its duration.
expect(getHeight(expansionTileKey), closeTo(98.6, 0.1));
await tester.pump(
const Duration(milliseconds: 50),
); // Advance the animation by 2/4 of its duration.
expect(getHeight(expansionTileKey), closeTo(73.4, 0.1));
await tester.pumpAndSettle(); // Advance the animation to the end.
expect(getHeight(expansionTileKey), 58.0);
// Test no animation.
await tester.pumpWidget(buildExpansionTile(animationStyle: AnimationStyle.noAnimation));
// Tap to expand the ExpansionTile.
await tester.tap(find.text('title'));
await tester.pump();
expect(getHeight(expansionTileKey), 158.0);
});
testWidgets('Material3 - ExpansionTile draws Inkwell splash on top of background color', (
WidgetTester tester,
) async {
const expansionTileKey = Key('expansionTileKey');
const ShapeBorder shape = RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
);
const ShapeBorder collapsedShape = RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
);
const collapsedBackgroundColor = Color(0xff00ff00);
const backgroundColor = Color(0xffff0000);
await tester.pumpWidget(
const MaterialApp(
home: Material(
child: Center(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 24.0),
child: ExpansionTile(
key: expansionTileKey,
shape: shape,
collapsedBackgroundColor: collapsedBackgroundColor,
backgroundColor: backgroundColor,
collapsedShape: collapsedShape,
title: TestText('title'),
trailing: TestIcon(),
children: <Widget>[SizedBox(height: 100, width: 100)],
),
),
),
),
),
);
// Tap and hold the ExpansionTile to trigger ink splash.
final Offset center = tester.getCenter(find.byKey(expansionTileKey));
final TestGesture gesture = await tester.startGesture(center);
await tester.pump(); // Start the splash animation.
await tester.pump(const Duration(milliseconds: 100)); // Splash is underway.
// Material 3 uses the InkSparkle which uses a shader, so we can't capture
// the effect with paint methods. Use a golden test instead.
// Check if the ink sparkle is drawn on top of the background color.
await expectLater(
find.byKey(expansionTileKey),
matchesGoldenFile('expansion_tile.ink_splash.drawn_on_top_of_background_color.png'),
);
// Finish gesture to release resources.
await gesture.up();
await tester.pumpAndSettle();
});
testWidgets('Default clipBehavior when a shape is provided', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: ExpansionTile(
title: Text('Title'),
subtitle: Text('Subtitle'),
shape: StadiumBorder(),
children: <Widget>[ListTile(title: Text('0'))],
),
),
),
);
expect(getMaterial(tester).clipBehavior, Clip.antiAlias);
});
testWidgets('Can override clipBehavior when a shape is provided', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: ExpansionTile(
title: Text('Title'),
subtitle: Text('Subtitle'),
shape: StadiumBorder(),
clipBehavior: Clip.none,
children: <Widget>[ListTile(title: Text('0'))],
),
),
),
);
expect(getMaterial(tester).clipBehavior, Clip.none);
});
group('Material 2', () {
// These tests are only relevant for Material 2. Once Material 2
// support is deprecated and the APIs are removed, these tests
// can be deleted.
testWidgets('ExpansionTile default iconColor, textColor', (WidgetTester tester) async {
final theme = ThemeData(useMaterial3: false);
await tester.pumpWidget(
MaterialApp(
theme: theme,
home: const Material(
child: ExpansionTile(
title: TestText('title'),
trailing: TestIcon(),
children: <Widget>[SizedBox(height: 100, width: 100)],
),
),
),
);
Color getIconColor() => tester.state<TestIconState>(find.byType(TestIcon)).iconTheme.color!;
Color getTextColor() => tester.state<TestTextState>(find.byType(TestText)).textStyle.color!;
expect(getIconColor(), theme.unselectedWidgetColor);
expect(getTextColor(), theme.textTheme.titleMedium!.color);
await tester.tap(find.text('title'));
await tester.pumpAndSettle();
expect(getIconColor(), theme.colorScheme.primary);
expect(getTextColor(), theme.colorScheme.primary);
});
testWidgets('Material2 - ExpansionTile draws inkwell splash on top of background color', (
WidgetTester tester,
) async {
const expansionTileKey = Key('expansionTileKey');
final theme = ThemeData(useMaterial3: false);
const ShapeBorder shape = RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
);
const ShapeBorder collapsedShape = RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
);
const collapsedBackgroundColor = Color(0xff00ff00);
const backgroundColor = Color(0xffff0000);
await tester.pumpWidget(
MaterialApp(
theme: theme,
home: const Material(
child: Center(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 24.0),
child: ExpansionTile(
key: expansionTileKey,
shape: shape,
collapsedBackgroundColor: collapsedBackgroundColor,
backgroundColor: backgroundColor,
collapsedShape: collapsedShape,
title: TestText('title'),
trailing: TestIcon(),
children: <Widget>[SizedBox(height: 100, width: 100)],
),
),
),
),
),
);
// Tap and hold the ExpansionTile to trigger ink splash.
final Offset center = tester.getCenter(find.byKey(expansionTileKey));
final TestGesture gesture = await tester.startGesture(center);
await tester.pump(); // Start the splash animation.
await tester.pump(const Duration(milliseconds: 100)); // Splash is underway.
final RenderObject inkFeatures = tester.allRenderObjects.firstWhere(
(RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures',
);
// Check if the ink splash is drawn on top of the background color.
expect(
inkFeatures,
paints
..path(color: collapsedBackgroundColor)
..circle(color: theme.splashColor),
);
// Finish gesture to release resources.
await gesture.up();
await tester.pumpAndSettle();
});
});
testWidgets('ExpansionTileController isExpanded, expand() and collapse()', (
WidgetTester tester,
) async {
final controller = ExpansionTileController();
await tester.pumpWidget(
MaterialApp(
home: Material(
child: ExpansionTile(
controller: controller,
title: const Text('Title'),
children: const <Widget>[Text('Child 0')],
),
),
),
);
expect(find.text('Child 0'), findsNothing);
expect(controller.isExpanded, isFalse);
controller.expand();
expect(controller.isExpanded, isTrue);
await tester.pumpAndSettle();
expect(find.text('Child 0'), findsOneWidget);
expect(controller.isExpanded, isTrue);
controller.collapse();
expect(controller.isExpanded, isFalse);
await tester.pumpAndSettle();
expect(find.text('Child 0'), findsNothing);
controller.dispose();
});
testWidgets(
'Calling ExpansionTileController.expand/collapsed has no effect if it is already expanded/collapsed',
(WidgetTester tester) async {
final controller = ExpansionTileController();
await tester.pumpWidget(
MaterialApp(
home: Material(
child: ExpansionTile(
controller: controller,
title: const Text('Title'),
initiallyExpanded: true,
children: const <Widget>[Text('Child 0')],
),
),
),
);
expect(find.text('Child 0'), findsOneWidget);
expect(controller.isExpanded, isTrue);
controller.expand();
expect(controller.isExpanded, isTrue);
await tester.pump();
expect(tester.hasRunningAnimations, isFalse);
expect(find.text('Child 0'), findsOneWidget);
controller.collapse();
expect(controller.isExpanded, isFalse);
await tester.pump();
expect(tester.hasRunningAnimations, isTrue);
await tester.pumpAndSettle();
expect(controller.isExpanded, isFalse);
expect(find.text('Child 0'), findsNothing);
controller.collapse();
expect(controller.isExpanded, isFalse);
await tester.pump();
expect(tester.hasRunningAnimations, isFalse);
controller.dispose();
},
);
testWidgets('Call to ExpansionTileController.of()', (WidgetTester tester) async {
final GlobalKey titleKey = GlobalKey();
final GlobalKey childKey = GlobalKey();
await tester.pumpWidget(
MaterialApp(
home: Material(
child: ExpansionTile(
initiallyExpanded: true,
title: Text('Title', key: titleKey),
children: <Widget>[Text('Child 0', key: childKey)],
),
),
),
);
final ExpansionTileController controller1 = ExpansionTileController.of(
childKey.currentContext!,
);
expect(controller1.isExpanded, isTrue);
final ExpansionTileController controller2 = ExpansionTileController.of(
titleKey.currentContext!,
);
expect(controller2.isExpanded, isTrue);
expect(controller1, controller2);
});
testWidgets('Call to ExpansionTile.maybeOf()', (WidgetTester tester) async {
final GlobalKey titleKey = GlobalKey();
final GlobalKey nonDescendantKey = GlobalKey();
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Column(
children: <Widget>[
ExpansionTile(
title: Text('Title', key: titleKey),
children: const <Widget>[Text('Child 0')],
),
Text('Non descendant', key: nonDescendantKey),
],
),
),
),
);
final ExpansionTileController? controller1 = ExpansionTileController.maybeOf(
titleKey.currentContext!,
);
expect(controller1, isNotNull);
expect(controller1?.isExpanded, isFalse);
final ExpansionTileController? controller2 = ExpansionTileController.maybeOf(
nonDescendantKey.currentContext!,
);
expect(controller2, isNull);
});
testWidgets('Check if dense, splashColor, enableFeedback, visualDensity parameter is working', (
WidgetTester tester,
) async {
final GlobalKey titleKey = GlobalKey();
final GlobalKey nonDescendantKey = GlobalKey();
const dense = true;
const Color splashColor = Colors.blue;
const enableFeedback = false;
const VisualDensity visualDensity = VisualDensity.compact;
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Column(
children: <Widget>[
ExpansionTile(
dense: dense,
splashColor: splashColor,
enableFeedback: enableFeedback,
visualDensity: visualDensity,
title: Text('Title', key: titleKey),
children: const <Widget>[Text('Child 0')],
),
Text('Non descendant', key: nonDescendantKey),
],
),
),
),
);
final Finder tileFinder = find.byType(ListTile);
final ListTile tileWidget = tester.widget<ListTile>(tileFinder);
expect(tileWidget.dense, dense);
expect(tileWidget.splashColor, splashColor);
expect(tileWidget.enableFeedback, enableFeedback);
expect(tileWidget.visualDensity, visualDensity);
});
testWidgets('ExpansionTileController should not toggle if disabled', (WidgetTester tester) async {
final controller = ExpansionTileController();
await tester.pumpWidget(
MaterialApp(
home: Material(
child: ExpansionTile(
enabled: false,
controller: controller,
title: const Text('Title'),
children: const <Widget>[Text('Child 0')],
),
),
),
);
expect(find.text('Child 0'), findsNothing);
expect(controller.isExpanded, isFalse);
await tester.tap(find.widgetWithText(ExpansionTile, 'Title'));
await tester.pumpAndSettle();
expect(find.text('Child 0'), findsNothing);
expect(controller.isExpanded, isFalse);
controller.expand();
await tester.pumpAndSettle();
expect(find.text('Child 0'), findsOneWidget);
expect(controller.isExpanded, isTrue);
await tester.tap(find.widgetWithText(ExpansionTile, 'Title'));
await tester.pumpAndSettle();
expect(find.text('Child 0'), findsOneWidget);
expect(controller.isExpanded, isTrue);
controller.dispose();
});
testWidgets(
'ExpansionTile does not include the default trailing icon when showTrailingIcon: false (#145268)',
(WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Material(
child: ExpansionTile(
enabled: false,
tilePadding: EdgeInsets.zero,
title: ColoredBox(color: Colors.red, child: Text('Title')),
showTrailingIcon: false,
),
),
),
);
final Size materialAppSize = tester.getSize(find.byType(MaterialApp));
final Size titleSize = tester.getSize(
find.descendant(of: find.byType(ExpansionTile), matching: find.byType(ColoredBox)),
);
expect(titleSize.width, materialAppSize.width);
},
);
testWidgets(
'ExpansionTile with smaller trailing widget allocates at least 32.0 units of space (preserves original behavior) (#145268)',
(WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Material(
child: ExpansionTile(
enabled: false,
tilePadding: EdgeInsets.zero,
title: ColoredBox(color: Colors.red, child: Text('Title')),
trailing: SizedBox.shrink(),
),
),
),
);
final Size materialAppSize = tester.getSize(find.byType(MaterialApp));
final Size titleSize = tester.getSize(
find.descendant(of: find.byType(ExpansionTile), matching: find.byType(ColoredBox)),
);
expect(titleSize.width, materialAppSize.width - 32.0);
},
);
testWidgets('ExpansionTile uses ListTileTheme controlAffinity', (WidgetTester tester) async {
Widget buildView(ListTileControlAffinity controlAffinity) {
return MaterialApp(
home: ListTileTheme(
data: ListTileThemeData(controlAffinity: controlAffinity),
child: const Material(child: ExpansionTile(title: Text('ExpansionTile'))),
),
);
}
await tester.pumpWidget(buildView(ListTileControlAffinity.leading));
final Finder leading = find.text('ExpansionTile');
final Offset offsetLeading = tester.getTopLeft(leading);
expect(offsetLeading, const Offset(56.0, 17.0));
await tester.pumpWidget(buildView(ListTileControlAffinity.trailing));
final Finder trailing = find.text('ExpansionTile');
final Offset offsetTrailing = tester.getTopLeft(trailing);
expect(offsetTrailing, const Offset(16.0, 17.0));
await tester.pumpWidget(buildView(ListTileControlAffinity.platform));
final Finder platform = find.text('ExpansionTile');
final Offset offsetPlatform = tester.getTopLeft(platform);
expect(offsetPlatform, const Offset(16.0, 17.0));
});
testWidgets('ExpansionTile can accept a new controller', (WidgetTester tester) async {
final controller1 = ExpansibleController();
final controller2 = ExpansibleController();
addTearDown(() {
controller1.dispose();
controller2.dispose();
});
await tester.pumpWidget(
MaterialApp(
home: Material(
child: ExpansionTile(
controller: controller1,
title: const Text('Title'),
initiallyExpanded: true,
children: const <Widget>[Text('Child 0')],
),
),
),
);
expect(find.text('Child 0'), findsOne);
expect(controller1.isExpanded, isTrue);
controller1.collapse();
expect(controller1.isExpanded, isFalse);
await tester.pumpAndSettle();
expect(find.text('Child 0'), findsNothing);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: ExpansionTile(
controller: controller2,
title: const Text('Title'),
initiallyExpanded: true,
children: const <Widget>[Text('Child 0')],
),
),
),
);
expect(find.text('Child 0'), findsNothing);
controller2.expand();
expect(controller2.isExpanded, isTrue);
await tester.pumpAndSettle();
expect(find.text('Child 0'), findsOne);
});
testWidgets('ExpansionTile can accept a new controller with a different state', (
WidgetTester tester,
) async {
final controller1 = ExpansibleController();
final controller2 = ExpansibleController();
addTearDown(() {
controller1.dispose();
controller2.dispose();
});
await tester.pumpWidget(
MaterialApp(
home: Material(
child: ExpansionTile(
controller: controller1,
title: const Text('Title'),
children: const <Widget>[Text('Child 0')],
),
),
),
);
expect(find.text('Child 0'), findsNothing);
expect(controller1.isExpanded, isFalse);
controller1.expand();
expect(controller1.isExpanded, isTrue);
await tester.pumpAndSettle();
expect(find.text('Child 0'), findsOne);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: ExpansionTile(
controller: controller2,
title: const Text('Title'),
children: const <Widget>[Text('Child 0')],
),
),
),
);
await tester.pumpAndSettle();
expect(
find.text('Child 0'),
findsNothing,
reason: 'The widget should update to the state of the new controller',
);
controller2.expand();
expect(controller2.isExpanded, isTrue);
await tester.pumpAndSettle();
expect(find.text('Child 0'), findsOne);
});
// Regression test for https://github.com/flutter/flutter/issues/176566
testWidgets(
'ExpansionTile semantics hint uses defaultTargetPlatform for VoiceOver regardless of theme platform',
(WidgetTester tester) async {
// Regression test for VoiceOver accessibility when theme platform differs from device platform.
// When someone sets theme.platform to TargetPlatform.android on an iOS device,
// VoiceOver should still work correctly by using the actual device platform for semantics hints.
final SemanticsHandle handle = tester.ensureSemantics();
const localizations = DefaultMaterialLocalizations();
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(platform: TargetPlatform.android),
home: const Material(
child: Column(
children: <Widget>[
ExpansionTile(title: Text('First Expansion Tile')),
ExpansionTile(initiallyExpanded: true, title: Text('Second Expansion Tile')),
],
),
),
),
);
SemanticsNode semantics = tester.getSemantics(
find.ancestor(of: find.byType(ListTile).first, matching: find.byType(Semantics)).first,
);
expect(semantics, isNotNull);
// On iOS/macOS platform, the semantics hint should include expanded/collapsed state guidance
// even theme platform is set to Android.
expect(
semantics.hint,
'${localizations.expandedHint}\n ${localizations.expansionTileCollapsedHint}',
);
semantics = tester.getSemantics(
find.ancestor(of: find.byType(ListTile).last, matching: find.byType(Semantics)).first,
);
expect(semantics, isNotNull);
expect(
semantics.hint,
'${localizations.collapsedHint}\n ${localizations.expansionTileExpandedHint}',
);
handle.dispose();
},
variant: const TargetPlatformVariant(<TargetPlatform>{
TargetPlatform.iOS,
TargetPlatform.macOS,
}),
);
// Regression test for https://github.com/flutter/flutter/issues/173060
group('Semantics tests for non-iOS/macOS/android platforms', () {
testWidgets(
'Semantics hint should show current state',
(WidgetTester tester) async {
final SemanticsHandle handle = tester.ensureSemantics();
const localizations = DefaultMaterialLocalizations();
await tester.pumpWidget(
const MaterialApp(
home: Material(
child: Column(
children: <Widget>[
ExpansionTile(title: Text('First Expansion Tile')),
ExpansionTile(initiallyExpanded: true, title: Text('Second Expansion Tile')),
],
),
),
),
);
// Test collapsed tile - should show "Collapsed" hint.
SemanticsNode semantics = tester.getSemantics(
find.ancestor(of: find.byType(ListTile).first, matching: find.byType(Semantics)).first,
);
expect(semantics, isNotNull);
expect(semantics.hint, localizations.expandedHint);
// Test expanded tile - should show "Expanded" hint.
semantics = tester.getSemantics(
find.ancestor(of: find.byType(ListTile).last, matching: find.byType(Semantics)).first,
);
expect(semantics, isNotNull);
expect(semantics.hint, localizations.collapsedHint);
handle.dispose();
},
variant: const TargetPlatformVariant(<TargetPlatform>{
TargetPlatform.android,
TargetPlatform.fuchsia,
TargetPlatform.linux,
TargetPlatform.windows,
}),
);
testWidgets(
'Semantics hint updates when expansion state changes',
(WidgetTester tester) async {
final SemanticsHandle handle = tester.ensureSemantics();
const localizations = DefaultMaterialLocalizations();
await tester.pumpWidget(
const MaterialApp(
home: Material(
child: ExpansionTile(title: Text('Test Tile'), children: <Widget>[Text('Child')]),
),
),
);
// Initially collapsed - should show "Collapsed".
SemanticsNode semantics = tester.getSemantics(
find.ancestor(of: find.byType(ListTile), matching: find.byType(Semantics)).first,
);
expect(semantics.hint, localizations.expandedHint);
// Tap to expand.
await tester.tap(find.text('Test Tile'));
await tester.pumpAndSettle();
// Now expanded - should show "Expanded".
semantics = tester.getSemantics(
find.ancestor(of: find.byType(ListTile), matching: find.byType(Semantics)).first,
);
expect(semantics.hint, localizations.collapsedHint);
// Tap to collapse.
await tester.tap(find.text('Test Tile'));
await tester.pumpAndSettle();
// Back to collapsed - should show "Collapsed" again.
semantics = tester.getSemantics(
find.ancestor(of: find.byType(ListTile), matching: find.byType(Semantics)).first,
);
expect(semantics.hint, localizations.expandedHint);
handle.dispose();
},
variant: const TargetPlatformVariant(<TargetPlatform>{
TargetPlatform.android,
TargetPlatform.fuchsia,
TargetPlatform.linux,
TargetPlatform.windows,
}),
);
});
group('Semantics tests for android platform', () {
testWidgets(
'Semantics liveregion updates when expansion state changes',
(WidgetTester tester) async {
final SemanticsHandle handle = tester.ensureSemantics();
const localizations = DefaultMaterialLocalizations();
await tester.pumpWidget(
const MaterialApp(
home: Material(
child: ExpansionTile(title: Text('Test Tile'), children: <Widget>[Text('Child')]),
),
),
);
// Initially collapsed - live region label is "Collapsed".
SemanticsNode liveRegionSemantics = tester.getSemantics(
find.ancestor(
of: find.byType(ListTile),
matching: find.byWidgetPredicate(
(Widget widget) => widget is Semantics && (widget.properties.liveRegion ?? false),
),
),
);
expect(liveRegionSemantics.label, localizations.expandedHint);
// Tap to expand.
await tester.tap(find.text('Test Tile'));
await tester.pumpAndSettle();
// Now expanded - should show "Expanded".
liveRegionSemantics = tester.getSemantics(
find.ancestor(
of: find.byType(ListTile),
matching: find.byWidgetPredicate(
(Widget widget) => widget is Semantics && (widget.properties.liveRegion ?? false),
),
),
);
expect(liveRegionSemantics.label, localizations.collapsedHint);
// Tap to collapse.
await tester.tap(find.text('Test Tile'));
await tester.pumpAndSettle();
// Back to collapsed - should show "Collapsed" again.
liveRegionSemantics = tester.getSemantics(
find.ancestor(
of: find.byType(ListTile),
matching: find.byWidgetPredicate(
(Widget widget) => widget is Semantics && (widget.properties.liveRegion ?? false),
),
),
);
expect(liveRegionSemantics.label, localizations.expandedHint);
handle.dispose();
},
variant: const TargetPlatformVariant(<TargetPlatform>{TargetPlatform.android}),
);
});
}