在移动端应用中,Accordion(手风琴/折叠面板) 是一种非常常见的 UI 交互形式。它允许用户点击一个标题区域,然后展开或收起对应内容,适合用来展示 FAQ、设置项、商品详情、分类菜单、表单分组等内容。
Flutter 中并没有一个直接叫做 Accordion 的内置 widget,但可以通过以下组件实现 Accordion 效果:
ExpansionTile:最常用、最简单的折叠列表项。ExpansionPanelList:更接近 Material Design 的面板式折叠组件。ExpansionPanelList.radio:经典 Accordion 行为,同一时间只允许展开一个面板。
一、什么是 Accordion?
Accordion 通常指一种可以展开和收起内容的界面组件。它的基本结构通常包括:
- 标题区域:用户点击的地方。
- 内容区域:展开后显示的具体内容。
- 状态图标:例如向下箭头、加号、减号等。
常见使用场景包括:
- FAQ 常见问题页面
- 设置页分组
- 商品详情页
- 课程目录
- 分类筛选菜单
- 表单分组
- 帮助中心
在 Flutter 中,我们最常使用 ExpansionTile 来实现基础 Accordion。
二、使用 ExpansionTile 实现基础 Accordion
最简单的 Accordion 可以直接使用 ExpansionTile。
dart
import 'package:flutter/material.dart';
class BasicAccordionPage extends StatelessWidget {
const BasicAccordionPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Basic Accordion'),
),
body: ListView(
children: const [
ExpansionTile(
title: Text('什么是 Flutter?'),
children: [
Padding(
padding: EdgeInsets.all(16.0),
child: Text(
'Flutter 是 Google 推出的跨平台 UI 框架,可以使用一套代码构建 Android、iOS、Web、桌面端应用。',
),
),
],
),
ExpansionTile(
title: Text('什么是 Accordion?'),
children: [
Padding(
padding: EdgeInsets.all(16.0),
child: Text(
'Accordion 是一种折叠面板 UI,用户点击标题后可以展开或收起内容。',
),
),
],
),
],
),
);
}
}
在这个例子中:
title是折叠项的标题。children是展开后显示的内容。ListView用来承载多个可折叠项。
这是 Flutter 中实现 Accordion 最简单、最直接的方式。
三、ExpansionTile 常用属性详解
ExpansionTile 的基本结构如下:
dart
ExpansionTile(
title: Text('标题'),
subtitle: Text('副标题'),
leading: Icon(Icons.info),
trailing: Icon(Icons.keyboard_arrow_down),
children: [
Text('展开后的内容'),
],
)
常用属性如下:
| 属性 | 作用 |
|---|---|
title |
标题区域,通常是 Text |
subtitle |
副标题 |
leading |
左侧图标 |
trailing |
右侧图标,默认是展开箭头 |
children |
展开后显示的组件列表 |
initiallyExpanded |
是否默认展开 |
onExpansionChanged |
展开/收起状态变化回调 |
backgroundColor |
展开时的背景色 |
collapsedBackgroundColor |
收起时的背景色 |
iconColor |
展开时图标颜色 |
collapsedIconColor |
收起时图标颜色 |
textColor |
展开时标题文字颜色 |
collapsedTextColor |
收起时标题文字颜色 |
childrenPadding |
子内容区域内边距 |
tilePadding |
标题区域内边距 |
expandedCrossAxisAlignment |
子组件横轴对齐方式 |
四、默认展开某一项
可以通过 initiallyExpanded 让某个 Accordion 默认展开。
dart
ExpansionTile(
initiallyExpanded: true,
title: const Text('默认展开的标题'),
children: const [
Padding(
padding: EdgeInsets.all(16),
child: Text('这段内容在页面加载时默认显示。'),
),
],
)
这个属性适合用于:
- FAQ 页面中的重点问题
- 当前正在编辑的设置项
- 需要优先展示的内容区域
- 新手引导或帮助内容
五、监听展开和收起状态
如果你想在展开或收起时执行某些逻辑,可以使用 onExpansionChanged。
dart
ExpansionTile(
title: const Text('点击监听展开状态'),
onExpansionChanged: (bool expanded) {
debugPrint('当前是否展开:$expanded');
},
children: const [
Padding(
padding: EdgeInsets.all(16),
child: Text('这里是展开后的内容'),
),
],
)
常见使用场景包括:
- 展开时加载远程数据
- 记录用户点击行为
- 根据展开状态切换图标
- 控制其他组件状态
- 做埋点统计
六、自定义 Accordion 样式
默认的 ExpansionTile 样式比较基础,实际项目中通常需要根据设计稿进行自定义。
1. 自定义背景色、文字颜色和图标颜色
dart
ExpansionTile(
title: const Text('自定义颜色'),
subtitle: const Text('展开和收起时颜色不同'),
backgroundColor: Colors.blue.shade50,
collapsedBackgroundColor: Colors.grey.shade100,
textColor: Colors.blue,
collapsedTextColor: Colors.black87,
iconColor: Colors.blue,
collapsedIconColor: Colors.grey,
children: const [
Padding(
padding: EdgeInsets.all(16),
child: Text('这里是自定义颜色后的内容区域。'),
),
],
)
需要注意的是:
backgroundColor是展开状态的背景色。collapsedBackgroundColor是收起状态的背景色。textColor是展开状态的标题颜色。collapsedTextColor是收起状态的标题颜色。iconColor是展开状态的图标颜色。collapsedIconColor是收起状态的图标颜色。
2. 自定义内边距
如果想让标题区域和内容区域更有呼吸感,可以设置 tilePadding 和 childrenPadding。
dart
ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
childrenPadding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
title: const Text('自定义间距'),
children: const [
Text(
'通过 tilePadding 可以控制标题区域的内边距,通过 childrenPadding 可以控制展开内容区域的内边距。',
),
],
)
在真实项目中,合理的内边距可以明显提升组件质感。
3. 使用 Card 包裹,做成卡片式 Accordion
卡片式 Accordion 是实际项目中非常常见的一种 UI 风格。
dart
Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 2,
child: ExpansionTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
collapsedShape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: const Text(
'卡片式 Accordion',
style: TextStyle(
fontWeight: FontWeight.bold,
),
),
childrenPadding: const EdgeInsets.all(16),
children: const [
Text(
'通过 Card + ExpansionTile 可以实现更现代的折叠卡片效果。',
),
],
),
)
这里的关键点是同时设置:
dart
shape: RoundedRectangleBorder(...)
collapsedShape: RoundedRectangleBorder(...)
如果只设置 Card 的圆角,而不设置 ExpansionTile 的 shape 和 collapsedShape,展开和收起时可能会出现边框、圆角或分割线不一致的问题。
4. 自定义展开箭头位置和图标
默认情况下,ExpansionTile 的箭头在右侧。如果想自定义图标,可以使用 trailing。
dart
ExpansionTile(
title: const Text('自定义右侧图标'),
trailing: const Icon(Icons.add_circle_outline),
children: const [
Padding(
padding: EdgeInsets.all(16),
child: Text('这里使用了自定义 trailing 图标。'),
),
],
)
不过这样写有一个问题:图标不会随着展开/收起自动变化。
如果你想实现展开时显示减号,收起时显示加号,可以自己维护状态。
dart
class CustomIconAccordion extends StatefulWidget {
const CustomIconAccordion({super.key});
@override
State<CustomIconAccordion> createState() => _CustomIconAccordionState();
}
class _CustomIconAccordionState extends State<CustomIconAccordion> {
bool _isExpanded = false;
@override
Widget build(BuildContext context) {
return ExpansionTile(
title: const Text('动态切换图标'),
trailing: Icon(
_isExpanded ? Icons.remove_circle_outline : Icons.add_circle_outline,
),
onExpansionChanged: (expanded) {
setState(() {
_isExpanded = expanded;
});
},
children: const [
Padding(
padding: EdgeInsets.all(16),
child: Text('展开后图标变成减号,收起后图标变成加号。'),
),
],
);
}
}
七、实现"同一时间只展开一个"的 Accordion
很多人理解的 Accordion 是:打开一个面板时,其他面板自动关闭。
这种情况下推荐使用 ExpansionPanelList.radio。
dart
import 'package:flutter/material.dart';
class RadioAccordionPage extends StatelessWidget {
const RadioAccordionPage({super.key});
@override
Widget build(BuildContext context) {
final items = [
{
'title': '账户设置',
'body': '这里可以展示账号、密码、安全设置等内容。',
},
{
'title': '通知设置',
'body': '这里可以展示邮件通知、推送通知、短信通知等内容。',
},
{
'title': '隐私设置',
'body': '这里可以展示权限、数据管理、隐私选项等内容。',
},
];
return Scaffold(
appBar: AppBar(
title: const Text('Radio Accordion'),
),
body: SingleChildScrollView(
child: ExpansionPanelList.radio(
initialOpenPanelValue: 0,
expansionCallback: (int index, bool isExpanded) {
debugPrint('点击了第 $index 项,当前展开状态:$isExpanded');
},
children: items.asMap().entries.map((entry) {
final index = entry.key;
final item = entry.value;
return ExpansionPanelRadio(
value: index,
headerBuilder: (context, isExpanded) {
return ListTile(
title: Text(
item['title']!,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
);
},
body: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Align(
alignment: Alignment.centerLeft,
child: Text(item['body']!),
),
),
);
}).toList(),
),
),
);
}
}
ExpansionPanelList.radio 适合用于这些场景:
- FAQ 页面只允许一个答案展开
- 设置页只允许一个分组展开
- 课程章节列表
- 商品详情中的多个信息模块
八、使用 ExpansionPanelList 手动控制展开状态
如果不想使用 radio 模式,而是希望多个面板可以同时展开,可以使用普通的 ExpansionPanelList。
dart
class MultiAccordionPage extends StatefulWidget {
const MultiAccordionPage({super.key});
@override
State<MultiAccordionPage> createState() => _MultiAccordionPageState();
}
class _MultiAccordionPageState extends State<MultiAccordionPage> {
final List<AccordionItem> _items = [
AccordionItem(
title: 'Flutter 基础',
body: 'Widget、State、BuildContext、MaterialApp、Scaffold 等基础概念。',
),
AccordionItem(
title: 'Flutter 布局',
body: 'Row、Column、Stack、Expanded、Flexible、Wrap 等布局组件。',
),
AccordionItem(
title: 'Flutter 动画',
body: 'AnimatedContainer、AnimationController、Tween、自定义动画等。',
),
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Multi Accordion')),
body: SingleChildScrollView(
child: ExpansionPanelList(
expansionCallback: (int index, bool isExpanded) {
setState(() {
_items[index].isExpanded = isExpanded;
});
},
children: _items.map<ExpansionPanel>((item) {
return ExpansionPanel(
isExpanded: item.isExpanded,
headerBuilder: (context, isExpanded) {
return ListTile(
title: Text(item.title),
);
},
body: Padding(
padding: const EdgeInsets.all(16),
child: Align(
alignment: Alignment.centerLeft,
child: Text(item.body),
),
),
);
}).toList(),
),
),
);
}
}
class AccordionItem {
AccordionItem({
required this.title,
required this.body,
this.isExpanded = false,
});
final String title;
final String body;
bool isExpanded;
}
这种方式适合需要更细粒度控制的场景,比如:
- 多个面板可以同时展开
- 展开状态需要和业务数据绑定
- 展开状态需要保存到本地或服务端
- 某些面板需要根据权限控制是否可展开
九、完整示例:封装一个可复用的 AppAccordion 组件
在真实项目中,推荐不要到处直接写 ExpansionTile,而是封装一个统一的 Accordion 组件。
dart
import 'package:flutter/material.dart';
class AppAccordion extends StatefulWidget {
const AppAccordion({
super.key,
required this.title,
required this.child,
this.subtitle,
this.leading,
this.initiallyExpanded = false,
this.margin = const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
});
final String title;
final String? subtitle;
final Widget? leading;
final Widget child;
final bool initiallyExpanded;
final EdgeInsets margin;
@override
State<AppAccordion> createState() => _AppAccordionState();
}
class _AppAccordionState extends State<AppAccordion> {
late bool _expanded;
@override
void initState() {
super.initState();
_expanded = widget.initiallyExpanded;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
margin: widget.margin,
elevation: _expanded ? 3 : 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: ExpansionTile(
initiallyExpanded: widget.initiallyExpanded,
leading: widget.leading,
tilePadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
collapsedShape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
backgroundColor: theme.colorScheme.surface,
collapsedBackgroundColor: theme.colorScheme.surface,
iconColor: theme.colorScheme.primary,
collapsedIconColor: theme.colorScheme.onSurfaceVariant,
textColor: theme.colorScheme.primary,
collapsedTextColor: theme.colorScheme.onSurface,
title: Text(
widget.title,
style: const TextStyle(
fontWeight: FontWeight.w600,
),
),
subtitle: widget.subtitle == null ? null : Text(widget.subtitle!),
trailing: AnimatedRotation(
turns: _expanded ? 0.5 : 0,
duration: const Duration(milliseconds: 200),
child: const Icon(Icons.keyboard_arrow_down),
),
onExpansionChanged: (expanded) {
setState(() {
_expanded = expanded;
});
},
children: [
Align(
alignment: Alignment.centerLeft,
child: widget.child,
),
],
),
);
}
}
使用方式如下:
dart
class AccordionDemoPage extends StatelessWidget {
const AccordionDemoPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Custom Accordion Demo'),
),
body: ListView(
children: const [
AppAccordion(
title: '如何使用 Flutter Accordion?',
subtitle: '基于 ExpansionTile 封装',
leading: Icon(Icons.widgets_outlined),
child: Text(
'在 Flutter 中可以使用 ExpansionTile 或 ExpansionPanelList 实现 Accordion 效果。',
),
),
AppAccordion(
title: '如何自定义样式?',
subtitle: '支持圆角、颜色、间距、图标动画',
leading: Icon(Icons.palette_outlined),
child: Text(
'可以通过 Card、shape、tilePadding、childrenPadding、iconColor、textColor 等属性来自定义样式。',
),
),
AppAccordion(
title: '什么时候用 ExpansionPanelList.radio?',
subtitle: '适合只允许展开一个面板的场景',
leading: Icon(Icons.radio_button_checked),
child: Text(
'如果你希望打开一个面板时自动关闭其他面板,可以使用 ExpansionPanelList.radio。',
),
),
],
),
);
}
}
这个封装组件具备以下能力:
- 支持标题、副标题、左侧图标。
- 支持卡片圆角。
- 支持展开/收起时图标旋转动画。
- 支持主题色自动适配。
- 支持默认展开。
- 代码结构清晰,适合在真实项目中复用。
十、FAQ 页面实战示例
Accordion 最常见的应用场景之一就是 FAQ。
dart
class FaqPage extends StatelessWidget {
const FaqPage({super.key});
@override
Widget build(BuildContext context) {
final faqs = [
{
'question': 'Flutter Accordion 是内置组件吗?',
'answer': 'Flutter 没有直接叫 Accordion 的组件,但可以使用 ExpansionTile 或 ExpansionPanelList 实现。',
},
{
'question': 'ExpansionTile 和 ExpansionPanelList 有什么区别?',
'answer': 'ExpansionTile 更简单,适合列表项折叠;ExpansionPanelList 更适合复杂的面板式布局。',
},
{
'question': '如何只允许展开一个面板?',
'answer': '可以使用 ExpansionPanelList.radio,它会自动管理展开状态。',
},
];
return Scaffold(
appBar: AppBar(title: const Text('FAQ')),
body: ListView.builder(
itemCount: faqs.length,
itemBuilder: (context, index) {
final faq = faqs[index];
return AppAccordion(
title: faq['question']!,
child: Text(faq['answer']!),
);
},
),
);
}
}
这样就可以快速得到一个样式统一、结构清晰的 FAQ 页面。
十一、在 ListView 中保持展开状态
如果 ExpansionTile 放在可滚动列表中,滚动出屏幕后可能会被销毁并重建。为了让展开状态在滚动后依然保持,可以给每个 ExpansionTile 设置唯一的 PageStorageKey。
dart
ListView.builder(
itemCount: 20,
itemBuilder: (context, index) {
return ExpansionTile(
key: PageStorageKey('accordion_item_$index'),
title: Text('问题 ${index + 1}'),
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text('这是问题 ${index + 1} 的答案。'),
),
],
);
},
)
这个细节在长列表中特别重要,否则用户展开某一项后滚动回来,可能会发现状态已经被重置。
十二、Accordion 里面嵌套复杂内容
Accordion 的内容区域可以放任何 Flutter widget,比如 Column、Row、Image、Form、TextField、ElevatedButton 等。
dart
ExpansionTile(
title: const Text('用户信息'),
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
TextField(
decoration: const InputDecoration(labelText: '用户名'),
),
const SizedBox(height: 12),
ElevatedButton(
onPressed: () {},
child: const Text('保存'),
),
],
),
),
],
)
不过,如果你在 Accordion 里面嵌套 ListView,通常需要设置:
dart
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
完整示例:
dart
ExpansionTile(
title: const Text('嵌套列表'),
children: [
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: 3,
itemBuilder: (context, index) {
return ListTile(
title: Text('子项 ${index + 1}'),
);
},
),
],
)
这是因为外层已经有滚动容器时,内层 ListView 如果不限制高度,容易出现布局约束问题。
十三、如何去掉默认分割线?
如果你不想显示 ExpansionTile 默认的分割线,可以使用 Theme 包裹并设置 dividerColor。
dart
Theme(
data: Theme.of(context).copyWith(
dividerColor: Colors.transparent,
),
child: ExpansionTile(
title: const Text('无分割线 Accordion'),
children: const [
Padding(
padding: EdgeInsets.all(16),
child: Text('这里没有默认分割线。'),
),
],
),
)
这种方式适合用于卡片式 Accordion,让 UI 看起来更干净。
十四、让展开动画更自然
ExpansionTile 本身已经内置了展开/收起动画。如果需要更明显的视觉反馈,可以自定义 trailing,结合 AnimatedRotation 实现箭头旋转。
dart
trailing: AnimatedRotation(
turns: _expanded ? 0.5 : 0,
duration: const Duration(milliseconds: 250),
child: const Icon(Icons.keyboard_arrow_down),
),
你也可以结合以下组件做更复杂的动画:
AnimatedContainerAnimatedSwitcherAnimatedOpacityAnimatedCrossFadeRotationTransition
如果只是实现普通 Accordion,AnimatedRotation 已经足够实用。
十五、ExpansionTile vs ExpansionPanelList 怎么选?
| 场景 | 推荐组件 |
|---|---|
| 简单 FAQ | ExpansionTile |
| 设置页折叠分组 | ExpansionTile |
| 列表中的折叠项 | ExpansionTile |
| 多个面板可同时展开 | ExpansionPanelList |
| 同一时间只展开一个 | ExpansionPanelList.radio |
| 需要更接近 Material 面板样式 | ExpansionPanelList |
| 需要快速封装复用组件 | ExpansionTile |
简单来说:
- 如果只是想要一个"点一下展开内容"的组件,优先使用
ExpansionTile。 - 如果需要严格的手风琴行为,也就是打开一个自动关闭其他项,使用
ExpansionPanelList.radio。 - 如果需要完全手动控制每个面板的展开状态,使用
ExpansionPanelList。
十六、最佳实践总结
在 Flutter 项目中使用 Accordion 时,可以参考以下建议:
-
简单场景优先使用
ExpansionTile它代码少、维护简单,非常适合 FAQ 和设置页。
-
需要单项展开时使用
ExpansionPanelList.radio不需要手动维护复杂状态,Flutter 会自动处理展开逻辑。
-
统一封装组件
建议封装
AppAccordion,统一处理圆角、颜色、间距、图标动画和主题适配。 -
长列表中使用
PageStorageKey避免滚动后展开状态丢失。
-
嵌套滚动组件时注意布局约束
内层
ListView通常需要设置shrinkWrap: true和NeverScrollableScrollPhysics()。 -
不要过度自定义
如果只是普通折叠内容,尽量使用 Flutter 原生能力,避免引入不必要的复杂度。
结语
Flutter 中实现 Accordion 并不复杂,核心思路是根据需求选择合适的折叠组件。
ExpansionTile 更适合快速实现简单、列表式的折叠内容。它使用成本低,非常适合 FAQ、设置页、分类列表等场景。
ExpansionPanelList 更适合复杂的面板式布局。如果你需要多个面板同时展开,可以手动维护状态;如果你希望同一时间只展开一个面板,可以直接使用 ExpansionPanelList.radio。
在真实项目中,更推荐基于 ExpansionTile 封装一个自己的 AppAccordion 组件,把圆角、颜色、间距、动画、主题适配等统一处理。这样不仅代码更简洁,也更方便维护统一的设计规范。