From 18be17e369ea015d05eacca5d11a5fc90be44337 Mon Sep 17 00:00:00 2001 From: Benji Date: Thu, 10 Sep 2026 13:09:40 +1200 Subject: [PATCH] [material_ui] Add trailingIconVisibility to ExpansionPanel (#179167) Fixes https://github.com/flutter/flutter/issues/179167 --- .../expansion_panel_list.1.dart | 128 +++++++++++++++ .../expansion_panel_list.1_test.dart | 42 +++++ .../material_ui/lib/src/expansion_panel.dart | 70 ++++++-- ...ansion_panel_trailing_icon_visibility.yaml | 3 + .../test/expansion_panel_test.dart | 150 ++++++++++++++++++ 5 files changed, 381 insertions(+), 12 deletions(-) create mode 100644 packages/material_ui/example/lib/expansion_panel/expansion_panel_list.1.dart create mode 100644 packages/material_ui/example/test/expansion_panel/expansion_panel_list.1_test.dart create mode 100644 packages/material_ui/pending_changelogs/expansion_panel_trailing_icon_visibility.yaml diff --git a/packages/material_ui/example/lib/expansion_panel/expansion_panel_list.1.dart b/packages/material_ui/example/lib/expansion_panel/expansion_panel_list.1.dart new file mode 100644 index 000000000000..c52f56ccfa61 --- /dev/null +++ b/packages/material_ui/example/lib/expansion_panel/expansion_panel_list.1.dart @@ -0,0 +1,128 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// #region body +import 'package:material_ui/material_ui.dart'; + +/// Flutter code sample for [ExpansionPanel.trailingIconVisibility]. + +void main() => runApp(const ExpansionPanelIconVisibilityExampleApp()); + +class ExpansionPanelIconVisibilityExampleApp extends StatelessWidget { + const ExpansionPanelIconVisibilityExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('ExpansionPanel Icon Visibility')), + body: const ExpansionPanelIconVisibilityExample(), + ), + ); + } +} + +class ExpansionPanelIconVisibilityExample extends StatefulWidget { + const ExpansionPanelIconVisibilityExample({super.key}); + + @override + State createState() => + _ExpansionPanelIconVisibilityExampleState(); +} + +class _ExpansionPanelIconVisibilityExampleState + extends State { + ExpansionPanelIconVisibility _visibility = .visible; + bool _canTapOnHeader = false; + final List _isExpanded = [false, false, false]; + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Column( + children: [ + const SizedBox(height: 24), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + SegmentedButton( + segments: const >[ + ButtonSegment( + value: .visible, + label: Text('Visible'), + ), + ButtonSegment( + value: .hidden, + label: Text('Hidden'), + ), + ButtonSegment( + value: .gone, + label: Text('Gone'), + ), + ], + selected: {_visibility}, + onSelectionChanged: + (Set selected) { + setState(() { + _visibility = selected.first; + }); + }, + ), + const SizedBox(height: 12), + SwitchListTile( + title: const Text('canTapOnHeader'), + value: _canTapOnHeader, + onChanged: (bool value) { + setState(() { + _canTapOnHeader = value; + }); + }, + ), + ], + ), + ), + const SizedBox(height: 24), + ExpansionPanelList( + expansionCallback: (int index, bool isExpanded) { + setState(() { + _isExpanded[index] = isExpanded; + }); + }, + children: [ + for (int i = 0; i < 3; i++) + ExpansionPanel( + trailingIconVisibility: _visibility, + canTapOnHeader: _canTapOnHeader, + headerBuilder: (BuildContext context, bool isExpanded) { + return ListTile( + title: Text('Panel ${i + 1}'), + trailing: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + shape: BoxShape.circle, + ), + ), + ); + }, + body: ListTile( + title: Text('Panel ${i + 1} content'), + subtitle: const Text( + 'When the icon visibility is hidden, the space is preserved. ' + 'When gone, the space is removed.', + ), + ), + isExpanded: _isExpanded[i], + ), + ], + ), + const SizedBox(height: 24), + ], + ), + ); + } +} +// #endregion body diff --git a/packages/material_ui/example/test/expansion_panel/expansion_panel_list.1_test.dart b/packages/material_ui/example/test/expansion_panel/expansion_panel_list.1_test.dart new file mode 100644 index 000000000000..6f5e931bfd6b --- /dev/null +++ b/packages/material_ui/example/test/expansion_panel/expansion_panel_list.1_test.dart @@ -0,0 +1,42 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:material_ui_examples/expansion_panel/expansion_panel_list.1.dart' + as example; + +void main() { + testWidgets('ExpansionPanel icon visibility can be toggled', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const example.ExpansionPanelIconVisibilityExampleApp(), + ); + + expect(find.byType(ExpandIcon), findsNWidgets(3)); + + final Finder visibilityFinder = find + .ancestor( + of: find.byType(ExpandIcon).first, + matching: find.byType(Visibility), + ) + .first; + + Visibility visibility = tester.widget(visibilityFinder); + expect(visibility.visible, isTrue); + + await tester.tap(find.text('Hidden')); + await tester.pumpAndSettle(); + + visibility = tester.widget(visibilityFinder); + expect(visibility.visible, isFalse); + expect(visibility.maintainSize, isTrue); + + await tester.tap(find.text('Gone')); + await tester.pumpAndSettle(); + + expect(find.byType(ExpandIcon), findsNothing); + }); +} diff --git a/packages/material_ui/lib/src/expansion_panel.dart b/packages/material_ui/lib/src/expansion_panel.dart index 1f94e8627f89..d6dcdd75e08a 100644 --- a/packages/material_ui/lib/src/expansion_panel.dart +++ b/packages/material_ui/lib/src/expansion_panel.dart @@ -58,6 +58,20 @@ typedef ExpansionPanelCallback = void Function(int panelIndex, bool isExpanded); /// [ExpansionPanel] needs to rebuild. typedef ExpansionPanelHeaderBuilder = Widget Function(BuildContext context, bool isExpanded); +/// Controls the visibility of the trailing expand/collapse icon in an +/// [ExpansionPanel]. +enum ExpansionPanelIconVisibility { + /// The icon is visible and interactive. + visible, + + /// The icon is hidden, but its layout space is preserved to maintain + /// alignment with other panels. + hidden, + + /// The icon is hidden and its layout space is removed. + gone, +} + /// A material expansion panel. It has a header and a body and can be either /// expanded or collapsed. The body of the panel is only visible when it is /// expanded. @@ -69,6 +83,17 @@ typedef ExpansionPanelHeaderBuilder = Widget Function(BuildContext context, bool /// /// See [ExpansionPanelList] for a sample implementation. /// +/// +/// +/// This example demonstrates how to use [trailingIconVisibility] to control +/// the visibility of the expand/collapse icon. +/// +/// {@macro material_ui.dartpad_guide} +/// +/// {@example /example/lib/expansion_panel/expansion_panel_list.1.dart#body} +/// +/// +/// /// See also: /// /// * [ExpansionPanelList] @@ -84,6 +109,7 @@ class ExpansionPanel { this.backgroundColor, this.splashColor, this.highlightColor, + this.trailingIconVisibility = ExpansionPanelIconVisibility.visible, }); /// The widget builder that builds the expansion panels' header. @@ -130,6 +156,16 @@ class ExpansionPanel { /// /// Defaults to [ThemeData.cardColor]. final Color? backgroundColor; + + /// Controls the visibility of the trailing expand/collapse icon. + /// + /// When set to [ExpansionPanelIconVisibility.hidden], the icon is not + /// rendered but its layout space is preserved for alignment. When set to + /// [ExpansionPanelIconVisibility.gone], both the icon and its space are + /// removed. + /// + /// Defaults to [ExpansionPanelIconVisibility.visible]. + final ExpansionPanelIconVisibility trailingIconVisibility; } /// An expansion panel that allows for radio-like functionality. @@ -153,6 +189,7 @@ class ExpansionPanelRadio extends ExpansionPanel { super.backgroundColor, super.splashColor, super.highlightColor, + super.trailingIconVisibility, }); /// The value that uniquely identifies a radio panel so that the currently @@ -406,22 +443,31 @@ class _ExpansionPanelListState extends State { final ExpansionPanel child = widget.children[index]; final Widget headerWidget = child.headerBuilder(context, _isChildExpanded(index)); - Widget expandIconPadded = Padding( - padding: const EdgeInsetsDirectional.only(end: 8.0), - child: IgnorePointer( - ignoring: child.canTapOnHeader, - child: ExpandIcon( - color: widget.expandIconColor, - isExpanded: _isChildExpanded(index), - padding: _kExpandIconPadding, - splashColor: child.splashColor, - highlightColor: child.highlightColor, - onPressed: (bool isExpanded) => _handlePressed(isExpanded, index), + final iconVisible = child.trailingIconVisibility == .visible; + final iconMaintainSize = child.trailingIconVisibility == .hidden; + + Widget expandIconPadded = Visibility( + visible: iconVisible, + maintainSize: iconMaintainSize, + maintainAnimation: iconMaintainSize, + maintainState: iconMaintainSize, + child: Padding( + padding: const EdgeInsetsDirectional.only(end: 8.0), + child: IgnorePointer( + ignoring: child.canTapOnHeader, + child: ExpandIcon( + color: widget.expandIconColor, + isExpanded: _isChildExpanded(index), + padding: _kExpandIconPadding, + splashColor: child.splashColor, + highlightColor: child.highlightColor, + onPressed: (bool isExpanded) => _handlePressed(isExpanded, index), + ), ), ), ); - if (!child.canTapOnHeader) { + if (!child.canTapOnHeader && iconVisible) { final MaterialLocalizations localizations = MaterialLocalizations.of(context); expandIconPadded = Semantics( label: _isChildExpanded(index) diff --git a/packages/material_ui/pending_changelogs/expansion_panel_trailing_icon_visibility.yaml b/packages/material_ui/pending_changelogs/expansion_panel_trailing_icon_visibility.yaml new file mode 100644 index 000000000000..fd2fa0fc8448 --- /dev/null +++ b/packages/material_ui/pending_changelogs/expansion_panel_trailing_icon_visibility.yaml @@ -0,0 +1,3 @@ +changelog: | + - Adds `ExpansionPanel.trailingIconVisibility` to control expand/collapse icon visibility. +version: minor diff --git a/packages/material_ui/test/expansion_panel_test.dart b/packages/material_ui/test/expansion_panel_test.dart index b5b51553f12e..c056a7e45000 100644 --- a/packages/material_ui/test/expansion_panel_test.dart +++ b/packages/material_ui/test/expansion_panel_test.dart @@ -2087,4 +2087,154 @@ void main() { final IgnorePointer ignorePointerTrue = tester.widget(ignorePointerFinder); expect(ignorePointerTrue.ignoring, isTrue); }); + + testWidgets('ExpansionPanel hides icon but preserves space with trailingIconVisibility.hidden', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: SingleChildScrollView( + child: ExpansionPanelList( + children: [ + ExpansionPanel( + trailingIconVisibility: ExpansionPanelIconVisibility.hidden, + headerBuilder: (BuildContext context, bool isExpanded) { + return const ListTile(title: Text('Panel')); + }, + body: const ListTile(title: Text('Content')), + ), + ], + ), + ), + ), + ); + + final Finder visibilityFinder = find + .ancestor(of: find.byType(ExpandIcon), matching: find.byType(Visibility)) + .first; + + final Visibility visibility = tester.widget(visibilityFinder); + expect(visibility.visible, isFalse); + expect(visibility.maintainSize, isTrue); + expect(visibility.maintainAnimation, isTrue); + expect(visibility.maintainState, isTrue); + + expect(find.byType(ExpandIcon), findsOneWidget); + }); + + testWidgets('ExpansionPanel removes icon and space with trailingIconVisibility.gone', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: SingleChildScrollView( + child: ExpansionPanelList( + children: [ + ExpansionPanel( + trailingIconVisibility: ExpansionPanelIconVisibility.gone, + headerBuilder: (BuildContext context, bool isExpanded) { + return const ListTile(title: Text('Panel')); + }, + body: const ListTile(title: Text('Content')), + ), + ], + ), + ), + ), + ); + + expect(find.byType(ExpandIcon), findsNothing); + }); + + testWidgets('ExpansionPanel shows icon by default with trailingIconVisibility.visible', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: SingleChildScrollView( + child: ExpansionPanelList( + children: [ + ExpansionPanel( + headerBuilder: (BuildContext context, bool isExpanded) { + return const ListTile(title: Text('Panel')); + }, + body: const ListTile(title: Text('Content')), + ), + ], + ), + ), + ), + ); + + final Finder visibilityFinder = find + .ancestor(of: find.byType(ExpandIcon), matching: find.byType(Visibility)) + .first; + + final Visibility visibility = tester.widget(visibilityFinder); + expect(visibility.visible, isTrue); + }); + + testWidgets('ExpansionPanel icon has semantics label when visible and canTapOnHeader is false', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: SingleChildScrollView( + child: ExpansionPanelList( + children: [ + ExpansionPanel( + headerBuilder: (BuildContext context, bool isExpanded) { + return const ListTile(title: Text('Panel')); + }, + body: const ListTile(title: Text('Content')), + ), + ], + ), + ), + ), + ); + + final Finder semanticsFinder = find.ancestor( + of: find.byType(ExpandIcon), + matching: find.byType(Semantics), + ); + expect(semanticsFinder, findsWidgets); + + final Semantics semantics = tester + .widgetList(semanticsFinder) + .firstWhere((Semantics s) => s.properties.label != null && s.properties.label!.isNotEmpty); + expect(semantics.properties.label, isNotNull); + }); + + testWidgets('ExpansionPanel icon has no semantics label when hidden', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: SingleChildScrollView( + child: ExpansionPanelList( + children: [ + ExpansionPanel( + trailingIconVisibility: ExpansionPanelIconVisibility.hidden, + headerBuilder: (BuildContext context, bool isExpanded) { + return const ListTile(title: Text('Panel')); + }, + body: const ListTile(title: Text('Content')), + ), + ], + ), + ), + ), + ); + + final Finder semanticsFinder = find.ancestor( + of: find.byType(ExpandIcon), + matching: find.byType(Semantics), + ); + final Iterable semanticsList = tester.widgetList(semanticsFinder); + final bool hasExpandCollapseLabel = semanticsList.any( + (Semantics s) => s.properties.label != null && s.properties.label!.isNotEmpty, + ); + expect(hasExpandCollapseLabel, isFalse); + }); }