Flutter实现一个评分组件的几种思路
highlight: a11y-dark
最近在玩AI绘图,先来只大猫镇场子。
RatingBar 作为一个常见不常用的控件,你能想到几种方案去实现。
各个平台都有自己的rating组件,我们可以先看看flutter自带的什么样。
然而并没有。
这个图是用stable-diffusion-webui的text2img生成的,想画一只尴尬的朋克猫,大概理解有问题,它貌似也不尴尬。
先用最简的方式,自己组装一个
组合控件
用List生成一个固定的row列, 前四个是实心,最后一个是空心,构成了一个评分组件,当然这种目前只能纯显示。
``` ~ 省略代码 ~
@override Widget build(BuildContext context) { return Row( children: List.generate(5, (index) { if(index < 4){ return Icon( Icons.star, color: _color, size :34.0 ,
);
}else{
return Icon(
Icons.star_border,
color: _color,
size :34.0
);
}
}),
); }
~ 省略代码 ~
```
如果想给一个它加上交互,可以使用Listener 或 GestureDetector
GestureDetector 有丰富的触摸和点击手势,能满足大部分功能; Listener 是监听原始指针 ,其实GestureDetector的底层也是包装的Listener ,他们的区别就是Listener 在反馈原始指针的时候不会触发竞争机制。 而 GestureDetector 处理了竞争, 例如父控件和子控件都监听了 GestureDetector 的 onTap方法 , 点击子控件不会触发父控件的onTop, 反之点击父控件也不会触发子控件的onTap, 内部通过冒泡竞争来实现.
具体可以参考
我们就用GestureDetector来试试, 监听水平滑动触摸更新这个方法:
GestureDetector(
onHorizontalDragUpdate: (DragUpdateDetails details) {
double newPosition = details.localPosition.dx;
// 根据变化值修改数据
print('$newPosition');
}),
如何使用这个相对坐标的偏移量newPosition
假定我们的星星宽度固定,互相没有间距,newPosition / 宽度 可以理解为有多少星星可以被填充满, 因为我们的图标只有了系统内置的整个星和半个星图标, 可以四舍五入到0.5精度
void _updateRating(double newPosition) {
setState(() {
_rating = newPosition / 34.0;
if (_rating > 5.0) {
_rating = 5.0;
} else if (_rating < 0.0) {
_rating = 0.0;
}
// 将评分四舍五入到最近的 0.5 分
_rating = (_rating * 2).roundToDouble() / 2;
});
}
可以得到_rating 的评分值, 然后在控件中显示需要填充多少颗星
```
Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(5, (index) {
var currentIndex = index + 1;
if (currentIndex - 0.5 <= _rating && _rating < currentIndex) {
// 填充一半
return Icon(
Icons.star_half,
color: _color,
size :34.0 ,
);
} else if (currentIndex <= _rating) {
// 完全填充
return Icon(
Icons.star,
color: _color,
size :34.0 ,
);
} else {
// 完全不填充
return Icon(
Icons.star_border,
color: _color,
size :34.0 ,
);
}
}), ) ```
裁剪方式提高精度
上边的方案基于图片的原因,如果要提供分数精度到0.1,那岂不是得找一堆图片,0.1分的图片需要十分之一的星星被填充,太麻烦了,换个思路。
取五个都是空心星星的图片,固定放到底下, 上层放五个都是实心星星的图片,根据得到的分数,来实时裁剪掉上层图片的多余部分。 如果是0.3分,你需要裁剪掉第一个实心星星的70%部分, 剩下的四张图都裁掉。
这里用到了一个Widget : ClipRect,它直接继承于SingleChildRenderObjectWidget
const ClipRect({
super.key,
this.clipper,
this.clipBehavior = Clip.hardEdge,
super.child,
}) : assert(clipBehavior != null);
这是一个布局类组件 ,布局类组件就是指直接或间接继承(包含)SingleChildRenderObjectWidget 和 MultiChildRenderObjectWidget的Widget,它们一般都会有一个
child
或children
属性用于接收子 Widget。 我们看一下继承关系 Widget > RenderObjectWidget > (Leaf/SingleChild/MultiChild)RenderObjectWidget 。 RenderObjectWidget 类中定义了创建、更新 RenderObject 的方法,子类必须实现他们,关于RenderObject 我们现在只需要知道它是最终布局、渲染 UI 界面的对象即可,也就是说,对于布局类组件来说,其布局算法都是通过对应的 RenderObject 对象来实现的。
我们给定一个完全填充的星星,用ClipRect组件包裹下 ,设置下填充矩阵 ,构造方法里有个clipper参数,就是一个CustomClipper
```
class MyClipper extends CustomClipper
final double value;
MyClipper({required this.value});
@override Rect getClip(Size size) => Rect.fromLTWH(0, 0, value, size.height);
@override
bool shouldReclip(CustomClipper
ClipRect(
clipper: MyClipper(value: 32),
child: Icon(
Icons.star,
color: _color,
size: 64.0,
),
)
很容易看出,我们只给宽度裁剪了一半(0.5分), 就会呈现出这样的效果
如果是0.1分的星,就是这样了。
这样,我们就可以实现精确到任意分数的星星了。
完整代码我放到文章结尾。
自定义渲染
先回顾下基础:
Widget 按功能划分有三大类:
1 Component Widget ,组合类 Widget,这类 Widget 都直接或间接继承于
StatelessWidget
或StatefulWidget
,通过组合功能相对单一的 Widget 可以得到功能更为复杂的 Widget。平常的业务开发主要是在开发这一类型的 Widget2 Proxy Widget, 代理类 Widget ,本身并不涉及 Widget 内部逻辑,只是为「Child Widget」提供一些附加的中间功能。典型的如:
InheritedWidget
用于在「Descendant Widgets」间传递共享信息、ParentDataWidget
用于配置「Descendant Renderer Widget」的布局信息3 Renderer Widget,渲染类 Widget,会直接参与后面的「Layout」、「Paint」流程,无论是「Component Widget」还是「Proxy Widget」最终都会映射到「Renderer Widget」上,否则将无法被绘制到屏幕上。这 3 类 Widget 中,只有「Renderer Widget」有与之一一对应的「Render Object」。
Render-Widget 大致有三类:
- 作为『 Widget Tree 』的叶节点,也是最小的 UI 表达单元,一般继承自
LeafRenderObjectWidget
;- 有一个子节点 ( Single Child ),一般继承自
SingleChildRenderObjectWidget
;- 有多个子节点 ( Multi Child ),一般继承自
MultiChildRenderObjectWidget
。
自定义一个布局就是继承 RenderObjectWidget的过程, 绝大部分常用布局容器都直接或间接继承自RenderObject , 只有一个孩子布局(如Center,Padding)的继承 SingleChildRenderObjectWidget 复杂些的布局(Row ,Column等)继承MultiChildRenderObjectWidget
SingleChildRenderObjectWidget
我们先自己模仿一个Center,看看效果
``` class CustomCenter extends SingleChildRenderObjectWidget {
CustomCenter({Key? key, Widget? child}) : super(key: key, child: child);
@override RenderObject createRenderObject(BuildContext context) { return RenderCustomCenter(); } }
class RenderCustomCenter extends RenderShiftedBox { RenderCustomCenter({RenderBox? child}) : super(child);
@override void performLayout() { //1 先对子组件进行layout,随后获取它的size child?.layout(constraints.loosen(), //将约束传递给子节点 parentUsesSize: true //因为接下来要使用 child 的 size,所以不能为 false );
//2 根据子组件的大小确定自身的大小
size = constraints.constrain(Size(
constraints.maxWidth == double.infinity
? (child?.size.width ?? 0)
: double.infinity,
constraints.maxHeight == double.infinity
? (child?.size.height ?? 0)
: double.infinity));
//3 根据父节点大小,算出子节点在父节点中居中后的偏移
//4 然后将这个偏移保存在子节点的 parentData 中,在后续的绘制节点会用到
BoxParentData parentData = child?.parentData as BoxParentData;
parentData.offset = ((size - (child?.size ?? const Size(0, 0))) as Offset) / 2;
} } ```
调用一下
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('RatingBar'),
),
body: CustomCenter(
child: Container(color: Colors.red, width: 120,height: 120,),
), // This trailing comma makes auto-formatting nicer for build methods.
);
}
看看效果
单个孩子的容器布局其实就是performLayout的过程,也就是通过孩子大小位置来确定自己身大小的过程
MultiChildRenderObjectWidget
如果你的布局里希望有多个子Widget,可以继承下 MultiChildRenderObjectWidget 并实现createRenderObject()方法和updateRenderObject()方法来创建和更新RenderObject
具体实现一个布局,让两个子孩子上下摆放,其实就是一个简易的Column
``` class TopBottomLayout extends MultiChildRenderObjectWidget {
TopBottomLayout({Key? key, required List
@override RenderObject createRenderObject(BuildContext context) { return TopBottomRender(); } }
class TopBottomParentData extends ContainerBoxParentData
class TopBottomRender extends RenderBox
with
ContainerRenderObjectMixin
@override void performLayout() { //获取当前约束(从父组件传入的), final BoxConstraints constraints = this.constraints;
//获取第一个组件,和他父组件传的约束
RenderBox? topChild = firstChild;
TopBottomParentData childParentData =
topChild?.parentData as TopBottomParentData;
//获取下一个组件
//至于这里为什么可以获取到下一个组件,是因为在 多子组件的 mount 中,遍历创建所有的 child 然后将其插入到到 child 的 childParentData 中了
RenderBox? bottomChild = childParentData.nextSibling;
//限制下孩子高度不超过总高度的一半
bottomChild?.layout(
constraints.copyWith(maxHeight: constraints.maxHeight / 2),
parentUsesSize: true);
//设置下孩子的 offset
childParentData = bottomChild?.parentData as TopBottomParentData;
//位于最下边
childParentData.offset = Offset(0, constraints.maxHeight - (bottomChild?.size.height ?? 0));
//上孩子的 offset 默认为 (0,0),为了确保上孩子能始终显示,我们不修改他的 offset
topChild?.layout(
constraints.copyWith(
//上侧剩余的最大高度
maxHeight: constraints.maxHeight - (bottomChild?.size.height ?? 0)),
parentUsesSize: true);
//设置上下组件的 size
size = Size(
max((topChild?.size.width ?? 0), (bottomChild?.size.width ?? 0)),
constraints.maxHeight);
}
double max(double height, double height2) { if (height > height2) return height; else return height2; }
@override void paint(PaintingContext context, Offset offset) { defaultPaint(context, offset); }
@override
bool hitTestChildren(BoxHitTestResult result, {Offset? position}) {
return defaultHitTestChildren(result, position: position ?? Offset.zero);
}
}
调用及效果
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('RatingBar'),
),
body: TopBottomLayout(
list: [
Text('上边的'),
Text('下边的'),
],
),
);
}
```
评分组件如果希望完全自绘和布局也是这个思路,不过实现一个这种东西的话代码过于复杂,还是直接参考一些 现成的 吧 ,效果上和上边两种方式是一样的。
三种方式的代码和一样过程样例都放到了git上
推荐:雪峰的flutter博客