Flutter 修改Wrap实现可展开收缩的流式布局

952 阅读6分钟

文章项目案例 github链接 flutter_shrink_wrap

首先,我们看看目标和实现效果

图一

图中是美团外卖的店铺列表,每个店铺都有很多活动,各店铺的有着不同的优惠活动情况。我们可以点击活动标签那里触发展开显示该店铺的全部优惠活动情况,再次点击可以收缩。我们很容易看出,每个店铺的活动标签宽度都不一致,当一行不足以显示时就换行显示。我们根据这个性质很容易想到Wrap。但是,Wrap虽然可以根据元素宽度实现自动换行,但不可设置收缩和展开。哎,只好来改Wrap来实现这种情况了。

图中 ① 框选的是收缩状态下的Wrap,图中 ② 框选的是Wrap元素总宽度不超过1行的情况下的效果,图中 ③ 框选的是Wrap中元素全部展开的状态。

我们需要实现哪些东西

一、我们要让Wrap支持 maxLine,也就是最多显示多少行,实现图中的效果的话就是maxLine = 1。当我们不设置maxLine时,就显示所有行。

二、Wrap的右边有一个展开/收缩的图标,当Wrap的元素行数大于maxLine时,需要显示展开的图标;那么我们需要知道这个Wrap里面的元素有多少行才行。不同产品甚至不同页面的设计不一样,展开/收缩的图标很可能也不同,所以展开/收缩的图标不能放在Wrap组件里,而且,放在Wrap里面的话,对Wrap的侵入性太强了。那么我们就需要在Wrap外得到Wrap内元素的总行数(就叫totalRowCount吧)

方案思路

一、Wrap既然实现了自动根据宽度换行,我们肯定可以通过看它的源码,找到的换行相关的地方,修改一下,让超过maxLine的元素不显示就行了。

二、怎么让Wrap外部得知Wrap内元素的总行数呢,我想的是给Wrap一个GlobalKey,然后根据这个GlobalKey去取Wrap内元素的总行数。

代码分析

Wrap的源码

class ShrinkWrap extends MultiChildRenderObjectWidget {
  Wrap({
    Key? key,
    this.direction = Axis.horizontal,
    this.alignment = WrapAlignment.start,
    this.spacing = 0.0,
    this.runAlignment = WrapAlignment.start,
    this.runSpacing = 0.0,
    this.crossAxisAlignment = WrapCrossAlignment.start,
    this.textDirection,
    this.verticalDirection = VerticalDirection.down,
    this.clipBehavior = Clip.none,
    this.maxLines = 0, /// 新增的maxLine
    List<Widget> children = const <Widget>[],
  }) : assert(clipBehavior != null), super(key: key, children: children);

  @override
  RenderShrinkWrap createRenderObject(BuildContext context) {
    return RenderShrinkWrap(
      direction: direction,
      alignment: alignment,
      spacing: spacing,
      runAlignment: runAlignment,
      runSpacing: runSpacing,
      crossAxisAlignment: crossAxisAlignment,
      textDirection: textDirection ?? Directionality.maybeOf(context),
      verticalDirection: verticalDirection,
      clipBehavior: clipBehavior,
      maxLines: maxLines, /// 新增的maxLine
    );
  }

  @override
  void updateRenderObject(BuildContext context, RenderShrinkWrap renderObject) {
    renderObject
      ..alignment = alignment
      ..spacing = spacing
      ..runAlignment = runAlignment
      ..runSpacing = runSpacing
      ..crossAxisAlignment = crossAxisAlignment
      ..textDirection = textDirection ?? Directionality.maybeOf(context)
      ..verticalDirection = verticalDirection
      ..clipBehavior = clipBehavior
      ..maxLines = maxLines;   /// 新增的maxLine
  }

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
    properties.add(EnumProperty<Axis>('direction', direction));
    properties.add(EnumProperty<WrapAlignment>('alignment', alignment));
    properties.add(DoubleProperty('spacing', spacing));
    properties.add(EnumProperty<WrapAlignment>('runAlignment', runAlignment));
    properties.add(DoubleProperty('runSpacing', runSpacing));
    properties.add(EnumProperty<WrapCrossAlignment>('crossAxisAlignment', crossAxisAlignment));
    properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
    properties.add(EnumProperty<VerticalDirection>('verticalDirection', verticalDirection, defaultValue: VerticalDirection.down));
    properties.add(IntProperty('maxLines', maxLines, defaultValue: 0)); /// 新增的maxLine
  }
}

