Flutter中的ExpansionPanel带实例的指南

983 阅读14分钟

每个移动操作系统都提供了一个内置的UI工具包,其中包含各种小部件,这些小部件通常不是很好定制。Flutter配备了一个灵活的部件系统,实现了Material Design规范,激励移动应用开发者创建未来主义的极简UI。

与特定平台的UI小部件不同,Flutter为每个通用需求提供了许多可定制的小部件选择,因此根据你独特的设计草图构建你的Flutter应用是很容易的。其中一个是ExpansionPanel widget,它可以帮助我们创建可扩展/可折叠的列表。

我们可以在一个ExpansionPanelList widget里面添加几个ExpansionPanel widget,在我们的Flutter应用中创建可扩展/可折叠的列表。这些小组件有一个扩展/折叠的图标按钮,供用户显示/隐藏额外的内容。Flutter开发者通常使用一个单独的细节屏幕来显示特定列表项目的大型内容段(即显示产品细节)。

ExpansionPanel 小组件帮助开发者为每个列表项目显示中小型内容段,而不需要屏幕导航。在UI/UX规范中,这个UI元素可以被称为Accordion、Expandable或Collapsible。

在本教程中,我将用实际例子解释如何使用和定制ExpansionPanel widget。此外,我们还将把它与提供类似功能的ExpansionTile widget进行比较。

FlutterExpansionPanel 教程

让我们创建一个新的Flutter项目,与ExpansionPanel widget一起工作。您也可以在您现有的Flutter项目中使用此示例代码。

如果您是Flutter的新手,请根据官方的Flutter安装指南安装Flutter开发工具。您可以在谷歌浏览器、物理移动设备或模拟器/仿真器上运行即将到来的例子。在本教程中,我将使用Chrome浏览器来预览示例应用程序。

首先,用以下命令创建一个新的Flutter应用程序:

flutter create expansionpanel_example
cd expansionpanel_example

输入flutter run 命令,以确保一切正常运行。

使用ExpansionPanelExpansionPanelList

让我们创建一个简单的指南页,用几个ExpansionPanel 小部件和一个ExpansionPanelList 小部件来创建Flutter应用程序。用户可以点击某个特定的步骤来展开它,看到更多细节。

在大多数情况下,我们通常通过后台Web服务用异步函数将数据加载到应用程序前端,但对于我们的教程,我们将从同步函数渲染硬编码数据,以快速开始使用ExpansionPanel

在你的main.dart 文件中添加以下代码:

import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);
  static const String _title = 'Flutter Tutorial';
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: _title,
      home: Scaffold(
        appBar: AppBar(title: const Text(_title)),
        body: const Steps(),
      ),
    );
  }
}

class Step {
  Step(
    this.title,
    this.body,
    [this.isExpanded = false]
  );
  String title;
  String body;
  bool isExpanded;
}

List<Step> getSteps() {
  return [
    Step('Step 0: Install Flutter', 'Install Flutter development tools according to the official documentation.'),
    Step('Step 1: Create a project', 'Open your terminal, run `flutter create <project_name>` to create a new project.'),
    Step('Step 2: Run the app', 'Change your terminal directory to the project directory, enter `flutter run`.'),
  ];
}

class Steps extends StatefulWidget {
  const Steps({Key? key}) : super(key: key);
  @override
  State<Steps> createState() => _StepsState();
}

class _StepsState extends State<Steps> {
  final List<Step> _steps = getSteps();
  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      child: Container(
        child: _renderSteps(),
      ),
    );
  }
  Widget _renderSteps() {
    return ExpansionPanelList(
      expansionCallback: (int index, bool isExpanded) {
        setState(() {
          _steps[index].isExpanded = !isExpanded;
        });
      },
      children: _steps.map<ExpansionPanel>((Step step) {
        return ExpansionPanel(
          headerBuilder: (BuildContext context, bool isExpanded) {
            return ListTile(
              title: Text(step.title),
            );
          },
          body: ListTile(
            title: Text(step.body),
          ),
          isExpanded: step.isExpanded,
        );
      }).toList(),
    );
  }
}

