Flutter 实现历史搜索的可折叠展开自动换行组件

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第9天,点击查看活动详情

背景

在搜索页中,历史搜索的列表,内容除了会自动换行之外,超过多少行还会自动折叠,点击箭头展开

image.png

分析

Flutter中的自动换行工具有Wrap

SizedBox(
        width: double.maxFinite,
        child: Wrap(
          spacing: 10,
          runSpacing: 10,
          children: [
            Container(
              width: 100,
              height: 50,
              color: Colors.red,
            ),
            Container(
              width: 200,
              height: 50,
              color: Colors.green,
            ),
            Container(
              width: 50,
              height: 50,
              color: Colors.black,
            ),
            Container(
              width: 20,
              height: 50,
              color: Colors.yellow,
            ),
            Container(
              width: 111,
              height: 50,
              color: Colors.deepPurple,
            ),
            Container(
              width: 300,
              height: 50,
              color: Colors.lightBlueAccent,
            ),
          ],
        ),
      )
复制代码

image.png

但仅支持自动换行,不满足折叠展开,插入展开按钮的功能。因为每个内容的宽度是不定的,不可能自己计算哪个item之后超过限制的行数

既然Wrap不满足,那什么组件可以实现需要的功能呢?类似的组件在官方介绍中还有Flow,不常用,因为他需要自己编写Delegate,规定每个Item排布

Flow

Flow,可以更加自由的将item排布。它有两个必须的参数:delegatechildren

children就是每个Item内容。delegate则是需要FlowDelegate

Flow({
  Key? key,
  required this.delegate,
  List<Widget> children = const <Widget>[],
  this.clipBehavior = Clip.hardEdge,
})
复制代码

FlowDelegate

继承下FlowDelegate看看

class TestFlowDelegate extends FlowDelegate {
  @override
  void paintChildren(FlowPaintingContext context) {
    // TODO: implement paintChildren
  }

  @override
  bool shouldRepaint(covariant FlowDelegate oldDelegate) {
    // TODO: implement shouldRepaint
    throw UnimplementedError();
  }

}
复制代码

从注释看看具体是做什么功能

/// Override to paint the children of the flow.
///
/// Children can be painted in any order, but each child can be painted at
/// most once. Although the container clips the children to its own bounds, it
/// is more efficient to skip painting a child altogether rather than having
/// it paint entirely outside the container's clip.
///
/// To paint a child, call [FlowPaintingContext.paintChild] on the given
/// [FlowPaintingContext] (the `context` argument). The given context is valid
/// only within the scope of this function call and contains information (such
/// as the size of the container) that is useful for picking transformation
/// matrices for the children.
///
/// If this function depends on information other than the given context,
/// override [shouldRepaint] to indicate when the container should
/// relayout.
void paintChildren(FlowPaintingContext context);

  /// Override this method to return true when the children need to be
  /// repainted. This should compare the fields of the current delegate and the
  /// given oldDelegate and return true if the fields are such that
  /// paintChildren would act differently.
  ///
  /// The delegate can also trigger a repaint if the delegate provides the
  /// repaint animation argument to this object's constructor and that animation
  /// ticks. Triggering a repaint using this animation-based mechanism is more
  /// efficient than rebuilding the [Flow] widget to change its delegate.
  ///
  /// The flow container might repaint even if this function returns false, for
  /// example if layout triggers painting (e.g., if [shouldRelayout] returns
  /// true).
  bool shouldRepaint(covariant FlowDelegate oldDelegate);
复制代码

paintChildren简单来说可以让我们随意的按任何顺序的去绘制child

shouldRepaint则是给定需要重新绘制布局的条件

所以可知,paintChildren就是我们绘制我们自动换行折叠功能的主要方法。其中提供了一个FlowPaintingContext

abstract class FlowPaintingContext {
  /// The size of the container in which the children can be painted.
  Size get size;

  /// The number of children available to paint.
  int get childCount;

  /// The size of the [i]th child.
  ///
  /// If [i] is negative or exceeds [childCount], returns null.
  Size? getChildSize(int i);

  /// Paint the [i]th child using the given transform.
  ///
  /// The child will be painted in a coordinate system that concatenates the
  /// container's coordinate system with the given transform. The origin of the
  /// parent's coordinate system is the upper left corner of the parent, with
  /// x increasing rightward and y increasing downward.
  ///
  /// The container will clip the children to its bounds.
  void paintChild(int i, { Matrix4 transform, double opacity = 1.0 });
}
复制代码

