Flutter Custom Paint滚动超出屏幕时如何捕获完整绘图
问题描述
我实现了一个Custom Paint组件用于绘图,但上下滚动时,屏幕外的绘图内容无法被捕获。已耗费大量时间仍无法自行解决,附上相关代码,恳请帮忙解决。
Future<void> captureAndSaveImage( {ui.PictureRecorder? recorder, GlobalKey? repaintKey}) async { RenderRepaintBoundary boundary = repaintKey!.currentContext!.findRenderObject() as RenderRepaintBoundary; ui.Image image = await boundary.toImage(pixelRatio: 10.0); ByteData? byteData = await image.toByteData(format: ui.ImageByteFormat.png); Uint8List uint8List = byteData!.buffer.asUint8List(); print(byteData); final pdf = pdf_widgets.Document(); //Request permissions if not already granted if (!(await Permission.storage.status.isGranted)) await Permission.storage.request(); pdf.addPage( pdf_widgets.Page( // pageFormat: PdfPageFormat.a3, build: (pdf_widgets.Context context) { return pdf_widgets.Image( pdf_widgets.MemoryImage(uint8List), ); }, ), ); //get the date to set name of the file DateTime now = DateTime.now(); DateTime date = DateTime(now.year, now.month, now.day, now.hour, now.minute, now.second); final directory = await getDownloadsDirectory(); final file = File('${directory!.path}/${date.toString()}.pdf'); await file.writeAsBytes(await pdf.save()); print('File saved to ${file.path}'); } enum CanvasState { draw, panUpdate } class PaintPrescription extends StatefulWidget { const PaintPrescription({super.key}); static String id = 'paintPrescription'; @override State<PaintPrescription> createState() => _PaintPrescriptionState(); } class _PaintPrescriptionState extends State<PaintPrescription> { List<List<Offset>> points = []; List<List<Offset>> redoStack = []; List<Offset> currentPath = []; Offset offset = Offset(0, 0); CanvasState canvasState = CanvasState.draw; final recorder = ui.PictureRecorder(); final GlobalKey _repaintKey = GlobalKey(); late Canvas canvas; @override void initState() { super.initState(); canvas = Canvas(recorder); } void _changeMode() { setState(() { canvasState = (canvasState == CanvasState.draw) ? CanvasState.panUpdate : CanvasState.draw; }); } Widget _appBarButtons( {required void onPressed(), required IconData iconData}) { return Padding( padding: const EdgeInsets.only(top: 15.0), child: IconButton( onPressed: onPressed, icon: Icon( iconData, size: 27.0, color: kAppBlue, )), ); } @override Widget build(BuildContext coheightntext) { return Scaffold( backgroundColor: Colors.white, appBar: AppBar( backgroundColor: Colors.white, actions: [ _appBarButtons(onPressed: _undo, iconData: Icons.undo_rounded), _appBarButtons(onPressed: _redo, iconData: Icons.redo_rounded), _appBarButtons( onPressed: _changeMode, iconData: canvasState == CanvasState.panUpdate ? Icons.swipe_vertical_rounded : Icons.swipe_vertical_outlined) ], ), //body body: GestureDetector( onScaleStart: (details) => _startPath(details.localFocalPoint), onScaleUpdate: (details) { if (canvasState == CanvasState.panUpdate) _updatePan(details.focalPointDelta); else _updatePath(details.localFocalPoint); }, onScaleEnd: (_) => _endPath(), child: RepaintBoundary( key: _repaintKey, child: SizedBox( width: MediaQuery.of(context).size.width, height: double.infinity, child: SingleChildScrollView( child: CustomPaint( painter: PrescriptionWritter(points, offset), ), ), ), ), ), //send button floatingActionButton: FloatingActionButton( onPressed: () => captureAndSaveImage(recorder: recorder, repaintKey: _repaintKey), backgroundColor: kPrimaryColor, child: Icon(Icons.send_rounded), ), ); } } class PrescriptionWritter extends CustomPainter { final List<List<Offset>> points; Offset offset; PrescriptionWritter(this.points, this.offset); var currentPaint = Paint() ..color = Colors.black ..strokeCap = StrokeCap.round ..strokeWidth = 3.0; @override void paint(canvas, Size size) { for (final path in points) { for (int i = 0; i < path.length - 1; i++) { if (path[i] != Offset(-1, -1) && path[i + 1] != Offset(-1, -1)) { canvas.drawLine( path[i] + offset, path[i + 1] + offset, currentPaint, ); } else if (path[i] != Offset(-1, -1) && path[i + 1] == Offset(-1, -1)) { canvas.drawPoints( ui.PointMode.points, [path[i] + offset], currentPaint, ); } } } } @override bool shouldRepaint(covariant CustomPainter oldDelegate) { return true; } }
解决方案
问题根源:
CustomPaint未设置明确尺寸,默认仅匹配父级可见区域,导致屏幕外绘制内容未被渲染。SingleChildScrollView包裹无固定尺寸的CustomPaint时,仅渲染当前可见部分,RepaintBoundary无法捕获屏幕外内容。
以下是修复后的完整代码及关键修改说明:
完整修复代码
import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:pdf/widgets.dart' as pdf_widgets; import 'package:path_provider/path_provider.dart'; Future<void> captureAndSaveImage({required GlobalKey repaintKey}) async { RenderRepaintBoundary boundary = repaintKey.currentContext!.findRenderObject() as RenderRepaintBoundary; ui.Image image = await boundary.toImage(pixelRatio: 3.0); ByteData? byteData = await image.toByteData(format: ui.ImageByteFormat.png); Uint8List uint8List = byteData!.buffer.asUint8List(); final pdf = pdf_widgets.Document(); if (!(await Permission.storage.status.isGranted)) await Permission.storage.request(); pdf.addPage( pdf_widgets.Page( build: (pdf_widgets.Context context) { return pdf_widgets.Image( pdf_widgets.MemoryImage(uint8List), fit: pdf_widgets.BoxFit.contain, ); }, ), ); DateTime now = DateTime.now(); DateTime date = DateTime(now.year, now.month, now.day, now.hour, now.minute, now.second); final directory = await getDownloadsDirectory(); final file = File('${directory!.path}/${date.toString()}.pdf'); await file.writeAsBytes(await pdf.save()); print('File saved to ${file.path}'); } enum CanvasState { draw, panUpdate } class PaintPrescription extends StatefulWidget { const PaintPrescription({super.key}); static String id = 'paintPrescription'; @override State<PaintPrescription> createState() => _PaintPrescriptionState(); } class _PaintPrescriptionState extends State<PaintPrescription> { List<List<Offset>> points = []; List<List<Offset>> redoStack = []; List<Offset> currentPath = []; Offset offset = Offset(0, 0); CanvasState canvasState = CanvasState.draw; final GlobalKey _repaintKey = GlobalKey(); // 计算画布实际尺寸,确保容纳所有绘制内容 Size _calculateCanvasSize() { if (points.isEmpty) return Size(MediaQuery.of(context).size.width, 600); double minX = double.infinity; double maxX = -double.infinity; double minY = double.infinity; double maxY = -double.infinity; for (final path in points) { for (final point in path) { if (point == const Offset(-1, -1)) continue; final adjustedPoint = point + offset; minX = min(minX, adjustedPoint.dx); maxX = max(maxX, adjustedPoint.dx); minY = min(minY, adjustedPoint.dy); maxY = max(maxY, adjustedPoint.dy); } } // 增加边距避免内容贴边 final width = max(maxX - minX, MediaQuery.of(context).size.width) + 40; final height = maxY - minY + 40; return Size(width, height); } void _undo() { if (points.isNotEmpty) { setState(() { redoStack.add(points.removeLast()); }); } } void _redo() { if (redoStack.isNotEmpty) { setState(() { points.add(redoStack.removeLast()); }); } } void _changeMode() { setState(() { canvasState = (canvasState == CanvasState.draw) ? CanvasState.panUpdate : CanvasState.draw; }); } void _startPath(Offset localFocalPoint) { if (canvasState == CanvasState.draw) { setState(() { currentPath = [localFocalPoint - offset]; }); } } void _updatePath(Offset localFocalPoint) { if (canvasState == CanvasState.draw) { setState(() { currentPath.add(localFocalPoint - offset); }); } } void _updatePan(Offset focalPointDelta) { setState(() { offset += focalPointDelta; }); } void _endPath() { if (canvasState == CanvasState.draw && currentPath.isNotEmpty) { setState(() { points.add(currentPath); currentPath = []; redoStack.clear(); }); } } Widget _appBarButtons({required VoidCallback onPressed, required IconData iconData}) { return Padding( padding: const EdgeInsets.only(top: 15.0), child: IconButton( onPressed: onPressed, icon: Icon( iconData, size: 27.0, color: Colors.blue, )), ); } @override Widget build(BuildContext context) { final canvasSize = _calculateCanvasSize(); return Scaffold( backgroundColor: Colors.white, appBar: AppBar( backgroundColor: Colors.white, actions: [ _appBarButtons(onPressed: _undo, iconData: Icons.undo_rounded), _appBarButtons(onPressed: _redo, iconData: Icons.redo_rounded), _appBarButtons( onPressed: _changeMode, iconData: canvasState == CanvasState.panUpdate ? Icons.swipe_vertical_rounded : Icons.swipe_vertical_outlined) ], ), body: GestureDetector( onScaleStart: (details) => _startPath(details.localFocalPoint), onScaleUpdate: (details) { if (canvasState == CanvasState.panUpdate) _updatePan(details.focalPointDelta); else _updatePath(details.localFocalPoint); }, onScaleEnd: (_) => _endPath(), child: RepaintBoundary( key: _repaintKey, child: SingleChildScrollView( child: SizedBox( width: canvasSize.width, height: canvasSize.height, child: CustomPaint( size: canvasSize, painter: PrescriptionWritter(points, offset), ), ), ), ), ), floatingActionButton: FloatingActionButton( onPressed: () => captureAndSaveImage(repaintKey: _repaintKey), backgroundColor: Colors.blue, child: const Icon(Icons.send_rounded), ), ); } } class PrescriptionWritter extends CustomPainter { final List<List<Offset>> points; final Offset offset; PrescriptionWritter(this.points, this.offset); final currentPaint = Paint() ..color = Colors.black ..strokeCap = StrokeCap.round ..strokeWidth = 3.0; @override void paint(ui.Canvas canvas, Size size) { for (final path in points) { for (int i = 0; i < path.length - 1; i++) { if (path[i] != const Offset(-1, -1) && path[i + 1] != const Offset(-1, -1)) { canvas.drawLine( path[i] + offset, path[i + 1] + offset, currentPaint, ); } else if (path[i] != const Offset(-1, -1) && path[i + 1] == const Offset(-1, -1)) { canvas.drawPoints( ui.PointMode.points, [path[i] + offset], currentPaint, ); } } } } @override bool shouldRepaint(covariant CustomPainter oldDelegate) { return true; } }
关键修改说明
- 计算画布实际尺寸:新增
_calculateCanvasSize方法,遍历所有绘制点计算画布最小宽高,确保容纳全部内容。 - 调整布局结构:给
CustomPaint设置明确的size,让SingleChildScrollView能识别完整的可滚动区域。 - 移除无用实例:删除未被使用的
PictureRecorder和canvas对象,避免混淆。 - 优化捕获逻辑:调整
captureAndSaveImage方法,确保toImage能捕获完整的RepaintBoundary内容。
内容的提问来源于stack exchange,提问作者Abhi Agyaat
相关产品推荐
相关产品推荐

