Flutter初探–半自定义ExpansionTile

背景

做移动端开发的朋友经常会遇到数据需要层级展示的场景,如二级或者三级列表。因此flutter也为我们提供了列表中可用来折叠的组件ExpansionTile,基本能满足我们的需求,但是...这玩意可自定义程度真的有限!??用 ExpansionTile实现效果如下图所示:

未展开的效果图

展开的效果图

存在问题:
1.ExpansionTile背景色展开后才显示
2.上下分割线展开后才显示
3.分割线颜色不可修改
4.标题和图标展开时展示成了主题色

那如何解决这些问题呢? 先看源码再说~

源码解析

1
2
3
4
5
6
7
8
9
10
11
const ExpansionTile({
    Key key,
    this.leading,
    @required this.title,
    this.backgroundColor,
    this.onExpansionChanged,
    this.children = const <Widget>[],
    this.trailing,
    this.initiallyExpanded = false,
  }) : assert(initiallyExpanded != null),
       super(key: key);

leading:左侧头部组件,如用户头像
title:常见标题组件
backgroundColor:展开时子列表背景色
children:展开的widgets
trailing:用于替换尾部箭头的组件
initiallyExpanded:设置默认是否展开

可以看到,ExpansionTile提供的属性非常有限,并没有提供我们用于解决上边问题的方法,ExpansionTile中的代码并不多,其中定义了各种动画,并设置了动画区间,ExpansionTile的展示效果也都依赖于这些颜色~

1
2
3
4
5
6
7
8
9
10
11
12
  final ColorTween _borderColorTween = ColorTween();
  final ColorTween _headerColorTween = ColorTween();
  final ColorTween _iconColorTween = ColorTween();
  final ColorTween _backgroundColorTween = ColorTween();

  AnimationController _controller;
  Animation<double> _iconTurns;
  Animation<double> _heightFactor;
  Animation<Color> _borderColor;
  Animation<Color> _headerColor;
  Animation<Color> _iconColor;
  Animation<Color> _backgroundColor;

_ExpansionTileState中定义的各种动画

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@override
  void didChangeDependencies() {
    final ThemeData theme = Theme.of(context);
    _borderColorTween
      ..end = theme.dividerColor;
    _headerColorTween
      ..begin = theme.textTheme.subhead.color
      ..end = theme.accentColor;
    _iconColorTween
      ..begin = theme.unselectedWidgetColor
      ..end = theme.accentColor;
    _backgroundColorTween
      ..end = widget.backgroundColor;
    super.didChangeDependencies();
  }

复写didChangeDependencies来设置颜色区间,所以看到这里,我们需要做的只是拷贝一份出来,修改这里的颜色区间逻辑而已~

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
import 'package:flutter/material.dart';

// import 'colors.dart';
// import 'icons.dart';
// import 'list_tile.dart';
// import 'theme.dart';
// import 'theme_data.dart';

const Duration _kExpand = Duration(milliseconds: 200);

/// A single-line [ListTile] with a trailing button that expands or collapses
/// the tile to reveal or hide the [children].
///
/// This widget is typically used with [ListView] to create an
/// "expand / collapse" list entry. When used with scrolling widgets like
/// [ListView], a unique [PageStorageKey] must be specified to enable the
/// [HJExpansionTile] to save and restore its expanded state when it is scrolled
/// in and out of view.
///
/// See also:
///
///  * [ListTile], useful for creating expansion tile [children] when the
///    expansion tile represents a sublist.
///  * The "Expand/collapse" section of
///    <https://material.io/guidelines/components/lists-controls.html>.

// 分割线显示时机
enum DividerDisplayTime {
  always, //总是显示
  opened, //展开时显示
  closed, //关闭时显示
  never //不显示
}

class HJExpansionTile extends StatefulWidget {
  /// Creates a single-line [ListTile] with a trailing button that expands or collapses
  /// the tile to reveal or hide the [children]. The [initiallyExpanded] property must
  /// be non-null.
  const HJExpansionTile({
    Key key,
    this.leading,
    @required this.title,
    this.backgroundColor,
    this.dividerColor,
    this.iconColor,
    this.dividerDisplayTime,
    this.onExpansionChanged,
    this.children = const <Widget>[],
    this.trailing,
    this.initiallyExpanded = false,
  })  : assert(initiallyExpanded != null),
        super(key: key);

  /// A widget to display before the title.
  ///
  /// Typically a [CircleAvatar] widget.
  final Widget leading;