Wrap没做什么太多的事情,只是创建了一个RenderWrap,然后把所有参数都扔给了它,是的没错,RenderWrap才是关键,RenderWrap的主要职责是布局和绘制。也就是说Wrap中每个元素显示在哪里,是RenderWrap在负责,那么我们要新增的maxLine参数肯定也要传递给RenderWrap

RenderWrap源码

PS: 对RenderObject不了解的小伙伴建议看看王叔的视频教程 自己动手写个RenderObject ,当然,王叔的其他视频质量也很高,欢迎加入咱们Flutter Candies群,王叔也在,王叔很好说话你王叔人特好 另外,推荐大家购买的王叔的《Flutter组件详解与实战》来看看。真的很不错哦。全书大致按照“由入门到精通”划分,又按功能板块细分,共分为3部分:「基础篇」详细介绍基础布局、文字、图片、按钮、事件流、滚动列表等常用组件,既适合Flutter新手,也可帮助有一定经验的开发者查漏补缺。「进阶篇」介绍更多与布局、动画、导航、人机交互、弹窗等功能相关的组件。「扩展篇」则重点介绍如Sliver机制、高效渲染、打破约束、自定义布局等难点。

好了,我假装你已经看了王叔的视频教程了,在这里我就不啰嗦了。大晚上的码字久了打瞌睡😄。。看了视频之后,你将明白,我们这次的修改主要是从RenderWrapperformLayout函数开搞,只需要在布局时把超过maxLine的元素动点小手脚就好了。另外,由于我们Wrap外部需要知道Wrap内元素的总行数,所以即便是我们只显示maxLine行,但是还是要计算总行数,我们可以仿照computeDryLayout函数来写一个计算总行数的函数_getRowCount,该函数会在performLayout执行时被调用,这样的话,当Wrap渲染完成后,我们就能在Wrap外面通过GlobalKey拿到Wrap的totalRowCount了。下面是关键代码,你也可以直接看仓库的shrink_wrap文件

class ShrinkWrap extends MultiChildRenderObjectWidget {
  ShrinkWrap({
    Key? key,
    this.direction = Axis.horizontal,
    this.alignment = WrapAlignment.start,
    this.spacing = 0.0,
    this.runAlignment = WrapAlignment.start,
    this.runSpacing = 0.0,
    this.crossAxisAlignment = WrapCrossAlignment.start,
    this.textDirection,
    this.verticalDirection = VerticalDirection.down,
    this.clipBehavior = Clip.none,
    this.maxLines = 0,
    List<Widget> children = const <Widget>[],
  })  : assert(maxLines >= 0, 'maxLines must be >= 0'),
        super(key: key, children: children);
  /// maximum rows when expand; when it is 0, the maximum rows is not limited;
  int _maxLines;

  int get maxLines => _maxLines;

  set maxLines(int value) {
    if (_maxLines == value) return;
    _maxLines = value;
    markNeedsLayout();
  }

  @override
  Size computeDryLayout(BoxConstraints constraints) {
    return _computeDryLayout(constraints);
  }

  Size _computeDryLayout(BoxConstraints constraints,
      [ChildLayouter layoutChild = ChildLayoutHelper.dryLayoutChild]) {
    final BoxConstraints childConstraints;
    double mainAxisLimit = 0.0;
    switch (direction) {
      case Axis.horizontal:
        childConstraints = BoxConstraints(maxWidth: constraints.maxWidth);
        mainAxisLimit = constraints.maxWidth;
        break;
      case Axis.vertical:
        childConstraints = BoxConstraints(maxHeight: constraints.maxHeight);
        mainAxisLimit = constraints.maxHeight;
        break;
    }

    double mainAxisExtent = 0.0;
    double crossAxisExtent = 0.0;
    double runMainAxisExtent = 0.0;
    double runCrossAxisExtent = 0.0;
    int childCount = 0;
    RenderBox? child = firstChild;
    int runMainIndex = 0;
    while (child != null) {
      final Size childSize = layoutChild(child, childConstraints);
      final double childMainAxisExtent = _getMainAxisExtent(childSize);
      final double childCrossAxisExtent = _getCrossAxisExtent(childSize);
      // There must be at least one child before we move on to the next run.
      if (childCount > 0 &&
          runMainAxisExtent + childMainAxisExtent + spacing > mainAxisLimit) {
        mainAxisExtent = math.max(mainAxisExtent, runMainAxisExtent);
        crossAxisExtent += runCrossAxisExtent + runSpacing;
        runMainAxisExtent = 0.0;
        runCrossAxisExtent = 0.0;
        childCount = 0;
        if (_maxLines > 0 && ++runMainIndex > _maxLines) break;
      }
      runMainAxisExtent += childMainAxisExtent;
      runCrossAxisExtent = math.max(runCrossAxisExtent, childCrossAxisExtent);
      if (childCount > 0) runMainAxisExtent += spacing;
      childCount += 1;
      child = childAfter(child);
    }
    crossAxisExtent += runCrossAxisExtent;
    mainAxisExtent = math.max(mainAxisExtent, runMainAxisExtent);

    switch (direction) {
      case Axis.horizontal:
        return constraints.constrain(Size(mainAxisExtent, crossAxisExtent));
      case Axis.vertical:
        return constraints.constrain(Size(crossAxisExtent, mainAxisExtent));
    }
  }