注意上述示例代码的以下事实:

  • Steps 小组件负责在屏幕上渲染整个可扩展列表
  • getSteps 同步函数将所有硬编码的步骤作为Item 类的实例返回,而_steps widget 状态变量将所有项目作为DartList
  • 我们使用ExpansionPanelList 类中的两个参数。
    • children 通过转换 列表来设置所有 实例_steps ExpansionPanel
    • expansionCallback 根据最近用户与扩展/折叠按钮的互动来更新 列表_steps
  • 我们使用了ListTile 类,而不是简单地使用Text ,以显示一个风格鲜明的材料列表

运行上述代码。你会看到创建Flutter项目的步骤,如以下预览所示:

The example of our Flutter ExpansionPanel widget

通过添加更多的步骤来测试应用程序,或者尝试用工厂构造函数生成一些动态数据。 List.generate工厂构造函数生成一些动态数据。

如果你需要从你的网络后端加载数据,你可以像往常一样用FutureBuilder 来包装ExpansionPanelList 小部件:

import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);
  static const String _title = 'Flutter Tutorial';
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: _title,
      home: Scaffold(
        appBar: AppBar(title: const Text(_title)),
        body: const Steps(),
      ),
    );
  }
}

class Step {
  Step(
    this.title,
    this.body,
    [this.isExpanded = false]
  );
  String title;
  String body;
  bool isExpanded;
}

Future<List<Step>> getSteps() async {
  var _items = [
    Step('Step 0: Install Flutter', 'Install Flutter development tools according to the official documentation.'),
    Step('Step 1: Create a project', 'Open your terminal, run `flutter create <project_name>` to create a new project.'),
    Step('Step 2: Run the app', 'Change your terminal directory to the project directory, enter `flutter run`.'),
  ];
  return Future<List<Step>>.delayed(const Duration(seconds: 2), () => _items);
}

class Steps extends StatelessWidget {
  const Steps({Key? key}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      child: Container(
        child: FutureBuilder<List<Step>>(
          future: getSteps(),
          builder: (BuildContext context, AsyncSnapshot<List<Step>> snapshot) {
            if(snapshot.hasData) {
              return StepList(steps: snapshot.data ?? []);
            }
            else {
              return Center(
                child: Padding(
                  padding: EdgeInsets.all(16),
                  child: CircularProgressIndicator(),
                ),
              );
            }
          }
        ),
      ),
    );
  }
}

class StepList extends StatefulWidget {
  final List<Step> steps;
  const StepList({Key? key, required this.steps}) : super(key: key);
  @override
  State<StepList> createState() => _StepListState(steps: steps);
}

class _StepListState extends State<StepList> {
  final List<Step> _steps;
  _StepListState({required List<Step> steps}) : _steps = steps;
  @override
  Widget build(BuildContext context) {
    return ExpansionPanelList(
      expansionCallback: (int index, bool isExpanded) {
        setState(() {
          _steps[index].isExpanded = !isExpanded;
        });
      },
      children: _steps.map<ExpansionPanel>((Step step) {
        return ExpansionPanel(
          headerBuilder: (BuildContext context, bool isExpanded) {
            return ListTile(
              title: Text(step.title),
            );
          },
          body: ListTile(
            title: Text(step.body),
          ),
          isExpanded: step.isExpanded,
        );
      }).toList(),
    );
  }
}

我们对之前的源代码做了三个更新,解释如下:

  1. 使得getSteps 函数异步化,并带有人工延迟,所以现在你甚至可以通过你喜欢的网络客户端库(即Dio)从网络服务中获取可扩展列表的数据。
  2. 通过创建第二个名为StepList 的小组件,将可扩展列表与FutureBuilder ,该小组件使用条件渲染,在人工网络延迟期间显示一个循环加载动画。
  3. 使得Steps widget成为无状态的,因为我们不在那里的状态中保留任何数据

运行上述代码--你将在两秒的延迟后看到可扩展的列表:

Loading expandable list data asynchronously with FutureBuilder

使用这两种方法中的任何一种,你可以为任何需要使用ExpansionPanel widget的情况提供解决方案。

现在,让我们研究一下ExpansionPanel 所提供的功能!在接下来的例子中,我们将更新同步版本,因为它的实现与异步版本相比是最小的。再次将第一个例子的源代码复制到你的main.dart 文件中,准备继续学习教程。

如何定制ExpansionPanel 小组件的用户界面

当你把ExpansionPanelListTile 一起使用时,你会得到一个用户友好的可扩展列表,正如我们在前面的例子中看到的那样。你可以根据你的个人喜好或应用程序主题来定制它。例如,你可以改变元素的背景颜色,如下所示:

