【Flutter 绘制探索】进度与裁剪 - CustomClipper 的使用

2,092 阅读4分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第 1 天,点击查看活动详情


0. 前言

在上传文件时,为了缓解等待的焦虑,一般希望显示上传的 进度,来给用户任务进度的 反馈。在上传图片时,经常见到给出一个透明遮罩,随着进度的增加,遮罩逐渐减少的进度表现形式。本文就来看一下这种表现的实现方式:


1. 实现思路

整体分为三层,底部的图片层、中间的透明遮罩层、上面的文字层。其中透明遮罩会根据进度,以中心为原点,顺时针扫描式地减少。这个效果可以通过 裁剪 完成,如下 35% 时,相当于把右上角裁掉,保留余下的阴影。所以关键点是: 计算余下阴影的路径

如下示意图,根据红色是图片矩形区域的路径;蓝色实线是外接圆上的弧线,弧度值根据进度确定。根据这两个路径进行 xor 的组合,就可以得到阴影路径:

image.png

如下,定义 CustomClipper<Path> 的派生类 ProgressClipper , 在构造时传入进度值。实现 getClip 抽象方法返回 Path 路径对象。裁剪器会根据这个路径进行裁剪,该路径之外的部分会被裁掉。shouldReclip 方法和绘制中的的 shouldRepaint 异曲同工,在 ProgressClipper 对象变化时,控制是否触发 getClip 重新裁剪。

class ProgressClipper extends CustomClipper<Path> {
  final double progress;

  ProgressClipper({this.progress=0});

  @override
  Path getClip(Size size) {
    if(progress==0){
      return Path();
    }
    // 红色区域
    Path zone = Path()..addRect(Rect.fromLTRB(0, 0, size.width, size.height));
    // 蓝色弧线
    double outRadius = sqrt(size.width/2*size.width/2 + size.height/2*size.height/2);
    Path path = Path()
      ..moveTo(size.width / 2, size.height / 2)
      ..relativeLineTo(0, -size.height / 2)
      ..arcTo(
          Rect.fromCenter(
              center: Offset(size.width / 2, size.height / 2),
              width: outRadius ,
              height: outRadius),
          -pi / 2,
          2 * pi * progress,
          false);
    return Path.combine(PathOperation.xor, path, zone);
  }

  @override
  bool shouldReclip(covariant ProgressClipper oldClipper) {
    return progress != oldClipper.progress;
  }
}

2. 裁剪器的使用

使用 ClipPath 组件,设置 clipper 参数,其类型为 CustomClipper<Path> ,可对 child 组件进行裁剪,如下是使用 ProgressClipper 裁剪器,进度 0.35 时的效果:

ClipPath(
  clipper: ProgressClipper(progress: 0.35),
  child: Container(
    width: 150,
    height: 150,
    color: Colors.black.withOpacity(0.7),
  ),
),

然后通过 Stack 组件,将 Image 放在遮罩的下层,文字放在上层,效果如下:

Stack(
  alignment: Alignment.center,
  children: [
    buildImage(),
    if (value != 0) buildMask(0.35),
    if (value != 0) buildText(0.35)
  ],
);

Widget buildImage()=> Image.asset(
    'assets/bg_5.jpg',
    width: 150,
    height: 150,
    fit: BoxFit.cover,
  );
  
Widget buildMask(double value)=> ClipPath(
    clipper: ProgressClipper(progress: value),
    child: Container(
      width: 150,
      height: 150,
      color: Colors.black.withOpacity(0.7),
    ),
  );
  
Widget buildText(double value)=> Text(
    "${(uploadProgress.value * 100).toInt()} %",
    style: TextStyle(color: Color(0xffEDFBFF), fontSize: 24),
  );

3. 进度的变化

然后只要更改进度值,即可完成需求,这里通过 Timer 定时器来模拟进度的变化,每 500 ms 增加 0.05 进度。代码如下所示:

void startTimer() {
  if (_timer != null) {
    _timer!.cancel();
    _timer = null;
  }
  _timer = Timer.periodic(const Duration(milliseconds: 500), _updateProgress);
}

void _updateProgress(Timer timer){
  uploadProgress.value += 0.05;
  if (uploadProgress.value >= 1) {
    uploadProgress.value = 0;
    _timer?.cancel();
    _timer = null;
  }
}

另外,通过 ValueListenableBuilder 来监听 uploadProgress 进度变化。计时器每次触发回调时,增加 uploadProgress.value 值即可触发局部构建。这样即可得到如下效果:

ValueNotifier<double> uploadProgress = ValueNotifier<double>(0);

---->[build]----
ValueListenableBuilder(
    valueListenable: uploadProgress,
    builder: (_, double value, child) {
      return
      Stack(
        alignment: Alignment.center,
        children: [
          buildImage(),
          if (value != 0) buildMask(value),
          if (value != 0) buildText(value)
        ],
      );
    })),


在实际上传时,可以使用 Diopost 请求,通过 onSendProgress 可以监听到上传的进度,在其中更新进度值即可。

dio.post(
  url,
  data: formData,
  onSendProgress: _sendProgressChange,
)

void _sendProgressChange(int count, int total) {
  uploadProgress.value = count / total;
}

4. 裁剪方式的拓展

裁剪的表现本质上是路径,所以通过提供不同的路径可以实现不同的效果。如下是随进度增加,阴影区域圆形缩减的效果:

18.gif

该效果通过下面的 CircleProgressClipper 裁剪器实现。逻辑非常简单,进度不断增大,半径逐渐减小,通过 outSide 乘以 1-progress 即可:

class CircleProgressClipper extends CustomClipper<Path> {
  final double progress;

  CircleProgressClipper({this.progress=0});

  @override
  Path getClip(Size size) {
    if(progress==0){
      return Path();
    }
    double outSide = sqrt(size.width*size.width+size.height*size.height);
    Rect rect = Rect.fromCenter(
        center: Offset(size.width / 2, size.height / 2),
        width: outSide*(1-progress) ,
        height: outSide*(1-progress) );

    Path path = Path()..addOval(rect);
    return path;
  }

  @override
  bool shouldReclip(covariant CircleProgressClipper oldClipper) {
    return progress != oldClipper.progress;
  }
}

还可以让遮罩以矩形的方式逐渐缩减,如下图所示:

19.gif

在创建矩形区域时,左下角的纵坐标值取 size.height*(1-progress) 即可。另外,阴影从 左到右右到左上到下 的变化都是类似的,有相关需求的话自己改改即可,当然也可以通过一个枚举类作为参数来控制表现效果。

class RectProgressClipper extends CustomClipper<Path> {
  final double progress;

  RectProgressClipper({this.progress=0});

  @override
  Path getClip(Size size) {
    if(progress==0){
      return Path();
    }
    Rect rect = Rect.fromPoints(
      Offset.zero,
      Offset(size.width,size.height*(1-progress)),
    );

    Path path = Path()..addRect(rect);
    return path;
  }

  @override
  bool shouldReclip(covariant RectProgressClipper oldClipper) {
    return progress != oldClipper.progress;
  }
}

本文主要通过图片上传的进度表现,介绍了 CustomClipper 裁剪器的派生和使用,希望可以为你的图片上传有所帮助。那本文就到这,谢谢观看 ~

  • @张风捷特烈 2022.09.30 未允禁转
  • 我的 公众号: 编程之王