  /// 获取真实行数
  int _getRowCount(BoxConstraints constraints) {
    ChildLayouter layoutChild = ChildLayoutHelper.dryLayoutChild;
    final BoxConstraints childConstraints;
    double mainAxisLimit = 0.0;
    switch (direction) {
      case Axis.horizontal:
        childConstraints = BoxConstraints(maxWidth: constraints.maxWidth);
        mainAxisLimit = constraints.maxWidth;
        break;
      case Axis.vertical:
        childConstraints = BoxConstraints(maxHeight: constraints.maxHeight);
        mainAxisLimit = constraints.maxHeight;
        break;
    }
    double runMainAxisExtent = 0.0;
    double runCrossAxisExtent = 0.0;
    int childCount = 0;
    RenderBox? child = firstChild;
    int runMainCount = child != null ? 1 : 0;
    while (child != null) {
      final Size childSize = layoutChild(child, childConstraints);
      final double childMainAxisExtent = _getMainAxisExtent(childSize);
      final double childCrossAxisExtent = _getCrossAxisExtent(childSize);
      // There must be at least one child before we move on to the next run.
      if (childCount > 0 &&
          runMainAxisExtent + childMainAxisExtent + spacing > mainAxisLimit) {
        runMainAxisExtent = 0.0;
        runCrossAxisExtent = 0.0;
        childCount = 0;
        runMainCount++;
      }
      runMainAxisExtent += childMainAxisExtent;
      runCrossAxisExtent = math.max(runCrossAxisExtent, childCrossAxisExtent);
      if (childCount > 0) runMainAxisExtent += spacing;
      childCount += 1;
      child = childAfter(child);
    }
    return runMainCount;
  }

  /// 总行数
  int _totalRowCount = 0;

  int get totalRowCount => _totalRowCount;