return ExpansionPanel(
  //.....
  //...
  backgroundColor: const Color(0xffeeeeff),
);

你可以改变可扩展列表的分隔线颜色,如下代码片段所示:

return ExpansionPanelList(
  dividerColor: Colors.teal,
  //....
  //...

还可以为标题设置一个自定义的填充物。请看下面的例子:

return ExpansionPanelList(
  expandedHeaderPadding: EdgeInsets.all(6),
  //....
  //...

下面的_renderSteps 方法实现使用上述参数来应用几个UI定制:

Widget _renderSteps() {
    return ExpansionPanelList(
      dividerColor: Colors.teal,
      expandedHeaderPadding: EdgeInsets.all(0),
      expansionCallback: (int index, bool isExpanded) {
        setState(() {
          _steps[index].isExpanded = !isExpanded;
        });
      },
      children: _steps.map<ExpansionPanel>((Step step) {
        return ExpansionPanel(
          backgroundColor: const Color(0xffeeeeff),
          headerBuilder: (BuildContext context, bool isExpanded) {
            return ListTile(
              title: Text(step.title),
            );
          },
          body: ListTile(
            title: Text(step.body),
          ),
          isExpanded: step.isExpanded,
        );
      }).toList(),
    );
  }

现在,你会看到一个自定义的可扩展列表UI,如下面的预览图所示:

A customized version of our ExpansionPanel UI

调整ExpansionPanel'的动画和触摸反馈

Flutter widget系统可以让你改变ExpansionPanel'的动画的速度。例如,您可以通过延长动画持续时间来减慢它的动画,如下所示:

return ExpansionPanelList(
  animationDuration: const Duration(milliseconds: 1500),
  //....
  //...

ExpansionPanel widget只有在用户点击右侧图标按钮时才会打开/关闭内容部分,但如果您使用以下设置,用户可以通过点击整个标题部分做同样的动作:

return ExpansionPanel(
  canTapOnHeader: true,
  //...
  //..

如果你的应用程序用户通常使用小屏幕设备,这种配置是一个很好的用户体验改进--他们不需要直接点击小的展开/折叠图标按钮来激活展开/折叠动作。

基于小部件状态的自动展开ExpansionPanel

在前面的例子中,我们在Step 类中使用了isExpanded 类变量,但我们没有明确地从getSteps 函数中为它设置一个值。我们所得到的是扩展面板折叠,最初。

我们可以为ExpansionPanel 类的isExpanded 参数设置一个初始值来设置一个自动展开的项目。使用下面这个同步的getSteps 函数实现:

List<Step> getSteps() {
  return [
    Step('Step 0: Install Flutter',
        'Install Flutter development tools according to the official documentation.',
        true),
    Step('Step 1: Create a project', 'Open your terminal, run `flutter create <project_name>` to create a new project.'),
    Step('Step 2: Run the app', 'Change your terminal directory to the project directory, enter `flutter run`.'),
  ];
}

在这里,我们在第一个列表元素中为isExpanded 设置true 。在_renderSteps 方法中找到以下代码行:

isExpanded: step.isExpanded,

上面这一行将Step 实例中的isExpanded 传递给ExpansionPanel ,所以现在我们可以看到第一个面板最初被自动展开了:

Open a specific panel automatically in ExpansionPanelList

同样地,你甚至可以从你的网络后端控制最初打开的面板!

一次性展开和折叠所有项目

你有没有注意到,在一些应用程序中,我们可以通过一个按钮一次性展开/折叠所有可展开的部分?如果用户需要阅读所有隐藏的内容,而不需要点击每个扩展面板,那么这个功能就很有帮助。使用下面的build 方法实现,_StepsState

@override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      child: Column(
        children: <Widget>[
          Padding(
            padding: EdgeInsets.all(12),
            child: ElevatedButton(
              child: const Text('Expand all'),
              onPressed: () {
                setState(() {
                  for(int i = 0; i < _steps.length; i++) {
                    _steps[i].isExpanded = true;
                  }
                });
              },
            ),
          ),
          _renderSteps()
        ],
      ),
    );
  }

在这里,我们创建了一个按钮来一次性展开所有的面板。setState 方法的调用将isExpanded 设置为所有列表项实例的true ,所以一旦你点了这个按钮,所有的步骤都会被展开,如下所示:

Implementing an expand all button for ExpansionPanelList

类似地,你可以通过将isExpanded 参数设置为false ,来实现一个折叠所有面板的按钮:

_steps[i].isExpanded = false;

Implementing a collapse all button for ExpansionPanelList

创建单选扩展面板与ExpansionPanelRadio

默认的ExpansionPanelList widget的行为就像一组复选框,所以当我们点击一个面板时,那个特定的面板会被展开,我们必须再次点击它来折叠它。

但是,如果我们需要建立一个可扩展的列表,它的行为就像一组单选按钮?我们只能保持一个面板展开,就像复选框组一样。

作为一个解决方案,你可能会考虑编写一些自定义逻辑来更新_steps 列表,就像我们实现扩大/折叠所有功能一样,但是Flutter widget系统实际上为这个需求提供了内置的ExpansionPanelRadio

使用下面的代码来实现_renderSteps 功能:

Widget _renderSteps() {
    return ExpansionPanelList.radio(
      children: _steps.map<ExpansionPanelRadio>((Step step) {
        return ExpansionPanelRadio(
          headerBuilder: (BuildContext context, bool isExpanded) {
            return ListTile(
              title: Text(step.title),
            );
          },
          body: ListTile(
            title: Text(step.body),
          ),
          value: step.title
        );
      }).toList(),
    );
  }

在这里,我们使用了ExpansionPanelRadio widget与ExpansionPanelList.radio 构造函数。ExpansionPanelRadio widget并不像ExpansionPanel 那样接受isExpanded 的参数;相反,它接受一个唯一的值与value 的参数。setState 此外,我们不需要从expansionCallback ,因为Flutter框架提供了一个内置的实现,一旦用户打开另一个面板,就会自动折叠。

一旦你使用上述代码片段,你会看到以下结果:

ExpansionPanelRadio usage example

如果你需要最初打开一个特定的面板,你可以用你用value 参数添加的唯一标识符来做,如下图所示:

return ExpansionPanelList.radio(
  initialOpenPanelValue: 'Step 0: Install Flutter',
  //....
  //...

请注意,这里我们使用项目标题字符串作为唯一的value ,用于演示。对于生产应用程序,确保使用一个更好的唯一值,如产品标识符。

构建嵌套的扩展面板

在大多数应用程序中,为扩展面板使用一个层次就足够了,比如我们之前的例子中。但当您用Flutter开发复杂的应用程序(即桌面应用程序)时,有时您需要添加嵌套的扩展面板。

Flutter小组件系统非常灵活,可以让您创建嵌套式扩展面板。但是,我们如何定义一个模型来保存一个扩展面板的数据?

我们确实可以使用Step 类的递归定义,如下所示:

class Step {
  Step(
    this.title,
    this.body,
    [this.subSteps = const <Step>[]]
  );
  String title;
  String body;
  List<Step> subSteps;
}

现在,我们可以通过使用subSteps 列表来渲染一个嵌套的扩展面板组。下面的示例代码为我们的Flutter教程应用程序增加了另一个步骤,有两个子步骤:

import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);
  static const String _title = 'Flutter Tutorial';
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: _title,
      home: Scaffold(
        appBar: AppBar(title: const Text(_title)),
        body: const Steps(),
      ),
    );
  }
}

