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