size提供了可绘制的容器的大小childCount则是child数量

getChildSize可以拿到childSize,这个比较重要,我们绘制自动换行的计算主要依靠这个。

paintChild则是绘制child

绘制自动换行

简单分析下如何自动换行,首先我们可以拿到总的child的数量,所以可以遍历每个child的坐标index。我们还可以拿到容器的大小,即知道了最大宽度

根据getChildSize我们可以拿到child的Size,定义一个起始值offset遍历到哪个child的时候,判断一下加上这个child宽度以及space(child之间的间距) 是否会超出最大宽度

· 不会的话,绘制该child在这个地方,并且offset加上child宽度space

· 会的话,offset归零,换至下一行,并把这个child作为下一行的首个Itemoffset加上这个child宽度space

然后遍历下一个继续直到最后。

所以可以先得到我们需要传入的配置参数:space(Item的左右间隔),runSpace(Item的上下间隔)。跟Wrap差不多,然后假如child的高度参差不齐,那不好看,计算下一行的y坐标起始点也比较麻烦,所以我再定义一个extentHeight作为每个child的最大高度

根据上边的分析,写一下paintChildren

@override
void paintChildren(FlowPaintingContext context) {
  // 横向最大宽度
  var screenW = context.size.width;
  
  double offsetX = 0; // x坐标
  double offsetY = 0; // y坐标
  
  int childCount = context.childCount;

  for (int i = 0; i < childCount; i++) {
    // 如果当前x左边加上控件宽度小于最大宽度  则继续绘制  否则换行
    if (offsetX + (context.getChildSize(i)?.width ?? 0) <= screenW) {
      // 绘制控件
      context.paintChild(i,
          transform: Matrix4.translationValues(offsetX, offsetY, 0));
      // 更改x坐标
      offsetX = offsetX + (context.getChildSize(i)?.width ?? 0) + spacing;
    } else {
      // 将x坐标重置为0
      offsetX = 0;
      // 计算换行后y坐标的值
      offsetY = offsetY + extentHeight + runSpacing;
      // 绘制控件
      context.paintChild(i,
          transform: Matrix4.translationValues(offsetX, offsetY, 0));
      // 更改x坐标
      offsetX = offsetX + (context.getChildSize(i)?.width ?? 0) + spacing;
    }
  }
}
复制代码

测试一下

SizedBox(
  width: MediaQueryData.fromWindow(window).size.width,
  child: Flow(
    children: _getChildren(),
    delegate: TestFlowDelegate(
      spacing: 10,
      runSpacing: 10,
      extentHeight: 50,
    ),
  ),
)
复制代码

image.png

会发现很奇怪,每个child的宽度都占满了。经过调试,发现他需要设定每个child的Constraints,即重写getConstraintsForChild

@override
BoxConstraints getConstraintsForChild(int i, BoxConstraints constraints) {
  return BoxConstraints(
      maxWidth: constraints.maxWidth,
      minWidth: 0,
      maxHeight: extentHeight,
      minHeight: 0);
}
复制代码

min的两个需要改为0maxHeight为我们限制的高度,不然都默认是无限大

加入限制后就正常了

image.png

容器高度自适应

假如尝试把它放入一个Column中,会发现高度溢出

image.png

因为他的容器高度不定义的话默认也是无限大的。类似getConstraintsForChild我们也得重写getSize,但这里是Size,需要给定确定好的高度。所以上边我们记录一下行数line,用行数和组件高度来得到总高度

这里的line,在里面的改变无法更新到最后的行数,所以我写在父组件中,通过setState来使他更新最终的行数,不知道有没有更好的方法

@override
void paintChildren(FlowPaintingContext context) {
  ...
  int nowLine = 1;

  for (int i = 0; i < childCount; i++) {
    // 如果当前x左边加上控件宽度小于最大宽度  则继续绘制  否则换行
    if (offsetX + (context.getChildSize(i)?.width ?? 0) <= screenW) {
      ...
    } else {
      nowLine++;
      ...
    }
  }

  onLine?.call(nowLine);
}

@override
bool shouldRelayout(covariant FoldWrapDelegate oldDelegate) {
    return (line != oldDelegate.line);
}
复制代码
Flow(
  children: _getChildren(),
  delegate: TestFlowDelegate(
    ...
    line: nowLine,
    onLine: (line) {
      WidgetsBinding.instance?.addPostFrameCallback((timeStamp) {
        setState(() {
          nowLine = line;
        });
      });
    }
  ),
)
复制代码

最终可以自适应高度了。

image.png

