Flutter刮刮乐仿滑动互动广告

灵感来源

仿照在刷科目一时的软件广告,觉得很有意思,故而仿之; 滑动到一定范围后,全部清除,并且弹窗(这里gif没有录制到) 项目地址杉木笙/ads_drawing (gitee.com)

实现效果

思路分析

一开始要实现这个效果,初步分析是Stack中CustomPaint和图片还有阴影叠在一起;

但是这样下来距离目标相差甚远,仔细想了一下问题出在哪里

  • 灰色半透明遮罩从何而来
  • 会采用绘制吗,应该绘制什么
  • 如果采用绘制,怎么判断绘制到一定程度来实现弹窗或其他效果?

思路实现

这个章节里的代码即为最终实现代码.一点一线皆是河山

在这里先放下几个问题,在目录问题解决中解决

  1. NewPoint类的对比问题
  2. 绘制是Rect,为了美观怎么转换成RRect
  3. 点太过密集,导致卡顿

如何绘制

因为绘制的目标不是单纯的纯色,而是图片;所以这里要用到绘制方法采用drawImageRect.

drawImageRect可以理解为把原图的一部分(srcRect)填充到绘制的地方(dstRect)中.

arduino 复制代码
canvas.drawImageRect(image!, srcRect, dstRect, paint);
//image是图片源
//srcRect是来自图片的区域
//dstRect是实际画的位置
//paint是画笔

于是有了下面这个例子

暂且忽视point.toOffset()在这里的作用只是一个坐标点,比如Offset(10,10)

arduino 复制代码
final srcRect = Rect.fromLTWH(  
  point.toOffset().dx * 2,  
  point.toOffset().dy * 2,  
  20,  
  20,  
);  
final dstRect = Rect.fromLTWH(  
  point.toOffset().dx,  
  point.toOffset().dy,  
  10,  
  10,  
);   
canvas.drawImageRect(image!, srcRect, dstRect, paint);

用这个方法,就可以画出2:1的图片 此时上面的方法还不能画出下面这个效果,上面只是一个区块,下图则是很多个点拼在一起的

这里有个技巧

srcRect与dstRect的比例就是绘制出来的比例,或者也可以看成缩放,2:1相当于缩放了一倍

一点一线

根据上面已经可以实现绘画出图片的一部分,也可以叫做一个点. 下面则是从一点到一线之间的变化,下面的代码会用到状态管理插件官网 provider | Flutter package (pub.dev) 从一点到一线这里采用的是GestureDetector来获取手势位移配合绘制,来绘制图片

一点

在这里才是正式开始代码的编写,从理论到实践 新建一个dart文件,在文件中创建类NewPoint 在这里直接一步到底(最后实现的代码),不过在后面会逐步讲到为什么会用到某些地方

dart 复制代码
enum NewPaintState { doing, none }

class NewPoint extends Equatable {
  final int x;
  final int y;
  NewPoint({required this.x, required this.y});
  Offset toOffset() => Offset(x.toDouble(), y.toDouble());

  double get distance => sqrt(x * x + y * y);

  factory NewPoint.fromOffset(Offset offset) {
    return NewPoint(
      x: offset.dx.toInt(),
      y: offset.dy.toInt(),
    );
  }
  NewPoint operator -(NewPoint other) =>
      NewPoint(x: x - other.x, y: y - other.y);

  @override
  // TODO: implement props
  List<Object?> get props => [x, y];
}

一线

创建完点后,再接着创建线 在这里也是直接一步到底(最后实现的代码),不过在后面会逐步讲到为什么会用到某些地方

ini 复制代码
class NewLine {
  List<NewPoint> points = [];
  NewPaintState state;
  double strokeWidth;
  Color color;
  ui.Image? image;
  NewLine({
    this.color = Colors.lightBlue,
    this.strokeWidth = 1,
    this.state = NewPaintState.doing,
    this.image,
  });
  void paint(Canvas canvas, Paint paint) {
    if (image != null) {
      for (var point in points) {
        canvas.save();
        final srcRect = Rect.fromLTWH(
          point.toOffset().dx * 4,
          point.toOffset().dy * 4,
          40,
          40,
        );
        final dstRect = Rect.fromLTWH(
          point.toOffset().dx,
          point.toOffset().dy,
          10,
          10,
        );
        // 定义圆角矩形区域
        final rrect = RRect.fromRectAndRadius(
          dstRect,
          Radius.circular(5),
        );
        // canvas.drawImage(image!, point.toOffset(), paint);
        canvas.clipRRect(rrect);
        canvas.drawImageRect(image!, srcRect, dstRect, paint);
        canvas.restore();
      }
    }
  }
}

