expandable_panel
Expandable and collapsible panels for Flutter, with accordion groups,
screen-reader and keyboard support, RTL layouts, reduced motion and state
restoration. The API mirrors the expandable package closely, so most apps can
migrate by changing one import.
Features
- Familiar API. It has the same widgets as
expandable:ExpandablePanel,Expandable,ExpandableNotifier,ExpandableController,ExpandableButton,ExpandableIcon,ScrollOnExpand,ExpandableThemeandExpandableThemeData, with the same theme fields. - Accordion.
ExpandableGroupkeeps at most one panel open, or at mostmaxExpandedpanels, or leaves the count unlimited. Its state object offerscollapseAll(). - Accessibility. The header is one button in the semantics tree. It reports an expanded or collapsed state and gives the localized hint "Expand" or "Collapse". Hidden content is left out of the semantics tree and of focus traversal.
- Keyboard. The header can take focus and shows a focus highlight. Enter, numpad Enter and Space toggle it.
- RTL. The icon is placed at
startorendby default, so it mirrors in right-to-left locales. Useleftorrightfor a fixed physical side. - Reduced motion. When
MediaQuery.disableAnimationsis set, panels, icons and scrolls snap without animating. - Initial state and restoration. Use
initialExpanded, or setrestorationIdto save and restore the state withRestorationMixin. - Lazy content. The expanded child is not built until the panel first
expands.
expandedBuilderdefers even the construction of the widget. - Keep state. With
maintainState: true, the expanded content keeps its state while collapsed. While collapsed it is offstage, its tickers are muted, and it cannot receive focus. - Material 3 defaults. The icon color comes from
ColorScheme.onSurfaceVariant, so it follows dark mode. The motion usesDurations.medium2andEasing.standard.
Fixed issues from expandable
Each fix is covered by a widget test. Rows marked R were also reproduced
by running the same test against expandable 5.0.1, where it fails. The
other rows are fixes for causes visible in the 5.0.1 source.
| Problem | Upstream issue | What changed |
|---|---|---|
R ScrollOnExpand scrolls to the wrong place when animationDuration differs from scrollAnimationDuration |
#51 | The scroll waits until every Expandable below it has finished animating, instead of starting on a timer. |
R scrollOnExpand: false still scrolls |
#130 | The flag is honoured. |
| R Inside a scrollable nested in another scrollable, the expanded panel ends up clipped | no open issue | The scroll runs on the final layout and reveals the panel in every enclosing viewport. |
| R The icon stops rotating, or the panel stops following the controller, after a parent rebuilds with a new controller | #69, #95, #117 | Every widget rebinds when the controller in scope is replaced. |
| R Content that changes height while expanded animates again (jank), and nested panels lag behind their parent | related: #108, #110 | Once settled, the body takes its content's size directly. During the animation, the size is interpolated from both children's live sizes on every frame. |
| Nested panels open and close their parents | #68 | A panel nested in another panel's body has its own state. |
| The icon stays dark in a dark theme | #122 | The icon color follows the color scheme. |
ExpandableThemeData(iconPadding: EdgeInsets.zero) is treated as empty |
#66 | Equality compares every field. |
A crossFadePoint outside 0 to 1 fails with a cryptic curve assertion |
#86 | The value is checked in the constructor. |
Install
dependencies:
expandable_panel: ^0.1.0
import 'package:expandable_panel/expandable_panel.dart';
Usage
A panel
class ArticleTile extends StatelessWidget {
const ArticleTile({super.key, required this.title, required this.body});
final String title;
final String body;
@override
Widget build(BuildContext context) {
return ExpandablePanel(
header: Text(title),
collapsed: Text(body, maxLines: 2, overflow: TextOverflow.ellipsis),
expanded: Text(body),
);
}
}
Cards in a list that scroll into view
Widget articleList(List<String> bodies) {
return ListView.builder(
itemCount: bodies.length,
itemBuilder: (context, i) => ExpandableNotifier(
key: ValueKey(i),
restorationId: 'article-$i',
child: ScrollOnExpand(
child: Card(
child: ExpandablePanel(
theme: const ExpandableThemeData(tapBodyToCollapse: true),
header: Text('Article ${i + 1}'),
collapsed: Text(bodies[i], maxLines: 2),
expanded: Text(bodies[i]),
),
),
),
),
);
}
Accordion
Widget faq(Map<String, String> answers) {
return ExpandableGroup(
maxExpanded: 1, // at most one open; use 2, 3 ... or null for no limit
child: Column(
children: [
for (final entry in answers.entries)
ExpandablePanel(
header: ListTile(title: Text(entry.key)),
expanded: Text(entry.value),
),
],
),
);
}
Programmatic control
final controller = ExpandableController(initialExpanded: true);
Widget details() {
return ExpandablePanel(
controller: controller,
header: const Text('Details'),
expanded: const Text('More details'),
);
}
void examples() {
controller.toggle();
controller.expanded = false;
controller.addListener(() => debugPrint('expanded: ${controller.value}'));
}
Custom layouts
Widget photos() {
return ExpandableNotifier(
child: Expandable(
collapsed: const ExpandableButton(child: Text('Show all photos')),
expanded: Column(
children: [
for (var i = 1; i <= 6; i++) Text('Photo $i'),
const ExpandableButton(child: Text('Back')),
],
),
),
);
}
Lazy and kept-alive content
Widget comments() {
return ExpandablePanel(
header: const Text('Comments'),
// Built only when the panel opens.
expandedBuilder: (context) => ListView(
shrinkWrap: true,
children: const [TextField()],
),
// Keep the text field's content while collapsed.
maintainState: true,
);
}
The example app in example/ shows all of these.
Migrating from expandable
-
Replace the dependency and the import:
dependencies: expandable_panel: ^0.1.0 # was: expandable: ^5.0.1import 'package:expandable_panel/expandable_panel.dart'; // was: import 'package:expandable/expandable.dart'; -
Class names, constructor parameters and theme fields are unchanged:
ExpandablePanel(header, collapsed, expanded, controller, builder, theme),Expandable(collapsed, expanded, controller, theme),ExpandableNotifier(controller, initialExpanded, child),ExpandableController(initialExpanded)withexpanded,value,toggle()andof(context, rebuildOnChange:, required:),ExpandableButton,ExpandableIcon,ScrollOnExpand(scrollOnExpand, scrollOnCollapse, theme),ExpandableThemeandExpandableThemeDatawithcombine,of,withDefaults,defaultsandempty. -
Check these behaviour changes:
Area expandable5.0.1expandable_panelIcon color Colors.black54ColorScheme.onSurfaceVariant. SeticonColorto keep the old color.Header alignment topcenter. SetheaderAlignment: ExpandablePanelHeaderAlignment.topfor the old layout.Icon placement rightend, which is still the right side in LTR. Userightto keep it on the right in RTL too.Body and child alignment left/Alignment.topLeftstart/AlignmentDirectional.topStartSize curve Curves.fastOutSlowIn, 300 msEasing.standard, 300 ms (Durations.medium2)iconPaddingtypeEdgeInsetsEdgeInsetsGeometry. Passing anEdgeInsetsstill works.collapsed/expandedboth required collapseddefaults to an empty box. Pass eitherexpandedorexpandedBuilder.Expanded child built immediately, kept in the tree built on first expansion and removed after collapsing. Use maintainState: trueif it must keep state or aGlobalKey.Nested ExpandablePanelwithout a controllershared the outer panel's controller gets its own state. Pass the same controllerif you want them linked.Missing controller debug assertion; release builds show the expanded child a FlutterErrornamingExpandableNotifierExpandableThemeData.isFull()required iconColorignores iconColor, which is resolved from the color schemecollapsedFadeStartand similar getterspublic removed -
The new options are opt-in:
ExpandableGroup,initialExpandedandrestorationIdonExpandablePanel,maintainState,expandedBuilder,ExpandableController.expand()/collapse(),ExpandableThemeData.copyWith/merge, and thestart/endplacement values.
Limitations
- Panels expand vertically only. Horizontal expansion (#80, #98) is not supported.
crossFadePointapplies symmetrically: when collapsing, the timeline runs backwards. Separate expand and collapse points (#23) are not supported.- There is no
onExpansionChangedcallback. Listen to theExpandableController, which is aValueListenable<bool>. ScrollOnExpandwaits forExpandablewidgets below it. Any other animation you run inside is not waited for. The widget keeps its list item alive (AutomaticKeepAlive) until the scroll has run, which needs the list's defaultaddAutomaticKeepAlives: true.- An
ExpandableGroupcounts only the panels and notifiers directly inside it. Panels nested inside a member are independent. - Semantics, focus and keyboard behaviour are verified with widget tests in the semantics tree. They have not been tried with TalkBack or VoiceOver on a device.
ExpandableButtonwith the defaultuseInkWell: trueneeds aMaterialancestor, asInkWelldoes.
License
MIT. See LICENSE.
This is a clean-room implementation written against the public API and the
issue tracker of expandable (MIT,
Alexander Ryzhov). It does not copy that package's code.
Made by Abdeldjalil Chougui.
Libraries
- expandable_panel
- Expandable and collapsible panels with accordion groups, accessibility, keyboard and RTL support, reduced motion and state restoration.