You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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;
  }
}

关键修改说明

  1. 计算画布实际尺寸:新增_calculateCanvasSize方法,遍历所有绘制点计算画布最小宽高,确保容纳全部内容。
  2. 调整布局结构:给CustomPaint设置明确的size,让SingleChildScrollView能识别完整的可滚动区域。
  3. 移除无用实例:删除未被使用的PictureRecorder和canvas对象,避免混淆。
  4. 优化捕获逻辑:调整captureAndSaveImage方法,确保toImage能捕获完整的RepaintBoundary内容。

内容的提问来源于stack exchange,提问作者Abhi Agyaat

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.10 23:35:54