class Step {
  Step(
    this.title,
    this.body,
    [this.subSteps = const <Step>[]]
  );
  String title;
  String body;
  List<Step> subSteps;
}

List<Step> getSteps() {
  return [
    Step('Step 0: Install Flutter', 'Install Flutter development tools according to the official documentation.'),
    Step('Step 1: Create a project', 'Open your terminal, run `flutter create <project_name>` to create a new project.'),
    Step('Step 2: Run the app', 'Change your terminal directory to the project directory, enter `flutter run`.'),
    Step('Step 3: Build your app', 'Select a tutorial:', [
      Step('Developing a to-do app', 'Add a link to the tutorial video'),
      Step('Developing a 2-D game', 'Add a link to the tutorial video'),
    ]),
  ];
}

class Steps extends StatefulWidget {
  const Steps({Key? key}) : super(key: key);
  @override
  State<Steps> createState() => _StepsState();
}

class _StepsState extends State<Steps> {
  final List<Step> _steps = getSteps();
  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      child: Container(
          child: _renderSteps(_steps)
      ),
    );
  }

  Widget _renderSteps(List<Step> steps) {
    return ExpansionPanelList.radio(
      children: steps.map<ExpansionPanelRadio>((Step step) {
        return ExpansionPanelRadio(
          headerBuilder: (BuildContext context, bool isExpanded) {
            return ListTile(
              title: Text(step.title),
            );
          },
          body: ListTile(
            title: Text(step.body),
            subtitle: _renderSteps(step.subSteps)
          ),
          value: step.title
        );
      }).toList(),
    );
  }
}

