【 -Flutter自定义组件- 】Wrapper组件,包裹装饰你的一切

3,923 阅读6分钟

零、前言

最近需要一个气泡框的需求,用图片,或现在三方组件一点都不灵活,倒不如自己写一个,分享来给大家一起用用。


1.看一下Wrapper组件整体的效果

主要就是一个包裹物,对于尖端的控制提供许多灵活的属性
包以及发布到pub,欢迎使用wrapper

dependencies:
  wrapper: ^$lastVersion


2. 应用于弹出的菜单框

通过Overlay可以显示弹框浮层,一般都会有个尖角指示,用Wrapper包裹就会非常方便。

3.在聊天界面中的使用:

效果还算不错,也顺便为我的《Flutter之旅》庆下生。


一、基础使用

1. 颜色和尖角方向

spineType是四种类型的枚举,上图依次是: SpineType.left、SpineType.right、SpineType.top、SpineType.bottom

属性名类型默认值简介
colorColorColors.green框框颜色
spineTypeSpineTypeSpineType.left尖角边枚举
childWidgetnull子组件
Wrapper(
    color: Color(0xff95EC69),
    spineType: SpineType.left,
    child: Text("张风捷特烈 " * 5),
),

2. 针尖属性控制

通过针尖的开角和高度能实现对尖角更细致的控制
通过offset进行位移,考虑到有可能从尾向前偏移,使用formEnd控制,如下[图四]

属性名类型默认值简介
angledouble75针尖夹角
spineHeightdouble10尖角高度
offsetdouble15偏移量
formEndboolfalse是否从尾部偏移
Wrapper(
  color: Color(0xff95EC69),
  spineType: SpineType.bottom,
  spineHeight: 20,
  angle: 45,
  offset: 15,
  fromEnd: false,
  child: Text("张风捷特烈 " * 5),
)

3. 框阴影

注意: 只有当elevation不为空的时候才能有阴影

属性名类型默认值简介
elevationdoublenull影深
shadowColorColorColors.grey阴影颜色
Wrapper(
  color: Colors.white,
  spineType: SpineType.right,
  elevation: 1,
  shadowColor: Colors.grey.withAlpha(88),
  child: Text("张风捷特烈 " * 5),
)

4. 边线边距

注意: 当strokeWidth不为空时,会变为边线模式

属性名类型默认值简介
strokeWidthdoublenull边线宽
paddingEdgeInsetsEdgeInsets.all(5)内边距
Wrapper(
  formEnd: true,
  padding: EdgeInsets.all(10),
  color: Colors.yellow,
  offset: 60,
  strokeWidth: 2,
  spineType: SpineType.bottom,
  child: Text("张风捷特烈 " * 5),
)

5. Wrapper.just

提供无针尖的构造方法,实现类似包裹的效果,可以包裹任意组件。

Wrapper.just(
  padding: EdgeInsets.all(2),
  color: Color(0xff5A9DFF),
  child: Text(
    "Lv3",
    style: TextStyle(color: Colors.white),
  ),
)

6. 尖端路径构造器

为了让组件更灵活,我将尖端路径的构造提取出来,暴露接口,并提供默认路径
这样就可以自己定制尖端图形,提高拓展性。路径构造器,返回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));
}

7.属性一览

注意一点: Wrapper的区域是由父容器控制的,Wrapper本身并不承担定尺寸职责。

属性名类型默认值简介
colorColorColors.green框框颜色
spineTypeSpineTypeSpineType.left尖角边枚举
childWidgetnull子组件
angledouble75针尖夹角
spineHeightdouble10尖角高度
offsetdouble15偏移量
formEndboolfalse是否从尾部偏移
elevationdoublenull影深
shadowColorColorColors.grey阴影颜色
strokeWidthdoublenull边线宽
paddingEdgeInsetsEdgeInsets.all(5)内边距
radiusdouble5圆角半径
spinePathBuilderSpinePathBuildernull尖端路径构造器

二、Wrapper在聊天界面中的使用

1. 实现思路

首先应该有一组数据,根据数据的类型觉得是左侧框,还是右侧框
这里简单演示一下,左侧是第偶数条数据,右侧是第奇数条数据
item的实现透过Row+Flexible进行布局控制,也正是因为Wrapper是填充父组件区域
这样就能实现一行短文字包裹住,当文字多行时,自动延伸。


2.具体代码实现
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)
        ],
      ),
    );
  }
}

三、Wrapper源码核心实现

1.定义属性

根据需求,进行属性定义

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

2.build方法使用画板

不同类型的尖端,由于高度会让边距出现问题,可以在内部处理一下,以方便外界的使用,这里自定义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),
    );
  }

3.WrapperPainter中的绘制

绘制主要分为两大块,一是外框盒子,二是尖端。由于尖端的存在,盒子需要根据类型进行处理。

  • 核心逻辑
@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 @张风捷特烈 未允禁转