Flutter 生成图片保存至相册
作者 | 星火燎原16 地址 | juejin.im/post/5eae5377f265da7bab3fd058
遇到一个需求,需要用 Flutter 生成图片,最终实现的效果如下:
基本思路
使用 Canvas
绘制图片中各元素,然后使用 PictureRecorder
进行记录生成。
添加依赖
qr_flutter: ^3.1.0
image_gallery_saver: ^1.2.2
fluttertoast: ^4.0.0
实现代码
import 'dart:ui' as ui;
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_platforms/generator/qrcode_generator.dart';
class ImageGenerator {
generate(ui.Image topImg, ui.Image bottomImg, double screenWidth,
String title, String content, String time) async {
print("screenWidth = $screenWidth");
final recorder = ui.PictureRecorder();
ui.Paint paint = new Paint()
..isAntiAlias = true
..filterQuality = ui.FilterQuality.high;
double rectTextTop = 150; // 文本显示矩形顶部距离图片最顶部的距离
double textMargin = 20; // 文字间间距,包括距离矩形边框左右间距
double pagePadding = 22; // 页面内容左右边距
double bottomHeight = 160; // 底部区域高度
// 获取标题高度等信息
double textMaxWidth = screenWidth - pagePadding * 2 - textMargin * 2;
TextPainter titlePainter = new TextPainter(
text: TextSpan(
text: title,
style: TextStyle(
fontSize: 20,
color: Colors.black87,
fontWeight: FontWeight.bold,
height: 1.2),
),
textDirection: TextDirection.ltr)
..layout(maxWidth: textMaxWidth);
var titleHeight = titlePainter.height;
print("titleHeight = $titleHeight");
TextPainter contentPainter = new TextPainter(
text: TextSpan(
text: content,
style: TextStyle(
fontSize: 16,
color: Colors.black87,
fontWeight: FontWeight.normal,
height: 1.5),
),
textDirection: TextDirection.ltr)
..layout(maxWidth: textMaxWidth);
var contentHeight = contentPainter.height;
print("contentheight = $contentHeight");
double textHeight = titleHeight + contentHeight + 3 * textMargin;
double bottom = textHeight + rectTextTop + textMargin * 2 + bottomHeight;
double shadowBottom = textHeight + rectTextTop;
print("bottom = $bottom");
if (bottom < 300) {
bottom = 300;
}
// 利用矩形左边的X坐标、矩形顶部的Y坐标、矩形右边的X坐标、矩形底部的Y坐标确定矩形的大小和位置
var canvasRect = Rect.fromLTWH(0, 0, screenWidth, bottom);
final canvas = Canvas(recorder, canvasRect);
// 0. 绘制背景
canvas.drawColor(Color(0xfffefefe), BlendMode.color);
// 1. 绘制图片
canvas.drawImageRect(
topImg,
Rect.fromLTWH(0, 0, topImg.width.toDouble(), topImg.height.toDouble()),
Rect.fromLTWH(
0, 0, screenWidth, topImg.height * screenWidth / topImg.width),
paint);
// 2. 绘制时间
new TextPainter(
text: TextSpan(
text: time,
style: TextStyle(
fontSize: 16,
color: Colors.white,
fontWeight: FontWeight.normal,
height: 1.5),
),
textDirection: TextDirection.ltr)
..layout(maxWidth: textMaxWidth)
..paint(canvas, Offset(pagePadding, rectTextTop - 40));
// 2. 绘制矩形,先绘制矩形,否则文字被覆盖
paint.color = Color(0x00ffffffff);
var rrect = RRect.fromRectAndRadius(
Rect.fromLTWH(pagePadding, rectTextTop, screenWidth - pagePadding * 2,
textHeight),
Radius.circular(6));
var path = Path()
..moveTo(pagePadding, rectTextTop)
..lineTo(screenWidth - pagePadding, rectTextTop)
..lineTo(screenWidth - pagePadding, shadowBottom)
..lineTo(pagePadding, shadowBottom)
..close();
canvas.drawShadow(path, Colors.black, 6, true);
canvas.drawRRect(rrect, paint);
// 3. 绘制文字
titlePainter.paint(
canvas, Offset(pagePadding + textMargin, rectTextTop + textMargin));
contentPainter.paint(
canvas,
Offset(pagePadding + textMargin,
rectTextTop + textMargin * 2 + titleHeight));
double bottomTextWidth = screenWidth * 2 / 5; // 底部文案宽度
double bottomTextTopMargin = bottomHeight * 2 / 5; // 底部文案距离上面文字间距
canvas.drawImageRect(
bottomImg,
Rect.fromLTWH(
0, 0, bottomImg.width.toDouble(), bottomImg.height.toDouble()),
// height / width = h / sc
Rect.fromLTWH(
screenWidth * 2 / 5,
shadowBottom + bottomTextTopMargin + 5,
bottomTextWidth,
bottomImg.height.toDouble() *
bottomTextWidth /
bottomImg.width.toDouble()),
paint);
// 绘制二维码
new QrCodeGenerator(data: "123456", version: 2).drawQrCode(
canvas, new Size(90, 90), 45, shadowBottom + bottomTextTopMargin);
// 转换成图片
final picture = recorder.endRecording();
ui.Image img = await picture.toImage(screenWidth.toInt(), bottom.toInt());
print('img的尺寸: $img');
final byteData = await img.toByteData(format: ui.ImageByteFormat.png);
return byteData;
}
}
import 'package:flutter/material.dart';
import 'package:flutter_platforms/generator/paint_cache.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'dart:ui' as ui;
// default color for the qr code pixels
const _qrDefaultColor = Color(0xff111111);
const _finderPatternLimit = 7;
class QrCodeGenerator {
ui.Image topImage;
ui.Image bottomImage;
/// The QR code version.
final int version; // the qr code version
/// The error correction level of the QR code.
final int errorCorrectionLevel; // the qr code error correction level
/// The color of the squares.
final Color color; // the color of the dark squares
/// The color of the non-squares (background).
@Deprecated(
'You should us the background color value of your container widget')
final Color emptyColor; // the other color
/// If set to false, the painter will leave a 1px gap between each of the
/// squares.
final bool gapless;
/// The image data to embed (as an overlay) in the QR code. The image will
/// be added to the center of the QR code.
ui.Image embeddedImage;
/// Styling options for the image overlay.
final QrEmbeddedImageStyle embeddedImageStyle;
/// The base QR code data
QrCode _qr;
/// This is the version (after calculating) that we will use if the user has
/// requested the 'auto' version.
int _calcVersion;
/// The size of the 'gap' between the pixels
final double _gapSize = 0.25;
/// Cache for all of the [Paint] objects.
final _paintCache = PaintCache();
QrCodeGenerator(
{@required String data,
@required this.version,
this.errorCorrectionLevel = QrErrorCorrectLevel.L,
this.color = _qrDefaultColor,
this.emptyColor,
this.gapless = false,
this.embeddedImage,
this.embeddedImageStyle}) {
_init(data);
}
bool _hasAdjacentVerticalPixel(int x, int y, int moduleCount) {
if (y + 1 >= moduleCount) return false;
return _qr.isDark(y + 1, x);
}
bool _hasAdjacentHorizontalPixel(int x, int y, int moduleCount) {
if (x + 1 >= moduleCount) return false;
return _qr.isDark(y, x + 1);
}
Size _scaledAspectSize(
Size widgetSize, Size originalSize, Size requestedSize) {
if (requestedSize != null && !requestedSize.isEmpty) {
return requestedSize;
} else if (requestedSize != null && _hasOneNonZeroSide(requestedSize)) {
final maxSide = requestedSize.longestSide;
final ratio = maxSide / originalSize.longestSide;
return Size(ratio * originalSize.width, ratio * originalSize.height);
} else {
final maxSide = 0.25 * widgetSize.shortestSide;
final ratio = maxSide / originalSize.longestSide;
return Size(ratio * originalSize.width, ratio * originalSize.height);
}
}
bool _isFinderPatternPosition(int x, int y) {
final isTopLeft = (y < _finderPatternLimit && x < _finderPatternLimit);
final isBottomLeft = (y < _finderPatternLimit &&
(x >= _qr.moduleCount - _finderPatternLimit));
final isTopRight = (y >= _qr.moduleCount - _finderPatternLimit &&
(x < _finderPatternLimit));
return isTopLeft || isBottomLeft || isTopRight;
}
bool _hasOneNonZeroSide(Size size) => size.longestSide > 0;
void _drawFinderPatternItem(
FinderPatternPosition position,
Canvas canvas,
_PaintMetrics metrics,
) {
final totalGap = (_finderPatternLimit - 1) * metrics.gapSize;
final radius = ((_finderPatternLimit * metrics.pixelSize) + totalGap) -
metrics.pixelSize;
final strokeAdjust = (metrics.pixelSize / 2.0);
final edgePos =
(metrics.inset + metrics.innerContentSize) - (radius + strokeAdjust);
Offset offset;
if (position == FinderPatternPosition.topLeft) {
offset =
Offset(metrics.inset + strokeAdjust, metrics.inset + strokeAdjust);
} else if (position == FinderPatternPosition.bottomLeft) {
offset = Offset(metrics.inset + strokeAdjust, edgePos);
} else {
offset = Offset(edgePos, metrics.inset + strokeAdjust);
}
// configure the paints
final outerPaint = _paintCache.firstPaint(QrCodeElement.finderPatternOuter,
position: position);
outerPaint.strokeWidth = metrics.pixelSize;
outerPaint.color = color;
final innerPaint = _paintCache.firstPaint(QrCodeElement.finderPatternInner,
position: position);
innerPaint.strokeWidth = metrics.pixelSize;
innerPaint.color = emptyColor ?? Color(0x00ffffff);
final dotPaint = _paintCache.firstPaint(QrCodeElement.finderPatternDot,
position: position);
dotPaint.color = color;
final outerRect = Rect.fromLTWH(offset.dx, offset.dy, radius, radius);
canvas.drawRect(outerRect, outerPaint);
final innerRadius = radius - (2 * metrics.pixelSize);
final innerRect = Rect.fromLTWH(offset.dx + metrics.pixelSize,
offset.dy + metrics.pixelSize, innerRadius, innerRadius);
canvas.drawRect(innerRect, innerPaint);
final gap = metrics.pixelSize * 2;
final dotSize = radius - gap - (2 * strokeAdjust);
final dotRect = Rect.fromLTWH(offset.dx + metrics.pixelSize + strokeAdjust,
offset.dy + metrics.pixelSize + strokeAdjust, dotSize, dotSize);
canvas.drawRect(dotRect, dotPaint);
}
void _drawImageOverlay(
Canvas canvas, Offset position, Size size, QrEmbeddedImageStyle style) {
final paint = Paint()
..isAntiAlias = true
..filterQuality = FilterQuality.high;
if (style != null) {
if (style.color != null) {
paint.colorFilter = ColorFilter.mode(style.color, BlendMode.srcATop);
}
}
final srcSize =
Size(embeddedImage.width.toDouble(), embeddedImage.height.toDouble());
final src = Alignment.center.inscribe(srcSize, Offset.zero & srcSize);
final dst = Alignment.center.inscribe(size, position & size);
canvas.drawImageRect(embeddedImage, src, dst, paint);
}
void _init(String data) {
if (!QrVersions.isSupportedVersion(version)) {
throw QrUnsupportedVersionException(version);
}
// configure and make the QR code data
final validationResult = QrValidator.validate(
data: data,
version: version,
errorCorrectionLevel: errorCorrectionLevel,
);
if (!validationResult.isValid) {
throw validationResult.error;
}
_qr = validationResult.qrCode;
_calcVersion = _qr.typeNumber;
_initPaints();
}
void _initPaints() {
// Cache the pixel paint object. For now there is only one but we might
// expand it to multiple later (e.g.: different colours).
_paintCache.cache(
Paint()..style = PaintingStyle.fill, QrCodeElement.codePixel);
// Cache the empty pixel paint object. Empty color is deprecated and will go
// away.
_paintCache.cache(
Paint()..style = PaintingStyle.fill, QrCodeElement.codePixelEmpty);
// Cache the finder pattern painters. We'll keep one for each one in case
// we want to provide customization options later.
for (final position in FinderPatternPosition.values) {
_paintCache.cache(Paint()..style = PaintingStyle.stroke,
QrCodeElement.finderPatternOuter,
position: position);
_paintCache.cache(Paint()..style = PaintingStyle.stroke,
QrCodeElement.finderPatternInner,
position: position);
_paintCache.cache(
Paint()..style = PaintingStyle.fill, QrCodeElement.finderPatternDot,
position: position);
}
}
/// 绘制二维码
drawQrCode(Canvas canvas, Size size, double dx, double dy) async {
canvas.save();
canvas.translate(dx, dy);
// if the widget has a zero size side then we cannot continue painting.
if (size.shortestSide == 0) {
print("[QR] WARN: width or height is zero. You should set a 'size' value "
"or nest this painter in a Widget that defines a non-zero size");
return;
}
final paintMetrics = _PaintMetrics(
containerSize: size.shortestSide,
moduleCount: _qr.moduleCount,
gapSize: (gapless ? 0 : _gapSize),
);
// draw the finder pattern elements
_drawFinderPatternItem(FinderPatternPosition.topLeft, canvas, paintMetrics);
_drawFinderPatternItem(
FinderPatternPosition.bottomLeft, canvas, paintMetrics);
_drawFinderPatternItem(
FinderPatternPosition.topRight, canvas, paintMetrics);
double left;
double top;
final gap = !gapless ? _gapSize : 0;
// get the painters for the pixel information
final pixelPaint = _paintCache.firstPaint(QrCodeElement.codePixel);
pixelPaint.color = color;
Paint emptyPixelPaint;
if (emptyColor != null) {
emptyPixelPaint = _paintCache.firstPaint(QrCodeElement.codePixelEmpty);
emptyPixelPaint.color = emptyColor;
}
for (var x = 0; x < _qr.moduleCount; x++) {
for (var y = 0; y < _qr.moduleCount; y++) {
// draw the finder patterns independently
if (_isFinderPatternPosition(x, y)) continue;
final paint = _qr.isDark(y, x) ? pixelPaint : emptyPixelPaint;
if (paint == null) continue;
// paint a pixel
left = paintMetrics.inset + (x * (paintMetrics.pixelSize + gap));
top = paintMetrics.inset + (y * (paintMetrics.pixelSize + gap));
var pixelHTweak = 0.0;
var pixelVTweak = 0.0;
if (gapless && _hasAdjacentHorizontalPixel(x, y, _qr.moduleCount)) {
pixelHTweak = 0.5;
}
if (gapless && _hasAdjacentVerticalPixel(x, y, _qr.moduleCount)) {
pixelVTweak = 0.5;
}
final squareRect = Rect.fromLTWH(
left,
top,
paintMetrics.pixelSize + pixelHTweak,
paintMetrics.pixelSize + pixelVTweak,
);
canvas.drawRect(squareRect, paint);
}
}
if (embeddedImage != null) {
final originalSize = Size(
embeddedImage.width.toDouble(),
embeddedImage.height.toDouble(),
);
final requestedSize =
embeddedImageStyle != null ? embeddedImageStyle.size : null;
final imageSize = _scaledAspectSize(size, originalSize, requestedSize);
final position = Offset(
(size.width - imageSize.width) / 2.0,
(size.height - imageSize.height) / 2.0,
);
// draw the image overlay.
_drawImageOverlay(canvas, position, imageSize, embeddedImageStyle);
}
canvas.restore();
}
}
class _PaintMetrics {
_PaintMetrics(
{@required this.containerSize,
@required this.gapSize,
@required this.moduleCount}) {
_calculateMetrics();
}
final int moduleCount;
final double containerSize;
final double gapSize;
double _pixelSize;
double get pixelSize => _pixelSize;
double _innerContentSize;
double get innerContentSize => _innerContentSize;
double _inset;
double get inset => _inset;
void _calculateMetrics() {
final gapTotal = (moduleCount - 1) * gapSize;
var pixelSize = (containerSize - gapTotal) / moduleCount;
_pixelSize = (pixelSize * 2).roundToDouble() / 2;
_innerContentSize = (_pixelSize * moduleCount) + gapTotal;
_inset = (containerSize - _innerContentSize) / 2;
}
}
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'dart:ui' as ui;
import 'package:flutter/services.dart';
import 'package:flutter_platforms/generator/image_generator.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:image_gallery_saver/image_gallery_saver.dart';
class ImageGeneratorPage extends StatefulWidget {
@override
_ImageGeneratorPageState createState() => _ImageGeneratorPageState();
}
class _ImageGeneratorPageState extends State<ImageGeneratorPage> {
ByteData _imgBytes;
ui.Image _topImage;
ui.Image _bottomImage;
@override
void initState() {
super.initState();
_loadImage('images/icon2.jpg').then((image) {
setState(() {
_topImage = image;
});
});
_loadImage('images/bottom_text.png').then((image) {
setState(() {
_bottomImage = image;
});
});
}
@override
Widget build(BuildContext context) {
double screenWidth = MediaQuery.of(context).size.width;
return Scaffold(
backgroundColor: Colors.teal,
body: Center(
child: Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Padding(
padding: const EdgeInsets.all(12.0),
child: RaisedButton(
child: Text("Image generate"),
onPressed: () {
_generate(screenWidth);
},
),
),
_imgBytes != null
? Container(
child: Image.memory(
Uint8List.view(_imgBytes.buffer),
height: 500,
))
: Container()
],
),
),
);
}
/// 加载图片
Future<ui.Image> _loadImage(String path) async {
var data = await rootBundle.load(path);
var codec = await ui.instantiateImageCodec(data.buffer.asUint8List());
var info = await codec.getNextFrame();
return info.image;
}
void _generate(double screenWidth) async {
ByteData byteData = await ImageGenerator().generate(
_topImage,
_bottomImage,
screenWidth,
"90后海归硕士多次偷快递 压力太大只为看看里面是什么",
"3月20日中午,一名年轻女子来取快递,3月20日中午,一名年轻女子来取快递,3月20日中午,一名年轻女子来取快递,3月20日中午,一名年轻女子来取快递,3月20日中午,一名年轻女子来取快递,3月20日中午,一名年轻女子来取快递,3月20日中午,一名年轻女子来取快递,3月20日中午,一名年轻女子来取快递,3月20日中午,一名年轻女子来取快递,3月20日中午,一名年轻女子来取快递,3月20日中午,一名年轻女子来取快递,3月20日中午,一名年轻女子来取快递,3月20日中午,一名年轻女子来取快递,3月20日中午,一名年轻女子来取快递,3月20日中午,一名年轻女子来取快递,3月20日中午,一名年轻女子来取快递,3月20日中午,一名年轻女子来取快递,1111111112222欧某问了她门牌号码并帮她找到了该住户的快递。但她离开后不久,此住户真正的物主来找快递未果,向欧某反映自己的快递丢失。欧某再次查找监控,11",
"2019年7月1日 英山网");
saveFile(byteData);
setState(() {
_imgBytes = byteData;
});
}
saveFile(ByteData byteData) async {
Uint8List pngBytes = byteData.buffer.asUint8List();
final result = await ImageGallerySaver.saveImage(pngBytes); //这个是核心的保存图片的插件
print("result = $result");
Fluttertoast.showToast(
msg: "filePath = $result",
toastLength: Toast.LENGTH_SHORT,
gravity: ToastGravity.CENTER,
timeInSecForIosWeb: 1,
backgroundColor: Colors.yellow,
textColor: Colors.black,
fontSize: 16.0);
}
}