在实际业务开发过程中,或多或少会遇到树形控件的需求。
最简单的需求比如 QQ 联系人的分组:
类似于这种,Flutter 给我们提供了相当便捷的 UI 组件 ExpansionPanel。
ExpansionPanel
看名字也能看出来,是一个"扩展面板"。
那按照惯例,我们首先打开官网,查看一下它的说明:
看说明也就能明白了,它不单独使用,只能和 ExpansionPanelList
配合使用。
那我们点进源码看一下构造函数:
ExpansionPanel({
@required this.headerBuilder,
@required this.body,
this.isExpanded = false,
this.canTapOnHeader = false,
}) : assert(headerBuilder != null),
assert(body != null),
assert(isExpanded != null),
assert(canTapOnHeader != null);
复制代码
一共有四个参数:
- headerBuilder:header
- body:body
- isExpanded:是否展开
- canTapOnHeader:header是否可以点击
看完了 ExpansionPanel
的构造函数,下面就看一下 ExpansionPanelList
。
ExpansionPanelList
照例先看它的介绍:
A material expansion panel list that lays out its children and animates expansions.
material 展开面板列表,用于设置其子项并为展开设置动画。
然后打开源码查看构造函数:
const ExpansionPanelList({
Key key,
this.children = const <ExpansionPanel>[],
this.expansionCallback,
this.animationDuration = kThemeAnimationDuration,
}) : assert(children != null),
assert(animationDuration != null),
_allowOnlyOnePanelOpen = false,
initialOpenPanelValue = null,
super(key: key);
复制代码
需要我们使用的也就三个参数:
- children:不用多说,就是 ExpansionPanel
- expansionCallback:展开回调,这里会返回点击的 index
- animationDuration:动画的时间
基本上看完构造函数,我们也就知道该怎么去写代码了,那官方也提供给我们了一个 Demo。
官方Demo
效果如下:
来看下代码:
class Item {
Item({
this.expandedValue,
this.headerValue,
this.isExpanded = false,
});
String expandedValue;
String headerValue;
bool isExpanded;
}
List<Item> generateItems(int numberOfItems) {
return List.generate(numberOfItems, (int index) {
return Item(
headerValue: 'Panel $index',
expandedValue: 'This is item number $index',
);
});
}
class ExpansionPanelPage extends StatefulWidget {
ExpansionPanelPage({Key key}) : super(key: key);
@override
_ExpansionPanelPageState createState() => _ExpansionPanelPageState();
}
class _ExpansionPanelPageState extends State<ExpansionPanelPage> {
List<Item> _data = generateItems(8);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('ExpansionPanelPage'),
),
body: SingleChildScrollView(
child: Container(
child: _buildPanel(),
),
),
);
}
Widget _buildPanel() {
return ExpansionPanelList(
expansionCallback: (int index, bool isExpanded) {
setState(() {
_data[index].isExpanded = !isExpanded;
});
},
children: _data.map<ExpansionPanel>((Item item) {
return ExpansionPanel(
headerBuilder: (BuildContext context, bool isExpanded) {
return ListTile(
title: Text(item.headerValue),
);
},
body: ListTile(
title: Text(item.expandedValue),
subtitle: Text('To delete this panel, tap the trash can icon'),
trailing: Icon(Icons.delete),
onTap: () {
setState(() {
_data.removeWhere((currentItem) => item == currentItem);
});
}),
isExpanded: item.isExpanded,
);
}).toList(),
);
}
}
复制代码
从上往下看。
Item
首先定义了一个 Item 类,里面包含了:
- expandedValue:展开的值
- headerValue:header的值
- isExpanded:是否已经展开
generateItems
生成指定数量的 Item
_ExpansionPanelPageState
重点来了,看build 方法:
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('ExpansionPanelPage'),
),
body: SingleChildScrollView(
child: Container(
child: _buildPanel(),
),
),
);
}
复制代码
_buildPanel()
方法就是根据 Item 的数量生成一个 ExpansionPanelList
。
那为什么要用 SingleChildScrollView 包起来?
我们先把 SingleChildScrollView 去掉来看一下效果:
发现什么都没有了,看一下log:
flutter: The following assertion was thrown during performLayout(): flutter: RenderListBody must have unlimited space along its main axis. flutter: RenderListBody does not clip or resize its children, so it must be placed in a parent that does not flutter: constrain the main axis. You probably want to put the RenderListBody inside a RenderViewport with a matching main axis.
大致意思就是说:
RenderListBody所在的主轴必须要有无线的空间,因为RenderListBody 要不断的调整children 的大小,所以必须把它放在不约束主轴的 parent 中。
在上面的gif图我们也能看出来,只有点击箭头才能展开,如果想要点击 header 也要展开的话,
使用 ExpansionPanel 的 canTapOnHeader
参数:
ExpansionPanel(
canTapOnHeader: true,
headerBuilder: xxx,
body: xxx;
)
复制代码
效果如下:
body 为ListView
在我们实际业务中,可能最多的业务为展开是一个列表,那需要 body 是ListView。
其实和官方Demo差不多,需要注意的一点就是 shrinkWrap & physics 这两个字段:
return ListView.builder(
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
);
复制代码
只能展开一个
有时我们也会遇到只能展开一个,点击其他的时候要关闭已经展开的。
效果如下:
代码如下,需使用 ExpansionPanelList.radio
:
Widget _buildPanel() {
return ExpansionPanelList.radio(
expansionCallback: (int index, bool isExpanded) {
setState(() {
_data[index].isExpanded = !isExpanded;
});
},
children: _data.map<ExpansionPanel>((Item item) {
return ExpansionPanelRadio(
canTapOnHeader: true,
headerBuilder: (BuildContext context, bool isExpanded) {
return ListTile(
title: Text(item.headerValue),
);
},
body: ListTile(
title: Text(item.expandedValue),
subtitle: Text('To delete this panel, tap the trash can icon'),
trailing: Icon(Icons.delete),
onTap: () {
setState(() {
_data.removeWhere((currentItem) => item == currentItem);
});
}),
value: item.headerValue,
);
}).toList(),
);
}
复制代码
ExpansionPanelList.radio
的 children 也需要改变为:ExpansionPanelRadio
。
ExpansionPanelRadio
和 ExpansionPanel
的区别就是一个 value。
ExpansionPanelRadio
是用 value 来区分的,所以每一个要是唯一的。
总结
使用 ExpansionPanel 可以很轻松的实现展开效果,
而且 ExpansionPanelList 返回的是一个 MergeableMaterial,
所以想自定义UI的,也可以自己实现。
完整代码已经传至GitHub: