likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 14

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

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

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

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

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

然而并没有。

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
        );
      }
    }),
  );
}

~ 省略代码 ~

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

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

      );
    }
  }),
)

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

裁剪方式提高精度

上边的方案基于图片的原因,如果要提供分数精度到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<Rect> {
    
  final double value;

  MyClipper({required this.value});
  
  @override
  Rect getClip(Size size) => Rect.fromLTWH(0, 0, value, size.height);

  @override
  bool shouldReclip(CustomClipper<Rect> oldClipper) => false;
}
ClipRect(
  clipper: MyClipper(value: 32),
  child: Icon(
    Icons.star,
    color: _color,
    size: 64.0,
  ),
)

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

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

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

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

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

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

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

自定义渲染

先回顾下基础:

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.
  );
}

看看效果

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

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

MultiChildRenderObjectWidget

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

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

class TopBottomLayout extends MultiChildRenderObjectWidget {

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

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

class TopBottomParentData extends ContainerBoxParentData<RenderBox> {}

class TopBottomRender extends RenderBox
    with
        ContainerRenderObjectMixin<RenderBox, TopBottomParentData>,
        RenderBoxContainerDefaultsMixin<RenderBox, TopBottomParentData> {
  /// 初始化每一个 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('下边的'),
      ],
    ), 
  );
}

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

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

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

推荐:雪峰的flutter博客