在这里,我们用step.subSteps 递归地调用_renderSteps 方法来渲染嵌套的扩展面板。一旦你运行上述代码,你会看到最后一个步骤的子步骤,如下图所示:

Implementing nested expansion panels with a recursive method

上面的例子只渲染了两层嵌套的扩展面板,但递归方法支持更多,那么如何修改getSteps 方法源以显示三个扩展层呢?你可以通过传递你的开发待办事项应用程序步骤的子步骤,轻松地添加另一个扩展层。

ExpansionPanel 对。ExpansionTile

我们已经测试了ExpansionPanel 所提供的所有功能。接下来,让我们把它与一个类似的小工具进行比较,并讨论何时需要使用各自的功能。请看下面的表格,它将ExpansionPanelExpansionTile 进行了比较。

比较因素ExpansionPanelExpansionTile
推荐的父小部件ExpansionPanelList 只有ListView,Column,Drawer, 或任何可以容纳单个或多个小组件的容器型小组件
支持的添加内容/正文的方式通过body 参数接受一个单一的小部件(通常是一个ListTile )。通过children 参数接受多个小组件(通常是ListTiles)。
预先定义的样式没有为标题和内容提供预定义的样式--开发者必须使用一个ListTile widget,根据材料规范实现一个扩展列表。它还渲染了一个不可定制的箭头图标。通过让开发者设置标题和副标题,为标题提供预定义的样式,因为这个小部件是作为一个扩展的小工具。ListTile
支持的UI定制提供标题生成器功能,用于根据扩展状态进行动态渲染。无法定制箭头图标,但默认图标(ExpandIcon)遵守了材料规范。能够设置自定义扩展图标,改变图标位置,并添加领先/落后的小部件
用异步数据源进行渲染像往常一样,可以使用FutureBuilder 实例像往常一样,可以使用FutureBuilder 实例

根据上面的比较,我们可以理解,ExpansionPanel 更像是一个用户可以展开/折叠的内容部件,所以我们可以使用它,例如,显示关于某个产品的更多细节,而不需要导航到第二个屏幕。另外,你可以通过用ExpansionPanelRadio 小部件分组,并在同一时间显示一组小部件来简化复杂的应用屏幕。

另一方面,ExpansionTile 是一个适合创建子列表的小组件,因为你可以直接在children 参数中使用多个ListTiles。例如,你可以用ExpansionTile widget实现一个设置面板或子菜单。参见flutter_settings_screens 实现,以了解更多关于用ExpansionTile 实现设置面板的信息。

总结

在本教程中,我们通过根据各种要求修改一个实际的例子,学习了如何在Flutter中使用ExpansionPanel widget。你可以根据Material Design的规范,使用这个部件来创建可扩展的细节部分。

ExpansionPanel 满足了添加可扩展列表的通用UI要求,但正如我们在比较部分注意到的,与 相比,它有一些局限性。然而,它坚持了Material规范,所以通常我们不需要对 进行高级定制,因为它与 一起提供了一个伟大的、对开发者友好的可扩展列表设计。ExpansionTile ExpansionPanel ListTile

如果你在使用ExpansionPanelExpansionTile 时遇到限制,你可以查看flutter-expandable社区包。它以一种更灵活的方式提供了ExpansionPanelExpansionTile 两者的综合功能。

Flutter提供了数以百计的内置widget,Flutter开发团队努力根据开发者的反馈改进现有的widget,因此他们可以引入新的功能或改进,这可能导致废弃替代的、基于社区的widget。因此,使用像ExpansionPanelExpansionTile 这样的原生widget可以使你的应用稳定并符合Material Design规范。

试试用ExpansionPanel 来实现你的下一个应用原型的可扩展列表吧 !