Flutter Accordion 完全指南:从折叠面板入门到高级样式自定义

在移动端应用中,Accordion(手风琴/折叠面板) 是一种非常常见的 UI 交互形式。它允许用户点击一个标题区域,然后展开或收起对应内容,适合用来展示 FAQ、设置项、商品详情、分类菜单、表单分组等内容。

Flutter 中并没有一个直接叫做 Accordion 的内置 widget,但可以通过以下组件实现 Accordion 效果:

  1. ExpansionTile:最常用、最简单的折叠列表项。
  2. ExpansionPanelList:更接近 Material Design 的面板式折叠组件。
  3. 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. 自定义内边距

如果想让标题区域和内容区域更有呼吸感,可以设置 tilePaddingchildrenPadding

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 的圆角,而不设置 ExpansionTileshapecollapsedShape,展开和收起时可能会出现边框、圆角或分割线不一致的问题。


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,比如 ColumnRowImageFormTextFieldElevatedButton 等。

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),
),

你也可以结合以下组件做更复杂的动画:

  • AnimatedContainer
  • AnimatedSwitcher
  • AnimatedOpacity
  • AnimatedCrossFade
  • RotationTransition

如果只是实现普通 Accordion,AnimatedRotation 已经足够实用。


十五、ExpansionTile vs ExpansionPanelList 怎么选?

场景 推荐组件
简单 FAQ ExpansionTile
设置页折叠分组 ExpansionTile
列表中的折叠项 ExpansionTile
多个面板可同时展开 ExpansionPanelList
同一时间只展开一个 ExpansionPanelList.radio
需要更接近 Material 面板样式 ExpansionPanelList
需要快速封装复用组件 ExpansionTile

简单来说:

  • 如果只是想要一个"点一下展开内容"的组件,优先使用 ExpansionTile
  • 如果需要严格的手风琴行为,也就是打开一个自动关闭其他项,使用 ExpansionPanelList.radio
  • 如果需要完全手动控制每个面板的展开状态,使用 ExpansionPanelList

十六、最佳实践总结

在 Flutter 项目中使用 Accordion 时,可以参考以下建议:

  1. 简单场景优先使用 ExpansionTile

    它代码少、维护简单,非常适合 FAQ 和设置页。

  2. 需要单项展开时使用 ExpansionPanelList.radio

    不需要手动维护复杂状态,Flutter 会自动处理展开逻辑。

  3. 统一封装组件

    建议封装 AppAccordion,统一处理圆角、颜色、间距、图标动画和主题适配。

  4. 长列表中使用 PageStorageKey

    避免滚动后展开状态丢失。

  5. 嵌套滚动组件时注意布局约束

    内层 ListView 通常需要设置 shrinkWrap: trueNeverScrollableScrollPhysics()

  6. 不要过度自定义

    如果只是普通折叠内容,尽量使用 Flutter 原生能力,避免引入不必要的复杂度。


结语

Flutter 中实现 Accordion 并不复杂,核心思路是根据需求选择合适的折叠组件。

ExpansionTile 更适合快速实现简单、列表式的折叠内容。它使用成本低,非常适合 FAQ、设置页、分类列表等场景。

ExpansionPanelList 更适合复杂的面板式布局。如果你需要多个面板同时展开,可以手动维护状态;如果你希望同一时间只展开一个面板,可以直接使用 ExpansionPanelList.radio

在真实项目中,更推荐基于 ExpansionTile 封装一个自己的 AppAccordion 组件,把圆角、颜色、间距、动画、主题适配等统一处理。这样不仅代码更简洁,也更方便维护统一的设计规范。

相关推荐
jiejiejiejie_1 小时前
Flutter for OpenHarmony 渐变色UI设计实战:LinearGradient与RadialGradient深度应用
flutter·ui
xmdy58663 小时前
Flutter+开源鸿蒙实战|城市共享驿站智能存取系统 Day1 项目初始化+架构分层+多端适配+全局状态基座
flutter·开源·harmonyos
Zh-jie4 小时前
Windows GitBash 下 FVM 配置流程
flutter
jiejiejiejie_4 小时前
Flutter for OpenHarmony 喝水提醒功能实现指南
flutter
liulian09164 小时前
Flutter for OpenHarmony 跨平台开发:喝水提醒功能实战指南
flutter