  /// The primary content of the list item.
  ///
  /// Typically a [Text] widget.
  final Widget title;

  /// Called when the tile expands or collapses.
  ///
  /// When the tile starts expanding, this function is called with the value
  /// true. When the tile starts collapsing, this function is called with
  /// the value false.
  final ValueChanged<bool> onExpansionChanged;

  /// The widgets that are displayed when the tile expands.
  ///
  /// Typically [ListTile] widgets.
  final List<Widget> children;

  /// The color to display behind the sublist when expanded.
  final Color backgroundColor;

  /// A widget to display instead of a rotating arrow icon.
  final Widget trailing;

  /// Specifies if the list tile is initially expanded (true) or collapsed (false, the default).
  final bool initiallyExpanded;

  final Color dividerColor;

  final DividerDisplayTime dividerDisplayTime;

  final Color iconColor;

  @override
  _HJExpansionTileState createState() => _HJExpansionTileState();
}

class _HJExpansionTileState extends State<HJExpansionTile>
    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: 0.0, end: 0.5);

  final ColorTween _borderColorTween = ColorTween();
  final ColorTween _headerColorTween = ColorTween();
  final ColorTween _iconColorTween = ColorTween();
  final ColorTween _backgroundColorTween = ColorTween();

  AnimationController _controller;
  Animation<double> _iconTurns;
  Animation<double> _heightFactor;
  Animation<Color> _borderColor;
  Animation<Color> _headerColor;
  Animation<Color> _iconColor;
  Animation<Color> _backgroundColor;

  bool _isExpanded = false;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(duration: _kExpand, vsync: this);
    _heightFactor = _controller.drive(_easeInTween);
    _iconTurns = _controller.drive(_halfTween.chain(_easeInTween));
    _borderColor = _controller.drive(_borderColorTween.chain(_easeOutTween));
    _headerColor = _controller.drive(_headerColorTween.chain(_easeInTween));
    _iconColor = _controller.drive(_iconColorTween.chain(_easeInTween));
    _backgroundColor =
        _controller.drive(_backgroundColorTween.chain(_easeOutTween));

    _isExpanded =
        PageStorage.of(context)?.readState(context) ?? widget.initiallyExpanded;
    if (_isExpanded) _controller.value = 1.0;
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  void _handleTap() {
    setState(() {
      _isExpanded = !_isExpanded;
      if (_isExpanded) {
        _controller.forward();
      } else {
        _controller.reverse().then<void>((void value) {
          if (!mounted) return;
          setState(() {
            // Rebuild without widget.children.
          });
        });
      }
      PageStorage.of(context)?.writeState(context, _isExpanded);
    });
    if (widget.onExpansionChanged != null)
      widget.onExpansionChanged(_isExpanded);
  }

  Widget _buildChildren(BuildContext context, Widget child) {
    final Color borderSideColor = _borderColor.value ?? Colors.transparent;

    return Container(
      decoration: BoxDecoration(
        color: _backgroundColor.value ?? Colors.transparent,
        border: Border(
          bottom: BorderSide(color: borderSideColor),
        ),
      ),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: <Widget>[
          ListTileTheme.merge(
            iconColor: _iconColor.value,
            textColor: _headerColor.value,
            child: ListTile(
              onTap: _handleTap,
              leading: widget.leading,
              title: widget.title,
              trailing: widget.trailing ??
                  RotationTransition(
                    turns: _iconTurns,
                    child: const Icon(Icons.expand_more),
                  ),
            ),
          ),
          ClipRect(
            child: Align(
              heightFactor: _heightFactor.value,
              child: child,
            ),
          ),
        ],
      ),
    );
  }

  @override
  void didChangeDependencies() {
   
    setupDidvierColorTween();

    setupIconColorTween();
   
    setupBackgroundColor();

    super.didChangeDependencies();
  }

  void setupDidvierColorTween() {
    final ThemeData theme = Theme.of(context);

    Color beginColor = this.widget.dividerColor ?? theme.dividerColor;
    Color endColor = beginColor;

    switch (widget.dividerDisplayTime) {
      case DividerDisplayTime.always:
        break;
      case DividerDisplayTime.opened:
        endColor = Colors.transparent;
        break;
      case DividerDisplayTime.closed:
        beginColor = Colors.transparent;
        break;
      case DividerDisplayTime.never:
        beginColor = Colors.transparent;
        endColor = Colors.transparent;
        break;
      default:
    }
    _borderColorTween
      ..begin = beginColor
      ..end = endColor;
  }

  void setupIconColorTween(){
    final ThemeData theme = Theme.of(context);

    Color beginColor = this.widget.iconColor ?? theme.unselectedWidgetColor;
    Color endColor = beginColor;

    _iconColorTween
      ..begin = beginColor
      ..end = endColor;
  }

  void setupBackgroundColor(){
    _backgroundColorTween
    ..begin = widget.backgroundColor
    ..end = widget.backgroundColor;
  }
  @override
  Widget build(BuildContext context) {
    final bool closed = !_isExpanded && _controller.isDismissed;
    return AnimatedBuilder(
      animation: _controller.view,
      builder: _buildChildren,
      child: closed ? null : Column(children: widget.children),
    );
  }
}

