// 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(['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 { 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 { 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( 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('expanded'); const Key collapsedKey = PageStorageKey('collapsed'); const Key defaultKey = PageStorageKey('default'); await tester.pumpWidget( MaterialApp( theme: ThemeData(dividerColor: dividerColor), home: Material( child: SingleChildScrollView( child: Column( children: [ ListTile(title: const Text('Top'), key: topKey), ExpansionTile( key: expandedKey, initiallyExpanded: true, title: const Text('Expanded'), backgroundColor: Colors.red, children: [ListTile(key: tileKey, title: const Text('0'))], ), ExpansionTile( key: collapsedKey, title: const Text('Collapsed'), children: [ListTile(key: tileKey, title: const Text('0'))], ), const ExpansionTile( key: defaultKey, title: Text('Default'), children: [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.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: [ const ListTile(title: Text('Top')), ExpansionTile( initiallyExpanded: true, title: TestText('Expanded', key: expandedTitleKey), backgroundColor: Colors.red, trailing: TestIcon(key: expandedIconKey), children: const [ListTile(title: Text('0'))], ), ExpansionTile( title: TestText('Collapsed', key: collapsedTitleKey), trailing: TestIcon(key: collapsedIconKey), children: const [ListTile(title: Text('0'))], ), ], ), ), ), ), ); Color iconColor(Key key) => tester.state(find.byKey(key)).iconTheme.color!; Color textColor(Key key) => tester.state(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.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: [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: [ ExpansionTile( title: Text('Tile 1'), maintainState: true, children: [Text('Maintaining State')], ), ExpansionTile(title: Text('Title 2'), children: [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: [ 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: [ 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().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: [ 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: [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: [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: [SizedBox(height: 100, width: 100)], ), ), ), ); var shapeDecoration = tester .firstWidget( 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( 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: [SizedBox(height: 100, width: 100)], ), ), ), ); Color getIconColor() => tester.state(find.byType(TestIcon)).iconTheme.color!; Color getTextColor() => tester.state(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: [SizedBox(height: 100, width: 100)], ), ), ), ); Color getIconColor() => tester.state(find.byType(TestIcon)).iconTheme.color!; Color getTextColor() => tester.state(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('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 [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: [ 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: [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: [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: [ 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: [ 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.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: [ ExpansionTile( key: expansionTileKey, collapsedShape: collapsedShape, collapsedTextColor: collapsedTextColor, collapsedBackgroundColor: collapsedBackgroundColor, collapsedIconColor: collapsedIconColor, title: const TestText('title'), trailing: const TestIcon(), children: const [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(find.byType(TestIcon)).iconTheme.color, const Color(0xffffffff), ); expect( tester.state(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(find.byType(TestIcon)).iconTheme.color, const Color(0xff000000), ); expect( tester.state(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: [ ExpansionTile( key: expansionTileKey, shape: shape, textColor: textColor, backgroundColor: backgroundColor, iconColor: iconColor, title: const TestText('title'), trailing: const TestIcon(), children: const [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(find.byType(TestIcon)).iconTheme.color, const Color(0xff00ffff), ); expect( tester.state(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(find.byType(TestIcon)).iconTheme.color!; textColor = tester.state(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(find.byType(TestIcon)).iconTheme.color, const Color(0xffffffff), ); expect( tester.state(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 [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: [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: [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: [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: [SizedBox(height: 100, width: 100)], ), ), ), ); Color getIconColor() => tester.state(find.byType(TestIcon)).iconTheme.color!; Color getTextColor() => tester.state(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: [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 [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 [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: [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: [ ExpansionTile( title: Text('Title', key: titleKey), children: const [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: [ ExpansionTile( dense: dense, splashColor: splashColor, enableFeedback: enableFeedback, visualDensity: visualDensity, title: Text('Title', key: titleKey), children: const [Text('Child 0')], ), Text('Non descendant', key: nonDescendantKey), ], ), ), ), ); final Finder tileFinder = find.byType(ListTile); final ListTile tileWidget = tester.widget(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 [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 [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 [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 [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 [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: [ 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.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: [ 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.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: [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.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: [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.android}), ); }); }