灵感来源
仿照在刷科目一时的软件广告,觉得很有意思,故而仿之; 滑动到一定范围后,全部清除,并且弹窗(这里gif没有录制到) 项目地址杉木笙/ads_drawing (gitee.com)
实现效果
思路分析
一开始要实现这个效果,初步分析是Stack中CustomPaint
和图片还有阴影叠在一起;
但是这样下来距离目标相差甚远,仔细想了一下问题出在哪里
- 灰色半透明遮罩从何而来
- 会采用绘制吗,应该绘制什么
- 如果采用绘制,怎么判断绘制到一定程度来实现弹窗或其他效果?
思路实现
这个章节里的代码即为最终实现代码.一点一线皆是河山
在这里先放下几个问题,在目录问题解决中解决
- NewPoint类的对比问题
- 绘制是Rect,为了美观怎么转换成RRect
- 点太过密集,导致卡顿
如何绘制
因为绘制的目标不是单纯的纯色,而是图片;所以这里要用到绘制方法采用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
对象有相同的 x
和 y
值,它们仍然被认为是不同的对象。
这个问题也可以用插件# 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,
);