一点一线皆是河山

现在有了点和线后,就可以更好的去实现绘制,这里同样给出最终版,并且开始讲解用处

ini 复制代码
class NewAdsProvider extends ChangeNotifier {
  // List<NewLine> _lines = [];//废弃
  // List<NewLine> get lines => _lines;//废弃
  // int allPointsLength = 0;//废弃
  // List<NewPoint> allNewPoints = [];//废弃
  final double tolerance = 5.0;
  Map<String, bool> ignorings = {};
  Map<String, int> allPointsLengths = {};
  Map<String, bool> bgShadows = {};
  Map<String, List<NewLine>> _linesMap = {}; //
  Map<String, ui.Image> _images = {};
  List<NewLine> getLines(String key) => _linesMap[key] ?? [];
  ui.Image? getImage(String key) => _images[key];
  bool getBgShadows(String key) => bgShadows[key] ?? true;
  bool getIgnoring(String key) => ignorings[key] ?? false;
  int getAllPointsLength(String key) => allPointsLengths[key] ?? 0;

  ///获取目前的线
  NewLine getActiveLine(String key) =>
      _linesMap[key]?.singleWhere(
          (element) => element.state == NewPaintState.doing,
          orElse: () => NewLine(state: NewPaintState.none)) ??
      NewLine(state: NewPaintState.none);

  ///添加点
  void addPoint(String key, NewPoint point, {bool force = false}) {
    if (getActiveLine(key).points.isNotEmpty && !force) {
      if ((point - getActiveLine(key).points.last).distance < tolerance) return;
    }
    getActiveLine(key).points.add(point);
    allPointsLengths[key] = getAllPointsLength(key) + 1;
    print(allPointsLengths[key]);
    notifyListeners();
  }

  ///开始一条新线
  void startNewLine(String key, String imageKey) {
    if (_images.containsKey(imageKey)) {
      if (_linesMap[key] == null) {
        _linesMap[key] = [];
        bgShadows[key] = true;
        ignorings[key] = false;
        allPointsLengths[key] = 0;
      }
      _linesMap[key]!.add(NewLine(image: _images[imageKey]));
      notifyListeners();
    }
  }

  ///结束一条新线
  void endLine(String key) {
    if (getActiveLine(key).state == NewPaintState.doing) {
      getActiveLine(key).state = NewPaintState.none;
    }
    if (getAllPointsLength(key) > 300) {
      bgShadows[key] = false;
      ignorings[key] = true;
      _linesMap[key] = [];
      allPointsLengths[key] = 0;
    }
    notifyListeners();
  }

  /// 加载图片
  Future<void> loadImage(String key, String img) async {
    final ByteData data = await rootBundle.load(img);
    final ui.Image image = await _loadImage(Uint8List.view(data.buffer));
    _images[key] = image;
    notifyListeners();
  }

  Future<ui.Image> _loadImage(Uint8List img) async {
    final Completer<ui.Image> completer = Completer();
    ui.decodeImageFromList(img, (ui.Image img) {
      completer.complete(img);
    });
    return completer.future;
  }
//清除
  void clearAll(String key) {
    _linesMap[key]?.clear();
    notifyListeners();
  }
}

同时再附上页面实现的代码

less 复制代码
void main() {
  runApp(
    ChangeNotifierProvider(
      create: (_) => NewAdsProvider(),
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: DrawingScreen(),
    );
  }
}

class DrawingScreen extends StatefulWidget {
  @override
  _DrawingScreenState createState() => _DrawingScreenState();
}

class _DrawingScreenState extends State<DrawingScreen> {
  @override
  void initState() {
    super.initState();
    final providerInit = Provider.of<NewAdsProvider>(context, listen: false);
    providerInit.loadImage('img2', 'assets/vex1.jpg');
    providerInit.loadImage('img1', 'assets/saduzi.jpg');
  }

