expandable_panel 0.1.0
expandable_panel: ^0.1.0 copied to clipboard
Expandable and collapsible panels for Flutter with accordion groups, accessibility, keyboard and RTL support, reduced motion and state restoration.
import 'package:expandable_panel/expandable_panel.dart';
import 'package:flutter/material.dart';
void main() => runApp(const DemoApp());
/// Demo application for `expandable_panel`.
class DemoApp extends StatefulWidget {
const DemoApp({super.key});
@override
State<DemoApp> createState() => _DemoAppState();
}
class _DemoAppState extends State<DemoApp> {
bool _rtl = false;
bool _reduceMotion = false;
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'expandable_panel',
restorationScopeId: 'app',
theme: ThemeData(colorSchemeSeed: Colors.teal),
darkTheme: ThemeData(
colorSchemeSeed: Colors.teal,
brightness: Brightness.dark,
),
builder: (context, child) => MediaQuery(
data: MediaQuery.of(context).copyWith(disableAnimations: _reduceMotion),
child: Directionality(
textDirection: _rtl ? TextDirection.rtl : TextDirection.ltr,
child: child!,
),
),
home: DefaultTabController(
length: 3,
child: Scaffold(
appBar: AppBar(
title: const Text('expandable_panel'),
actions: [
IconButton(
tooltip: 'Toggle right-to-left',
icon: const Icon(Icons.format_textdirection_r_to_l),
isSelected: _rtl,
onPressed: () => setState(() => _rtl = !_rtl),
),
IconButton(
tooltip: 'Toggle reduced motion',
icon: const Icon(Icons.motion_photos_off_outlined),
isSelected: _reduceMotion,
onPressed: () => setState(() => _reduceMotion = !_reduceMotion),
),
],
bottom: const TabBar(tabs: [
Tab(text: 'Cards'),
Tab(text: 'Accordion'),
Tab(text: 'Custom'),
]),
),
body: const TabBarView(children: [
CardsPage(),
AccordionPage(),
CustomPage(),
]),
),
),
);
}
}
const _lorem = 'Expandable panels show a short summary and reveal the full '
'text on demand. The header is a single accessible button: screen readers '
'announce whether it is expanded, and it responds to Enter and Space. '
'While the panel is open, its content can grow or shrink and the panel '
'follows immediately. ';
/// Article cards that scroll into view when expanded.
class CardsPage extends StatelessWidget {
const CardsPage({super.key});
@override
Widget build(BuildContext context) {
return ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: 12,
itemBuilder: (context, i) => ArticleCard(key: ValueKey(i), index: i),
);
}
}
/// One card: header, a two-line summary, and the full text when expanded.
class ArticleCard extends StatelessWidget {
const ArticleCard({super.key, required this.index});
final int index;
@override
Widget build(BuildContext context) {
final text = _lorem * (index % 3 + 2);
final textTheme = Theme.of(context).textTheme;
return ExpandableNotifier(
restorationId: 'card-$index',
child: ScrollOnExpand(
child: Card(
clipBehavior: Clip.antiAlias,
child: ExpandablePanel(
theme: const ExpandableThemeData(tapBodyToCollapse: true),
header: Padding(
padding: const EdgeInsets.all(16),
child: Text('Article ${index + 1}', style: textTheme.titleMedium),
),
collapsed: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Text(
text,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
expanded: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Text(text),
),
),
),
),
);
}
}
/// Only one question open at a time.
class AccordionPage extends StatelessWidget {
const AccordionPage({super.key});
static const _faq = {
'Can several panels be open?':
'Set maxExpanded on ExpandableGroup, or null for no limit.',
'Does the expanded content keep its state?':
'Set maintainState: true on the panel. Otherwise it is rebuilt.',
'Is the expanded content built up front?':
'No. It is built on first expansion, or use expandedBuilder.',
'Does it respect reduced motion?':
'Yes. With MediaQuery.disableAnimations the panel snaps.',
};
@override
Widget build(BuildContext context) {
return ExpandableGroup(
child: ListView(
padding: const EdgeInsets.all(8),
children: [
for (final entry in _faq.entries)
Card(
clipBehavior: Clip.antiAlias,
child: ExpandablePanel(
header: ListTile(title: Text(entry.key)),
expanded: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Text(entry.value),
),
),
),
],
),
);
}
}
/// A custom layout built from Expandable and ExpandableButton, driven by an
/// external controller.
class CustomPage extends StatefulWidget {
const CustomPage({super.key});
@override
State<CustomPage> createState() => _CustomPageState();
}
class _CustomPageState extends State<CustomPage> {
final _controller = ExpandableController();
int _items = 3;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
ValueListenableBuilder<bool>(
valueListenable: _controller,
builder: (context, expanded, _) => SwitchListTile(
title: const Text('Expanded (controller)'),
value: expanded,
onChanged: (value) => _controller.expanded = value,
),
),
ExpandableNotifier(
controller: _controller,
child: Card(
clipBehavior: Clip.antiAlias,
child: Expandable(
maintainState: true,
collapsed: ExpandableButton(
child: ListTile(
leading: const Icon(Icons.photo_library_outlined),
title: Text('$_items photos'),
trailing: const ExpandableIcon(),
),
),
expanded: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < _items; i++)
ListTile(
leading: const Icon(Icons.photo_outlined),
title: Text('Photo ${i + 1}'),
),
OverflowBar(
alignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => setState(() => _items++),
child: const Text('Add photo'),
),
const ExpandableButton(
child: Padding(
padding: EdgeInsets.all(12),
child: Text('Close'),
),
),
],
),
],
),
),
),
),
],
);
}
}