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博客