修改内容说明

代码拷出来以后,原先引入的文件会报错,只需将它们注释或删除,引入material.dart即可

1
2
3
4
5
6
7
import 'package:flutter/material.dart';

// import 'colors.dart';
// import 'icons.dart';
// import 'list_tile.dart';
// import 'theme.dart';
// import 'theme_data.dart';

然后扩展了三个属性,用来配置分割线和icon颜色,当然icon你也可以用trail来设置~

定义了分割线枚举来区分分割线的显示时机

1
2
3
4
5
6
7
// 分割线显示时机
enum DividerDisplayTime {
  always, //总是显示
  opened, //展开时显示
  closed, //关闭时显示
  never //不显示
}

扩展的三个属性

1
2
3
4
5
  final Color dividerColor;

  final DividerDisplayTime dividerDisplayTime;

  final Color iconColor;

调整后的初始化方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const HJExpansionTile({
    Key key,
    this.leading,
    @required this.title,
    this.backgroundColor,
    this.dividerColor,
    this.iconColor,
    this.dividerDisplayTime,
    this.onExpansionChanged,
    this.children = const <Widget>[],
    this.trailing,
    this.initiallyExpanded = false,
  })  : assert(initiallyExpanded != null),
        super(key: key);

调整背景色,分割线颜色,icon的动画颜色区间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
@override
  void didChangeDependencies() {
   
    setupDidvierColorTween();

    setupIconColorTween();
   
    setupBackgroundColor();

    super.didChangeDependencies();
  }

  void setupDidvierColorTween() {
    final ThemeData theme = Theme.of(context);

    Color beginColor = this.widget.dividerColor ?? theme.dividerColor;
    Color endColor = beginColor;

    switch (widget.dividerDisplayTime) {
      case DividerDisplayTime.always:
        break;
      case DividerDisplayTime.opened:
        endColor = Colors.transparent;
        break;
      case DividerDisplayTime.closed:
        beginColor = Colors.transparent;
        break;
      case DividerDisplayTime.never:
        beginColor = Colors.transparent;
        endColor = Colors.transparent;
        break;
      default:
    }
    _borderColorTween
      ..begin = beginColor
      ..end = endColor;
  }

  void setupIconColorTween(){
    final ThemeData theme = Theme.of(context);

    Color beginColor = this.widget.iconColor ?? theme.unselectedWidgetColor;
    Color endColor = beginColor;

    _iconColorTween
      ..begin = beginColor
      ..end = endColor;
  }

  void setupBackgroundColor(){
    _backgroundColorTween
    ..begin = widget.backgroundColor
    ..end = widget.backgroundColor;
  }

我不想要上方的分割线,所以只留了一个~任性??

1
2
3
4
5
6
7
8
9
10
11
 Widget _buildChildren(BuildContext context, Widget child) {
    final Color borderSideColor = _borderColor.value ?? Colors.transparent;

    return Container(
      decoration: BoxDecoration(
        color: _backgroundColor.value ?? Colors.transparent,
        border: Border(
          bottom: BorderSide(color: borderSideColor),
        ),
      ),
...

至此已修改完成~

修改后效果

调用Demo

1
2
3
4
5
6
7
8
HJExpansionTile tile = HJExpansionTile(
        title: Text(e['title']),
        children: <Widget>[_expandCell(e['values'])],
        backgroundColor: Color(0xFFF9FAFC),
        dividerColor: Color(0xFFE6E6E6),
        dividerDisplayTime: DividerDisplayTime.always, //默认
        iconColor: Colors.grey,
      );

12039186-cd78a2e213f586cf.png

12039186-e32b4e7933f8dbaa.png

结语

本篇文章是在开发总所遇问题的总觉,如有更加方案,欢迎留言讨论,学习道路永无止境,不断学习和总结才能进步~??