  @override
  Widget build(BuildContext context) {
    final adsProvider = Provider.of<NewAdsProvider>(context);
    return Scaffold(
      appBar: AppBar(
        title: Text('Gesture Drawing'),
        actions: [
          IconButton(
            icon: Icon(Icons.clear),
            onPressed: () {
              Provider.of<NewAdsProvider>(context, listen: false)
                  .clearAll('drawing1');
            },
          ),
        ],
      ),
      body: Column(
        children: [
          Expanded(
            child: Stack(
              children: [
                IgnorePointer(
                  ignoring: adsProvider.getIgnoring('drawing1'),
                  child: GestureDetector(
                    onPanStart: (details) {
                      adsProvider.startNewLine('drawing1', 'img1');
                      Offset localPosition = details.localPosition;
                      NewPoint newPoint = NewPoint.fromOffset(localPosition);
                      adsProvider.addPoint('drawing1', newPoint);
                    },
                    onPanUpdate: (details) {
                      Offset localPosition = details.localPosition;
                      NewPoint newPoint = NewPoint.fromOffset(localPosition);
                      adsProvider.addPoint('drawing1', newPoint);
                    },
                    onPanEnd: (details) {
                      adsProvider.endLine('drawing1');
                    },
                    child: Consumer<NewAdsProvider>(
                      builder: (context, adsProvider, child) {
                        return CustomPaint(
                          size: Size.infinite,
                          painter: MyPainter(adsProvider.getLines('drawing1'),
                              adsProvider.getBgShadows('drawing1')),
                        );
                      },
                    ),
                  ),
                ),
              ],
            ),
          ),
          Expanded(
            child: Stack(
              children: [
                Image.asset(
                  'assets/vex1.jpg',
                  width: 300,
                  fit: BoxFit.fitWidth,
                ),
                Visibility(
                  visible: adsProvider.getBgShadows('drawing2'),
                  child: Container(
                    width: 300,
                    height: 300,
                    color: Colors.grey.withOpacity(0.9),
                  ),
                ),
                GestureDetector(
                  onPanStart: (details) {
                    adsProvider.startNewLine('drawing2', 'img2');
                    Offset localPosition = details.localPosition;
                    NewPoint newPoint = NewPoint.fromOffset(localPosition);
                    adsProvider.addPoint('drawing2', newPoint);
                  },
                  onPanUpdate: (details) {
                    Offset localPosition = details.localPosition;
                    NewPoint newPoint = NewPoint.fromOffset(localPosition);
                    adsProvider.addPoint('drawing2', newPoint);
                  },
                  onPanEnd: (details) {
                    adsProvider.endLine('drawing2');
                  },
                  child: Consumer<NewAdsProvider>(
                    builder: (context, adsProvider, child) {
                      return CustomPaint(
                        size: Size.infinite,
                        painter: MyPainter(adsProvider.getLines('drawing2'),
                            adsProvider.getBgShadows('drawing2')),
                      );
                    },
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

class MyPainter extends CustomPainter {
  final List<NewLine> lines;
  final bool bg_shadow;
  MyPainter(this.lines, this.bg_shadow);
  int allPointsLength = 0;
  List<NewPoint> allNewPoints = [];
  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint();
    for (var line in lines) {
      line.paint(canvas, paint);
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }
}

问题解决

基于以上的代码已经实现刮刮乐目的,下面则是分析为什么代码中一些部分会采用别的写法,或是问题的解决

问题1 NewPoint类的对比问题

在这个代码块里allNewPoints.contains(point)是失效的

在NewAdsProvider第四行

// List< NewPoint > allNewPoints = [];//废弃

原因是List.contains 方法通过调用对象的 == 运算符来检查列表是否包含指定元素。如果对象没有正确实现 == 运算符,那么 List.contains 方法可能不会按预期工作。

List.contains 方法没有按预期工作,因为 NewPoint 类没有正确实现 == 运算符和 hashCode 方法。默认情况下,Dart 使用对象的内存地址来比较两个对象是否相等,所以即使两个 NewPoint 对象有相同的 xy 值,它们仍然被认为是不同的对象。

这个问题也可以用插件# equatable | Dart package (pub.dev) 来优化.

一点中的代码采用的是插件来优化,如果不想要插件也可以改为

dart 复制代码
import 'package:flutter/material.dart';

class NewPoint {
  final double x;
  final double y;

  NewPoint(this.x, this.y);

  Offset toOffset() => Offset(x, y);

  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is NewPoint &&
          runtimeType == other.runtimeType &&
          x == other.x &&
          y == other.y;

  @override
  int get hashCode => x.hashCode ^ y.hashCode;
}

对==运算符重载来解决

问题2 绘制是Rect,为了美观怎么转换成RRect

因为绘制的是图片,所以只能采用canvas.drawImageRect这个方法,这个方法绘制出来的是Rect,并不是RRect,所以绘制出来会有一块一块的效果.

为了避免Rect带来的不美观,则需要换成RRct,但是canvas并没有canvas.drawImageRRect,所以需要RRect的话,需要另寻他路,

这里采用了裁剪的方法来获取RRect canvas.clipRRect

dstRect是我们绘制的Rect,把这个转换成RRect,则得到了需要的圆角矩形

ini 复制代码
// 定义圆角矩形区域  
final rrect = RRect.fromRectAndRadius(  
  dstRect,  
  Radius.circular(20),  
);

接下来只需要在绘制图片之前加上canvas.clipRRect就可以实现更好的绘制.

但是如果这样的话,会发现只有一个点可以绘制出来?

这是因为clipRRect后并没有还原画布,画布大小被局限为rrect的大小.

解决方法则是

在绘制前加上canvas.save();绘制后加上canvas.restore();

scss 复制代码
//目前的绘制方法
void paint(Canvas canvas, Paint paint) {  
  if (image != null) {  
    for (var point in points) {  
      canvas.save();  
      final srcRect = Rect.fromLTWH(  
        point.toOffset().dx * 4,  
        point.toOffset().dy * 4,  
        40,  
        40,  
      );  
      final dstRect = Rect.fromLTWH(  
        point.toOffset().dx,  
        point.toOffset().dy,  
        10,  
        10,  
      );  
      // 定义圆角矩形区域  
      final rrect = RRect.fromRectAndRadius(  
        dstRect,  
        Radius.circular(5),  
      );  
      canvas.clipRRect(rrect);  
      canvas.drawImageRect(image!, srcRect, dstRect, paint);  
      canvas.restore();  
    }  
  }  
}

这样就解决了Rect的问题

问题3 点太过密集,导致卡顿

如果不是绘制图片的话,这里的解决方法应为

对曲线的点收集采取每隔一定距离收集一个点,然后曲线拟合成一条曲线.

但是这里是绘制图片,所以曲线拟合就用不到了,

只需要对曲线的点收集采取每隔一定距离收集一个点

回到NewPoint类,添加获取距离和

dart 复制代码
class NewPoint extends Equatable {  
  final int x;  
  final int y;  
  NewPoint({required this.x, required this.y});  
  Offset toOffset() => Offset(x.toDouble(), y.toDouble());  
  ///开始  获取距离
  double get distance => sqrt(x * x + y * y);  
  
  factory NewPoint.fromOffset(Offset offset) {  
    return NewPoint(  
      x: offset.dx.toInt(),  
      y: offset.dy.toInt(),  
    );  
  }  
  NewPoint operator -(NewPoint other) =>  
      NewPoint(x: x - other.x, y: y - other.y);  
    ///结束  运算符重载 operator '-'
  @override  
  // TODO: implement props  
  List<Object?> get props => [x, y];  
}

然后在NewAdsProvider里的添加点方法addPoint进行修改,

scss 复制代码
final double tolerance = 5.0;

///添加点
void addPoint(String key, NewPoint point, {bool force = false}) {
  if (getActiveLine(key).points.isNotEmpty && !force) {
    if ((point - getActiveLine(key).points.last).distance < tolerance) return;
  }
  getActiveLine(key).points.add(point);
  allPointsLengths[key] = getAllPointsLength(key) + 1;
  print(allPointsLengths[key]);
  notifyListeners();
}

扩展

这里还有一些因为时间没有实现的功能,但是思路已经拟出来了

NewLine中dstRect和srcRect之间的倍率关系

这里根据原图的大小和想实现的大小进行等比缩放即可. 如果比例不同则在NewLine中的paint方法中修改

arduino 复制代码
final srcRect = Rect.fromLTWH(
  point.toOffset().dx * 4,
  point.toOffset().dy * 4,
  40,
  40,
);
相关推荐
crasowas2 小时前
Flutter问题记录 - 适配Xcode 16和iOS 18
flutter·ios·xcode
老田低代码1 天前
Dart自从引入null check后写Flutter App总有一种难受的感觉
前端·flutter
AiFlutter1 天前
Flutter Web首次加载时添加动画
前端·flutter
ZemanZhang3 天前
Flutter启动无法运行热重载
flutter
AiFlutter3 天前
Flutter-底部选择弹窗(showModalBottomSheet)
flutter
帅次4 天前
Android Studio:驱动高效开发的全方位智能平台
android·ide·flutter·kotlin·gradle·android studio·android jetpack
程序者王大川4 天前
【前端】Flutter vs uni-app:性能对比分析
前端·flutter·uni-app·安卓·全栈·性能分析·原生
yang2952423614 天前
使用 Vue.js 将数据对象的值放入另一个数据对象中
前端·vue.js·flutter
我码玄黄4 天前
解锁定位服务:Flutter应用中的高德地图定位
前端·flutter·dart
hudawei9964 天前
flutter widget 设置GestureDetector点击无效
flutter·gesturedetector·点击事件