Flutter ExpansionTitle 组件自定义

开发环境:

  • 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),
                        ),
                      ],
                    )
                  ],
                ),
              ),
            ], //展开的子列表布局
          )
  • 9
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
Flutter 中,你可以使用自定义组件来创建树状图。下面是一个简单的示例: 首先,创建一个名为 `TreeNode` 的自定义组件,用于表示树的节点: ```dart class TreeNode extends StatelessWidget { final String title; final List<TreeNode> children; TreeNode({required this.title, required this.children}); @override Widget build(BuildContext context) { return Column( children: [ Text(title), SizedBox(height: 10), Column( children: children.map((node) => TreeNode(title: node.title, children: node.children)).toList(), ), ], ); } } ``` 然后,可以使用 `TreeNode` 组件来构建树状图。例如,假设我们有以下的树结构: ``` - Root - Node 1 - Leaf 1.1 - Leaf 1.2 - Node 2 - Leaf 2.1 - Node 3 ``` 可以使用以下代码来构建树状图: ```dart TreeNode root = TreeNode( title: 'Root', children: [ TreeNode( title: 'Node 1', children: [ TreeNode(title: 'Leaf 1.1', children: []), TreeNode(title: 'Leaf 1.2', children: []), ], ), TreeNode( title: 'Node 2', children: [ TreeNode(title: 'Leaf 2.1', children: []), ], ), TreeNode(title: 'Node 3', children: []), ], ); ``` 最后,将 `root` 组件放入 `build` 方法中的 `Widget` 树中进行渲染: ```dart @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('Tree Example'), ), body: SingleChildScrollView( child: TreeNode(title: root.title, children: root.children), ), ); } ``` 这样,你就可以在 Flutter 应用中显示一个简单的树状图了。根据实际需求,你可以对 `TreeNode` 组件进行更多的自定义和样式调整。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值