  @override
  void performLayout() {
    final BoxConstraints constraints = this.constraints;
    assert(_debugHasNecessaryDirections);

    // 计算总行数以便于外部在渲染一次后能拿到总行数
    _totalRowCount = _getRowCount(constraints); 

    _hasVisualOverflow = false;
    RenderBox? child = firstChild;
    if (child == null) {
      size = constraints.smallest;
      return;
    }
    final BoxConstraints childConstraints;
    double mainAxisLimit = 0.0;
    bool flipMainAxis = false;
    bool flipCrossAxis = false;
    switch (direction) {
      case Axis.horizontal:
        childConstraints = BoxConstraints(maxWidth: constraints.maxWidth);
        mainAxisLimit = constraints.maxWidth;
        if (textDirection == TextDirection.rtl) flipMainAxis = true;
        if (verticalDirection == VerticalDirection.up) flipCrossAxis = true;
        break;
      case Axis.vertical:
        childConstraints = BoxConstraints(maxHeight: constraints.maxHeight);
        mainAxisLimit = constraints.maxHeight;
        if (verticalDirection == VerticalDirection.up) flipMainAxis = true;
        if (textDirection == TextDirection.rtl) flipCrossAxis = true;
        break;
    }
    final double spacing = this.spacing;
    final double runSpacing = this.runSpacing;
    final List<_RunMetrics> runMetrics = <_RunMetrics>[];
    double mainAxisExtent = 0.0;
    double crossAxisExtent = 0.0;
    double runMainAxisExtent = 0.0;
    double runCrossAxisExtent = 0.0;
    int childCount = 0;
    int runMainIndex = 1;
    while (child != null) {
      final childParentData = child.parentData! as ShrinkWrapParentData;
      if (_maxLines > 0 && runMainIndex > _maxLines) { //// 超出了_maxLines
        child.layout(BoxConstraints.loose(Size.zero), parentUsesSize: true);
        child = childParentData.nextSibling;
        continue;
      } else {
        child.layout(childConstraints, parentUsesSize: true);
      }
      final double childMainAxisExtent = _getMainAxisExtent(child.size);
      final double childCrossAxisExtent = _getCrossAxisExtent(child.size);
      if (childCount > 0 &&
          runMainAxisExtent + spacing + childMainAxisExtent > mainAxisLimit) {
        mainAxisExtent = math.max(mainAxisExtent, runMainAxisExtent);
        crossAxisExtent += runCrossAxisExtent;
        if (runMetrics.isNotEmpty) crossAxisExtent += runSpacing;
        runMetrics.add(
            _RunMetrics(runMainAxisExtent, runCrossAxisExtent, childCount));
        runMainAxisExtent = 0.0;
        runCrossAxisExtent = 0.0;
        childCount = 0;
        //// 超出了_maxLines
        if (_maxLines > 0 && ++runMainIndex > _maxLines) {
          child.layout(BoxConstraints.loose(Size.zero), parentUsesSize: true);
          child = childParentData.nextSibling;
          continue;
        }
      }
      runMainAxisExtent += childMainAxisExtent;
      if (childCount > 0) runMainAxisExtent += spacing;
      runCrossAxisExtent = math.max(runCrossAxisExtent, childCrossAxisExtent);
      childCount += 1;

      childParentData._runIndex = runMetrics.length;
      child = childParentData.nextSibling;
    }
    if (childCount > 0) {
      mainAxisExtent = math.max(mainAxisExtent, runMainAxisExtent);
      crossAxisExtent += runCrossAxisExtent;
      if (runMetrics.isNotEmpty) crossAxisExtent += runSpacing;
      runMetrics.add(_RunMetrics(runMainAxisExtent, runCrossAxisExtent, childCount));
    }
    /// while (child != null) 之下并未作修改,省略
    ......
}


....
}

怎么用

写到这里,我已经在打瞌睡了,想偷懒了,直接贴代码吧,

class LabelWidget extends StatefulWidget {
  final List<Widget> children;
  final double spacing, runSpacing;
  final int maxLines;

  const LabelWidget(
      {Key? key,
      required this.children,
      this.spacing = 3,
      this.runSpacing = 2,
      this.maxLines = 1})
      : super(key: key);

  @override
  State<LabelWidget> createState() => _LabelWidgetState();
}

class _LabelWidgetState extends State<LabelWidget> {
  GlobalKey wrapUniqueKey = GlobalKey();
  final ValueNotifier<int> totalRowCount = ValueNotifier(0);
  bool expand = false;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
      var renderObject = wrapUniqueKey.currentContext?.findRenderObject();
      if (renderObject == null) return;
      totalRowCount.value = (renderObject as RenderShrinkWrap).totalRowCount;
    });
  }

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

  static const Widget spacerButton = SizedBox(width: 14);
  static const Widget upButton =
      Icon(Icons.arrow_drop_up_rounded, color: Colors.black54, size: 14);
  static const Widget downButton =
      Icon(Icons.arrow_drop_down_rounded, color: Colors.black54, size: 14);

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      behavior: HitTestBehavior.opaque,
      onTap: () {
        if (totalRowCount.value <= widget.maxLines) return;
        setState(() => expand = !expand);
      },
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Expanded(
            child: ShrinkWrap(
              key: wrapUniqueKey,
              spacing: widget.spacing,
              runSpacing: widget.runSpacing,
              maxLines: expand ? 0 : widget.maxLines,
              children: widget.children,
            ),
          ),
          ValueListenableBuilder(
            valueListenable: totalRowCount,
            builder: (_, __, ___) {
              if (totalRowCount.value <= widget.maxLines) return spacerButton;
              return expand ? upButton : downButton;
            },
          )
        ],
      ),
    );
  }
}

于是我们就可以下面结果了结果

好了好了,实在是扛不住了,好困啊!!睡了睡了。。晚安!