折叠

要折叠的话,首先我们要知道从哪行开始折叠,以及当前是否折叠,定义两个参数

final int foldLine;
final bool isFold;
复制代码

换行后的判断修改一下

// 如果超过限制行数,则结束循环,不再绘制
if ((isFold && ((nowLine + 1) > foldLine))) {
  break;
} else {
  nowLine++;
  ...
}
复制代码

image.png

箭头按钮

折叠之后,当然要告诉用户已经折叠了,所以要在折叠的尾部插入一个小箭头标志,点击还可以展开

因为不能绘制children外的组件,所以我们插入在末尾中,并且可以自定这个按钮

List<Widget> _getChildren() {
  List<Widget> children = [];
  children.addAll(widget.children);
  if (widget.foldWidget != null) {
    children.add(widget.foldWidget!);
  } else {
    children.add(Container());
  }

  return children;
}
复制代码

shouldRepaint定义什么时候Repaint,然后这个按钮就可以控制isFold变量的改变,即可以改变折叠状态

@override
bool shouldRepaint(covariant TestFlowDelegate oldDelegate) {
  if (isFold != oldDelegate.isFold) {
    return true;
  }
  if (line != oldDelegate.line) {
    return true;
  }
  return false;
}
复制代码

接下来则是如何将小箭头插入到折叠末尾。

拿到箭头组件下标

// 最后一个child是折叠箭头
int foldWidgetIndex = context.childCount - 1;

for (int i = 0; i < foldWidgetIndex; i++) {
  ...
}
复制代码

因为最后一个child我们添加了小箭头,所以循环次数也需要减一

可否插入箭头组件

判断可以放下下一个child之后,再进行一次判断,下一行折叠的话,这个child加上下一个child再加上箭头组件的话可否放下。

· 可以的话,绘制这个child

· 不可以的话,判断这个child加上箭头可否放下,可以的话,绘制这个child和箭头;不可以的话,不绘制这个child,仅绘制箭头组件

// 如果当前x左边加上控件宽度小于最大宽度  则继续绘制  否则换行
if (offsetX + (context.getChildSize(i)?.width ?? 0) <= screenW) {
  // 是否需要切换为折叠交互组件
  if (needChangeToFoldWidget(i, offsetX, screenW, nowLine, context)) {
    if (canAddToFoldWidget(
        i, offsetX, screenW, context, foldWidgetIndex)) {
      // 绘制控件
      context.paintChild(i,
          transform: Matrix4.translationValues(offsetX, offsetY, 0));
      // 更改x坐标
      offsetX = offsetX + (context.getChildSize(i)?.width ?? 0) + spacing;
    }
    // 绘制折叠箭头组件
    context.paintChild(foldWidgetIndex,
        transform: Matrix4.translationValues(offsetX, offsetY, 0));
    // 更改x坐标
    offsetX = offsetX +
        (context.getChildSize(foldWidgetIndex)?.width ?? 0) +
        spacing;
    break;
  } else {
    // 绘制子控件
    context.paintChild(i,
        transform: Matrix4.translationValues(offsetX, offsetY, 0));
    // 更改x坐标
    offsetX = offsetX + (context.getChildSize(i)?.width ?? 0) + spacing;
  }
}
复制代码
bool needChangeToFoldWidget(int i, double offsetX, double screenW,
    int nowLine, FlowPaintingContext context) {
  if (!isFold) {
    return false;
  }
  if ((i + 1 < context.childCount - 1) &&
      ((offsetX +
          (context.getChildSize(i)?.width ?? 0) +
          spacing +
          (context.getChildSize(i + 1)?.width ?? 0)) >
          screenW)) {
    if ((isFold && ((nowLine + 1) > foldLine))) {
      return true;
    }
  }
  return false;
}

bool canAddToFoldWidget(int i, double offsetX, double screenW,
    FlowPaintingContext context, int lastIndex) {
  if ((offsetX +
      (context.getChildSize(i)?.width ?? 0) +
      spacing +
      (context.getChildSize(lastIndex)?.width ?? 0)) <=
      screenW) {
    return true;
  }
  return false;
}
复制代码

换行后也需要做一次这个判断流程,考虑某个组件的宽度直接占满了最大宽度的情况。

效果

自动换行可折叠展开的组件就完成啦。 iShot_2022-06-15_18.16.11.gif

按照一般情况,展开后就不收回了,假如要收回的话可以在展开后的末尾也绘制箭头组件上去。

猜你喜欢

转载自juejin.im/post/7109413914332364831