Flutter实现一个评分组件的几种思路

语言: CN / TW / HK

highlight: a11y-dark

00007-752900067.png

最近在玩AI绘图,先来只大猫镇场子。

RatingBar 作为一个常见不常用的控件,你能想到几种方案去实现。

下载.jpeg

各个平台都有自己的rating组件,我们可以先看看flutter自带的什么样。

然而并没有。

00011-752900071.png

这个图是用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
    );
  }
}),

); }

~ 省略代码 ~

```

19D42312E3EA4973AAFC4D09F038AFD5.png

如果想给一个它加上交互,可以使用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 ,

  );
}

}), ) ```

111.gif


裁剪方式提高精度

上边的方案基于图片的原因,如果要提供分数精度到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,它们一般都会有一个childchildren属性用于接收子 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 oldClipper) => false; } ```

ClipRect( clipper: MyClipper(value: 32), child: Icon( Icons.star, color: _color, size: 64.0, ), )

很容易看出,我们只给宽度裁剪了一半(0.5分), 就会呈现出这样的效果

22.png

如果是0.1分的星,就是这样了。

333.png

这样,我们就可以实现精确到任意分数的星星了。

44.gif

完整代码我放到文章结尾。


自定义渲染

先回顾下基础:

Widget 按功能划分有三大类:

1 Component Widget ,组合类 Widget,这类 Widget 都直接或间接继承于StatelessWidgetStatefulWidget,通过组合功能相对单一的 Widget 可以得到功能更为复杂的 Widget。平常的业务开发主要是在开发这一类型的 Widget

2 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. ); } 看看效果

11.png

单个孩子的容器布局其实就是performLayout的过程,也就是通过孩子大小位置来确定自己身大小的过程


MultiChildRenderObjectWidget

如果你的布局里希望有多个子Widget,可以继承下 MultiChildRenderObjectWidget 并实现createRenderObject()方法和updateRenderObject()方法来创建和更新RenderObject

具体实现一个布局,让两个子孩子上下摆放,其实就是一个简易的Column

``` class TopBottomLayout extends MultiChildRenderObjectWidget {

TopBottomLayout({Key? key, required List list}) : assert(list.length == 2, "只能传两个 child"), super(key: key, children: list);

@override RenderObject createRenderObject(BuildContext context) { return TopBottomRender(); } }

class TopBottomParentData extends ContainerBoxParentData {}

class TopBottomRender extends RenderBox with ContainerRenderObjectMixin, RenderBoxContainerDefaultsMixin { /// 初始化每一个 child 的 parentData @override void setupParentData(RenderBox child) { if (child.parentData is! TopBottomParentData) child.parentData = TopBottomParentData(); }

@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('下边的'), ], ), ); } ```

123.png

评分组件如果希望完全自绘和布局也是这个思路,不过实现一个这种东西的话代码过于复杂,还是直接参考一些 现成的 吧 ,效果上和上边两种方式是一样的。

三种方式的代码和一样过程样例都放到了git

推荐:雪峰的flutter博客