持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第9天,点击查看活动详情
背景
在搜索页中,历史搜索的列表,内容除了会自动换行之外,超过多少行还会自动折叠,点击箭头展开。
分析
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,
),
],
),
)
复制代码
但仅支持自动换行,不满足折叠展开,插入展开按钮的功能。因为每个内容的宽度是不定的,不可能自己计算哪个item之后超过限制的行数。
既然Wrap
不满足,那什么组件可以实现需要的功能呢?类似的组件在官方介绍中还有Flow
,不常用,因为他需要自己编写Delegate
,规定每个Item的排布。
Flow
Flow,可以更加自由的将item排布。它有两个必须的参数:delegate
和children
。
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
可以拿到child
的Size
,这个比较重要,我们绘制自动换行的计算主要依靠这个。
paintChild
则是绘制child
。
绘制自动换行
简单分析下如何自动换行,首先我们可以拿到总的child的数量,所以可以遍历每个child的坐标index。我们还可以拿到容器的大小,即知道了最大宽度。
根据getChildSize
我们可以拿到child的Size,定义一个起始值offset
,遍历到哪个child的时候,判断一下加上这个child宽度以及space(child之间的间距) 是否会超出最大宽度,
· 不会的话,绘制该child在这个地方,并且offset加上child宽度和space;
· 会的话,offset归零,换至下一行,并把这个child作为下一行的首个Item,offset加上这个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,
),
),
)
复制代码
会发现很奇怪,每个child的宽度都占满了。经过调试,发现他需要设定每个child的Constraints
,即重写getConstraintsForChild
@override
BoxConstraints getConstraintsForChild(int i, BoxConstraints constraints) {
return BoxConstraints(
maxWidth: constraints.maxWidth,
minWidth: 0,
maxHeight: extentHeight,
minHeight: 0);
}
复制代码
min的两个需要改为0,maxHeight为我们限制的高度,不然都默认是无限大。
加入限制后就正常了
容器高度自适应
假如尝试把它放入一个Column中,会发现高度溢出
因为他的容器高度不定义的话默认也是无限大的。类似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;
});
});
}
),
)
复制代码
最终可以自适应高度了。
折叠
要折叠的话,首先我们要知道从哪行开始折叠,以及当前是否折叠,定义两个参数
final int foldLine;
final bool isFold;
复制代码
换行后的判断修改一下
// 如果超过限制行数,则结束循环,不再绘制
if ((isFold && ((nowLine + 1) > foldLine))) {
break;
} else {
nowLine++;
...
}
复制代码
箭头按钮
折叠之后,当然要告诉用户已经折叠了,所以要在折叠的尾部插入一个小箭头标志,点击还可以展开。
因为不能绘制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;
}
复制代码
换行后也需要做一次这个判断流程,考虑某个组件的宽度直接占满了最大宽度的情况。
效果
自动换行可折叠展开的组件就完成啦。
按照一般情况,展开后就不收回了,假如要收回的话可以在展开后的末尾也绘制箭头组件上去。