开发环境:
Flutter 3.16.9
Dart 3.1.3
Android Studio 2023.2.1
最近项目中有个功能需要一个展开列表,奈何官方的难易满足想要的样子,搜索中发现两篇文章 :
Flutter初探–半自定义ExpansionTile
Flutter 自定义ExpansionTitle
基本上已经可以满足我得需求了,但是我得需求中,可能需要通过点击 leading 或 trailing 去触发展开/收缩,而非点击整个头就进行展开/收缩,简单修改,基本上能满足我的需求。
完整代码如下:
import 'package:flutter/material.dart';
const Duration _kExpand = Duration(milliseconds: 200);
// 分割线显示时机
enum DividerDisplayTime {
always, // 总是显示
opened, // 展开时显示
closed, // 关闭时显示
never // 不显示
}
// 点击展开时机
enum TapExpandTime {
header, // 整个头部
leading, // 前缀
title, // 标题
trailing, // 后缀
never // 不需要展开
}
class CustomExpansionTileController {
CustomExpansionTileController();
_CustomExpansionTileState? _state;
bool get isExpanded {
assert(_state != null);
return _state!._isExpanded;
}
void expand() {
assert(_state != null);
if (!isExpanded) {
_state!._toggleExpansion();
}
}
void collapse() {
assert(_state != null);
if (isExpanded) {
_state!._toggleExpansion();
}
}
static CustomExpansionTileController of(BuildContext context) {
final _CustomExpansionTileState? result =
context.findAncestorStateOfType<_CustomExpansionTileState>();
if (result != null) {
return result._tileController;
}
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary(
'CustomExpansionTileController.of() called with a context that does not contain a CustomExpansionTile.',
),
ErrorDescription(
'No CustomExpansionTile ancestor could be found starting from the context that was passed to CustomExpansionTileController.of(). '
'This usually happens when the context provided is from the same StatefulWidget as that '
'whose build function actually creates the CustomExpansionTile widget being sought.',
),
ErrorHint(
'There are several ways to avoid this problem. The simplest is to use a Builder to get a '
'context that is "under" the CustomExpansionTile. For an example of this, please see the '
'documentation for CustomExpansionTileController.of():\n'
' https://api.flutter.dev/flutter/material/CustomExpansionTile/of.html',
),
ErrorHint(
'A more efficient solution is to split your build function into several widgets. This '
'introduces a new context from which you can obtain the CustomExpansionTile. In this solution, '
'you would have an outer widget that creates the CustomExpansionTile populated by instances of '
'your new inner widgets, and then in these inner widgets you would use CustomExpansionTileController.of().\n'
'An other solution is assign a GlobalKey to the CustomExpansionTile, '
'then use the key.currentState property to obtain the CustomExpansionTile rather than '
'using the CustomExpansionTileController.of() function.',
),
context.describeElement('The context used was'),
]);
}
static CustomExpansionTileController? maybeOf(BuildContext context) {
return context
.findAncestorStateOfType<_CustomExpansionTileState>()
?._tileController;
}
}
class CustomExpansionTile extends StatefulWidget {
const CustomExpansionTile({
super.key,
this.leading,
required this.title,
this.onExpansionChanged,
this.children = const <Widget>[],
this.trailing,
this.initiallyExpanded = false,
this.maintainState = false,
this.expandedCrossAxisAlignment,
this.expandedAlignment,
this.childrenPadding,
this.controller,
///
this.dividerColor,
this.dividerDisplayTime = DividerDisplayTime.never,
this.tapExpandTime = TapExpandTime.header,
this.showDefaultTrailing = false,
}) : assert(
expandedCrossAxisAlignment != CrossAxisAlignment.baseline,
'CrossAxisAlignment.baseline is not supported since the expanded children '
'are aligned in a column, not a row. Try to use another constant.',
);
/// 前缀
final Widget Function(bool)? leading;
/// 标题
final Widget Function(bool) title;
/// 展开/关闭回调事件
final ValueChanged<bool>? onExpansionChanged;
/// 展开内容
final List<Widget> children;
/// 后缀
final Widget Function(bool)? trailing;
// 初始化时是否打开
final bool initiallyExpanded;
/// 是否保持内容区状态
/// 当设置为 true 时,折叠时内容区内容将不会被销毁
/// 当设置为 false 时,折叠时内容区内容将会被移除,并在展开时重新创建。
final bool maintainState;
/// 内容区对齐方式
final Alignment? expandedAlignment;
/// 内容区交叉轴对齐方式
final CrossAxisAlignment? expandedCrossAxisAlignment;
/// 内容区内边距
final EdgeInsetsGeometry? childrenPadding;
final CustomExpansionTileController? controller;
/// 新添加的 ///
/// 边框颜色
final Color? dividerColor;
/// 边框显示时机
final DividerDisplayTime dividerDisplayTime;
/// 点击展开的时机
final TapExpandTime tapExpandTime;
/// 显示默认后缀(用户没有自定义的情况下生效)
final bool showDefaultTrailing;
State<CustomExpansionTile> createState() => _CustomExpansionTileState();
}
class _CustomExpansionTileState extends State<CustomExpansionTile>
with SingleTickerProviderStateMixin {
static final Animatable<double> _easeOutTween =
CurveTween(curve: Curves.easeOut);
static final Animatable<double> _easeInTween =
CurveTween(curve: Curves.easeIn);
static final Animatable<double> _halfTween =
Tween<double>(begin: 1.0, end: 1.0);
final ColorTween _borderColorTween = ColorTween();
late AnimationController _animationController;
late Animation<double> _iconTurns;
late Animation<double> _heightFactor;
late Animation<Color?> _borderColor;
bool _isExpanded = false;
late CustomExpansionTileController _tileController;
late TapExpandTime _tapExpandTime;
void initState() {
super.initState();
_animationController = AnimationController(duration: _kExpand, vsync: this);
_heightFactor = _animationController.drive(_easeInTween);
_iconTurns = _animationController.drive(_halfTween.chain(_easeInTween));
_borderColor =
_animationController.drive(_borderColorTween.chain(_easeOutTween));
_isExpanded = PageStorage.maybeOf(context)?.readState(context) as bool? ??
widget.initiallyExpanded;
if (_isExpanded) {
_animationController.value = 1.0;
}
assert(widget.controller?._state == null);
_tileController = widget.controller ?? CustomExpansionTileController();
_tileController._state = this;
// 如果没有配置 leading,展开时机确是 leading,则默认为 header
// 如果没有配置 trailing且showDefaultTrailing为false,则默认为header
_tapExpandTime = widget.tapExpandTime;
if ((widget.leading == null &&
widget.tapExpandTime == TapExpandTime.leading) ||
(widget.trailing == null &&
!widget.showDefaultTrailing &&
widget.tapExpandTime == TapExpandTime.trailing)) {
_tapExpandTime = TapExpandTime.header;
}
}
void dispose() {
_tileController._state = null;
_animationController.dispose();
super.dispose();
}
void _toggleExpansion() {
setState(() {
_isExpanded = !_isExpanded;
if (_isExpanded) {
_animationController.forward();
} else {
_animationController.reverse().then<void>((void value) {
if (!mounted) {
return;
}
setState(() {
// Rebuild without widget.children.
});
});
}
PageStorage.maybeOf(context)?.writeState(context, _isExpanded);
});
widget.onExpansionChanged?.call(_isExpanded);
}
void _handleTap() {
_toggleExpansion();
}
Widget _buildTrailingIcon(BuildContext context) {
return _isExpanded ? const Icon(Icons.expand_less): const Icon(Icons.expand_more);
}
Widget _buildChildren(BuildContext context, Widget? child) {
final Color borderSideColor = _borderColor.value ?? Colors.transparent;
return Container(
alignment: Alignment.centerLeft,
decoration: BoxDecoration(
color: Colors.white,
border: Border(
bottom: BorderSide(color: borderSideColor),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
bindTapEvent(
child: Row(
children: [
bindTapEvent(
child: widget.leading?.call(_isExpanded) ?? const SizedBox.shrink(),
tapExpandTime: TapExpandTime.leading,
),
bindTapEvent(
child: Expanded(child: widget.title.call(_isExpanded)),
tapExpandTime: TapExpandTime.title,
),
bindTapEvent(
child: widget.trailing?.call(_isExpanded) ??
(widget.showDefaultTrailing
? _buildTrailingIcon(context)
: const SizedBox.shrink()),
tapExpandTime: TapExpandTime.trailing,
)
],
),
tapExpandTime: TapExpandTime.header,
),
ClipRect(
child: Align(
alignment: widget.expandedAlignment ?? Alignment.center,
heightFactor: _heightFactor.value,
child: child,
),
),
],
),
);
}
Widget bindTapEvent({
required Widget child,
required TapExpandTime tapExpandTime,
}) {
if (_tapExpandTime == tapExpandTime) {
return InkWell(
onTap: _handleTap,
child: child,
);
}
return child;
}
void didChangeDependencies() {
final ThemeData theme = Theme.of(context);
_updateBorder(theme);
super.didChangeDependencies();
}
void _updateBorder(ThemeData theme) {
Color beginColor = widget.dividerColor ?? theme.dividerColor;
Color endColor = beginColor;
switch (widget.dividerDisplayTime) {
case DividerDisplayTime.always:
break;
case DividerDisplayTime.opened:
beginColor = Colors.transparent;
break;
case DividerDisplayTime.closed:
endColor = Colors.transparent;
break;
case DividerDisplayTime.never:
beginColor = Colors.transparent;
endColor = Colors.transparent;
break;
default:
}
_borderColorTween
..begin = beginColor
..end = endColor;
}
Widget build(BuildContext context) {
final bool closed = !_isExpanded && _animationController.isDismissed;
final bool shouldRemoveChildren = closed && !widget.maintainState;
final Widget result = Offstage(
offstage: closed,
child: TickerMode(
enabled: !closed,
child: Padding(
padding: widget.childrenPadding ?? EdgeInsets.zero,
child: Column(
crossAxisAlignment:
widget.expandedCrossAxisAlignment ?? CrossAxisAlignment.center,
children: widget.children,
),
),
),
);
return AnimatedBuilder(
animation: _animationController.view,
builder: _buildChildren,
child: shouldRemoveChildren ? null : result,
);
}
}
然后参考 “Flutter 自定义ExpansionTitle” 文章中的界面,大概实现了类似的效果。如下图
源代码如下(有些自己封装的组件):
CustomExpansionTile(
title: (isExpand) {
return Container(
height: 35,
padding: const EdgeInsets.symmetric(
vertical: 5,
horizontal: 15,
),
decoration: const BoxDecoration(
color: Color(0xFFFFA116),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(5),
topRight: Radius.circular(5),
bottomLeft: Radius.circular(15),
bottomRight: Radius.circular(15),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Container(
height: 30,
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: Color(0xFFF7F8Fa),
borderRadius: BorderRadius.all(Radius.circular(5)),
),
alignment: Alignment.center,
child: const CustomText(
text: '简单',
color: Color(0xFF00AF9B),
),
),
const SizedBox(width: 10),
const CustomText(
text: '面试经典 150 题',
color: Colors.white,
)
],
),
CustomIcon.icon(
isExpand ? Icons.expand_less : Icons.expand_more,
color: Colors.white,
)
],
),
);
},
children: [
Container(
height: 40,
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(
color: Color(0xFFEBEBEB),
),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
CustomIcon.icon(
Icons.check_circle,
color: const Color(0xFF4CB352),
size: 18,
),
const SizedBox(width: 5),
const CustomText(text: '两数之和')
],
),
Row(
children: [
const CustomText(
text: '简单',
color: Color(0xFF00AF9B),
),
CustomIcon.icon(
Icons.keyboard_arrow_right_outlined,
color: const Color(0xFFAAAAAA),
),
],
)
],
),
),
Container(
height: 40,
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(
color: Color(0xFFEBEBEB),
),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
CustomIcon.icon(
Icons.check_circle,
color: const Color(0xFFAAAAAA),
size: 18,
),
const SizedBox(width: 5),
const CustomText(text: '合并两个有序数组')
],
),
Row(
children: [
const CustomText(
text: '简单',
color: Color(0xFF00AF9B),
),
CustomIcon.icon(
Icons.keyboard_arrow_right_outlined,
color: const Color(0xFFAAAAAA),
),
],
)
],
),
),
], //展开的子列表布局
)