最近需要一个气泡框的需求,用图片,或现在三方组件一点都不灵活,倒不如自己写一个,分享来给大家一起用用。
主要就是一个包裹物,对于尖端的控制提供许多灵活的属性 包以及发布到pub,欢迎使用wrapper
dependencies:
wrapper: ^$lastVersion
通过Overlay可以显示弹框浮层,一般都会有个尖角指示,用Wrapper包裹就会非常方便。
效果还算不错,也顺便为我的《Flutter之旅》庆下生。
spineType是四种类型的枚举,上图依次是:
SpineType.left、SpineType.right、SpineType.top、SpineType.bottom
属性名 | 类型 | 默认值 | 简介 |
---|---|---|---|
color | Color | Colors.green | 框框颜色 |
spineType | SpineType | SpineType.left | 尖角边枚举 |
child | Widget | null | 子组件 |
Wrapper(
color: Color(0xff95EC69),
spineType: SpineType.left,
child: Text("张风捷特烈 " * 5),
),
通过针尖的开角和高度能实现对尖角更细致的控制 通过offset进行位移,考虑到有可能从尾向前偏移,使用formEnd控制,如下[图四]
属性名 | 类型 | 默认值 | 简介 |
---|---|---|---|
angle | double | 75 | 针尖夹角 |
spineHeight | double | 10 | 尖角高度 |
offset | double | 15 | 偏移量 |
formEnd | bool | false | 是否从尾部偏移 |
Wrapper(
color: Color(0xff95EC69),
spineType: SpineType.bottom,
spineHeight: 20,
angle: 45,
offset: 15,
fromEnd: false,
child: Text("张风捷特烈 " * 5),
)
注意: 只有当elevation不为空的时候才能有阴影
属性名 | 类型 | 默认值 | 简介 |
---|---|---|---|
elevation | double | null | 影深 |
shadowColor | Color | Colors.grey | 阴影颜色 |
Wrapper(
color: Colors.white,
spineType: SpineType.right,
elevation: 1,
shadowColor: Colors.grey.withAlpha(88),
child: Text("张风捷特烈 " * 5),
)
注意: 当strokeWidth不为空时,会变为边线模式
属性名 | 类型 | 默认值 | 简介 |
---|---|---|---|
strokeWidth | double | null | 边线宽 |
padding | EdgeInsets | EdgeInsets.all(5) | 内边距 |
Wrapper(
formEnd: true,
padding: EdgeInsets.all(10),
color: Colors.yellow,
offset: 60,
strokeWidth: 2,
spineType: SpineType.bottom,
child: Text("张风捷特烈 " * 5),
)
Wrapper.just
提供无针尖的构造方法,实现类似包裹的效果,可以包裹任意组件。
Wrapper.just(
padding: EdgeInsets.all(2),
color: Color(0xff5A9DFF),
child: Text(
"Lv3",
style: TextStyle(color: Colors.white),
),
)
为了让组件更灵活,我将尖端路径的构造提取出来,暴露接口,并提供默认路径 这样就可以自己定制尖端图形,提高拓展性。路径构造器,返回Path对象,回调尖端所在的矩形区域range,类型spineType,还回调了Canvas以供绘制。
Wrapper(
spinePathBuilder: _spinePathBuilder,
strokeWidth: 1.5,
color: Color(0xff95EC69),
spineType: SpineType.bottom,
child: Text("张风捷特烈 " * 5)
),
Path _spinePathBuilder2(Canvas canvas, SpineType spineType, Rect range) {
return Path()
..addOval(Rect.fromCenter(center: range.center, width: 10, height: 10));
}
注意一点: Wrapper的区域是由父容器控制的,Wrapper本身并不承担定尺寸职责。
属性名 | 类型 | 默认值 | 简介 |
---|---|---|---|
color | Color | Colors.green | 框框颜色 |
spineType | SpineType | SpineType.left | 尖角边枚举 |
child | Widget | null | 子组件 |
angle | double | 75 | 针尖夹角 |
spineHeight | double | 10 | 尖角高度 |
offset | double | 15 | 偏移量 |
formEnd | bool | false | 是否从尾部偏移 |
elevation | double | null | 影深 |
shadowColor | Color | Colors.grey | 阴影颜色 |
strokeWidth | double | null | 边线宽 |
padding | EdgeInsets | EdgeInsets.all(5) | 内边距 |
radius | double | 5 | 圆角半径 |
spinePathBuilder | SpinePathBuilder | null | 尖端路径构造器 |
首先应该有一组数据,根据数据的类型觉得是左侧框,还是右侧框 这里简单演示一下,左侧是第偶数条数据,右侧是第奇数条数据 item的实现透过Row+Flexible进行布局控制,也正是因为Wrapper是填充父组件区域 这样就能实现一行短文字包裹住,当文字多行时,自动延伸。
class ChatList extends StatelessWidget {
//数据
final data = [
"经过十月怀胎,我的Flutter书总算出版了,是全彩色版的呢。",
"编程书还搞彩色的,大佬就是有逼格,叫什么名字,我去捧捧场。",
"书名是《Flutter之旅》,内容是偏向刚接触Flutter的小白,并没有讲的太深,像你这样的Lever,可能不是很需要。",
"你想多了,我只是想买本书垫桌脚",
"还有,书里的源码,你可以在FlutterUnit的GitHub主页看到下载链接。",
"好的,话说FlutterUnit最近发展进度如何?",
"FlutterUnit的绘制集录正在着手,不要心急。",
];
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: ListView.builder(
itemCount: data.length,
itemBuilder: (_, index) => index.isEven ? buildLeft(index) : buildRight(index),
),
);
}
//左侧item组件
Widget buildLeft(int index) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(right: 10),
child: Image.asset( "assets/images/icon_head.png", width: 50 ),
),
Flexible(
child: Padding(
padding: const EdgeInsets.only(top:4.0),
child: Wrapper(
elevation: 1,
shadowColor: Colors.grey.withAlpha(88),
offset: 8, color: Color(0xff95EC69), child: Text(data[index])),
)),
SizedBox(width: 50)
],
),
);
}
//右测item组件
Widget buildRight(int index) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
textDirection: TextDirection.rtl,
children: [
Padding(
padding: const EdgeInsets.only(left: 10),
child: Image.asset( "assets/images/icon_7.webp", width: 5 ),
),
Flexible(
child: Wrapper(
spineType: SpineType.right,
elevation: 1,
shadowColor: Colors.grey.withAlpha(88),
offset: 8, color: Colors.white, child: Text(data[index]))),
SizedBox(width: 50)
],
),
);
}
}
复制代码
根据需求,进行属性定义
typedef SpinePathBuilder = Path Function(
Canvas canvas, SpineType spineType, Rect range);
class Wrapper extends StatelessWidget {
final double spineHeight;
final double angle;
final double radius;
final double offset;
final SpineType spineType;
final Color color;
final Widget child;
final SpinePathBuilder spinePathBuilder;
final double strokeWidth;
final bool formEnd;
final EdgeInsets padding;
final double elevation;
final Color shadowColor;
Wrapper(
{this.spineHeight = 8.0,
this.angle = 75,
this.radius = 5.0,
this.offset = 15,
this.strokeWidth,
this.child,
this.elevation,
this.shadowColor = Colors.grey,
this.formEnd = false,
this.color = Colors.green,
this.spinePathBuilder,
this.padding = const EdgeInsets.all(8),
this.spineType = SpineType.left});
复制代码
不同类型的尖端,由于高度会让边距出现问题,可以在内部处理一下,以方便外界的使用,这里自定义WrapperPainter,将绘制需要的所有属性全部传入。
@override
Widget build(BuildContext context) {
var _padding = padding;
switch (spineType) {
case SpineType.top:
_padding = padding + EdgeInsets.only(top: spineHeight);
break;
case SpineType.left:
_padding = padding + EdgeInsets.only(left: spineHeight);
break;
case SpineType.right:
_padding = padding + EdgeInsets.only(right: spineHeight);
break;
case SpineType.bottom:
_padding = padding + EdgeInsets.only(bottom: spineHeight);
break;
}
return CustomPaint(
child: Padding(
padding: _padding,
child: child,
),
painter: WrapperPainter(
spineHeight: spineHeight,
angle: angle,
radius: radius,
offset: offset,
strokeWidth: strokeWidth,
color: color,
shadowColor: shadowColor,
elevation: elevation,
spineType: spineType,
formBottom: formEnd,
spinePathBuilder: spinePathBuilder),
);
}
绘制主要分为两大块,一是外框盒子,二是尖端。由于尖端的存在,盒子需要根据类型进行处理。
核心逻辑
@override
void paint(Canvas canvas, Size size) {
// 绘制盒子
path = buildBoxBySpineType(
canvas,
spineType,
size.width,
size.height,
);
// spinePathBuilder为null,使用buildDefaultSpinePath
// 否则通过spinePathBuilder进行构造spinePath,比较复杂一丢丢的是区域的回调
Path spinePath;
if (spinePathBuilder == null) {
spinePath = buildDefaultSpinePath(canvas, spineHeight, spineType, size);
} else {
Rect range ;
switch(spineType){
case SpineType.top:
range = Rect.fromLTRB(0, -spineHeight, size.width, 0);
break;
case SpineType.left:
range = Rect.fromLTRB(-spineHeight, 0, 0, size.height);
break;
case SpineType.right:
range = Rect.fromLTRB(-spineHeight, 0, 0, size.height).translate(size.width, 0);
break;
case SpineType.bottom:
range = Rect.fromLTRB(0, 0, size.width, spineHeight).translate(0, size.height-spineHeight);
break;
}
spinePath = spinePathBuilder(canvas, spineType, range);
}
// 如果spinePath不为null,将两个路径结合,
// 如果elevation存在,则绘制阴影
if (spinePath != null) {
path = Path.combine(PathOperation.union, spinePath, path);
if (elevation != null) {
canvas.drawShadow(path, shadowColor, elevation, true);
}
canvas.drawPath(path, mPaint);
}
}
绘制盒子
Path buildBoxBySpineType(
Canvas canvas,
SpineType spineType,
double width,
double height,
) {
double lineHeight, lineWidth;
switch (spineType) {
case SpineType.top:
lineHeight = height - spineHeight;
canvas.translate(0, spineHeight);
lineWidth = width;
break;
case SpineType.left:
lineWidth = width - spineHeight;
lineHeight = height;
canvas.translate(spineHeight, 0);
break;
case SpineType.right:
lineWidth = width - spineHeight;
lineHeight = height;
break;
case SpineType.bottom:
lineHeight = height - spineHeight;
lineWidth = width;
break;
}
Rect box = Rect.fromCenter(
center: Offset(lineWidth / 2, lineHeight / 2),
width: lineWidth,
height: lineHeight);
return Path()..addRRect(RRect.fromRectXY(box, radius, radius));
}
绘制默认的线条
buildDefaultSpinePath(
Canvas canvas, double spineHeight, SpineType spineType, Size size) {
switch (spineType) {
case SpineType.top: return _drawTop(size.width, size.height, canvas);
case SpineType.left:
return _drawLeft(size.width, size.height, canvas);
case SpineType.right:
return _drawRight(size.width, size.height, canvas);
case SpineType.bottom:
return _drawBottom(size.width, size.height, canvas);
}
}
Path _drawTop(double width, double height, Canvas canvas) {
var angleRad = pi / 180 * angle;
var spineMoveX = spineHeight * tan(angleRad / 2);
var spineMoveY = spineHeight;
if (spineHeight != 0) {
return Path()
..moveTo(!formBottom ? offset : width - offset - spineHeight, 0)
..relativeLineTo(spineMoveX, -spineMoveY)
..relativeLineTo(spineMoveX, spineMoveY);
}
return Path();
}
Path _drawBottom(double width, double height, Canvas canvas) {
var lineHeight = height - spineHeight;
var angleRad = pi / 180 * angle;
var spineMoveX = spineHeight * tan(angleRad / 2);
var spineMoveY = spineHeight;
if (spineHeight != 0) {
return Path()
..moveTo(
!formBottom ? offset : width - offset - spineHeight, lineHeight)
..relativeLineTo(spineMoveX, spineMoveY)
..relativeLineTo(spineMoveX, -spineMoveY);
}
return Path();
}
Path _drawLeft(double width, double height, Canvas canvas) {
var angleRad = pi / 180 * angle;
var spineMoveX = spineHeight;
var spineMoveY = spineHeight * tan(angleRad / 2);
if (spineHeight != 0) {
return Path()
..moveTo(0, !formBottom ? offset : height - offset - spineHeight)
..relativeLineTo(-spineMoveX, spineMoveY)
..relativeLineTo(spineMoveX, spineMoveY);
}
return Path();
}
Path _drawRight(double width, double height, Canvas canvas) {
var lineWidth = width - spineHeight;
var angleRad = pi / 180 * angle;
var spineMoveX = spineHeight;
var spineMoveY = spineHeight * tan(angleRad / 2);
if (spineHeight != 0) {
return Path()
..moveTo(lineWidth, !formBottom ? offset : height - offset - spineHeight)
..relativeLineTo(spineMoveX, spineMoveY)
..relativeLineTo(-spineMoveX, spineMoveY);
}
return Path();
}
本篇就到这里, 感谢大家关注FlutterUnit的发展~ , github地址: Star一下
End 2020-09-20 @张风捷特烈 未允禁转