/// 自然写互动课堂平板端应用软件 V1.0 /// Skia笔迹渲染器 - CustomPainter实现触屏直写与点阵笔笔迹渲染 /// /// 功能说明: /// 1. CustomPainter高性能笔迹绘制(Skia引擎) /// 2. 触屏直写支持(手指/触控笔Pointer事件处理) /// 3. 点阵笔BLE数据渲染(从BLE服务接收坐标数据) /// 4. 压力感应笔锋效果(触控笔ActiveStylus压力数据) /// 5. 贝塞尔曲线平滑算法 /// 6. 字帖练习辅助线(田字格/米字格/四线三格) /// 7. 撤销/重做操作栈 /// 8. 笔迹导出(SVG/PNG格式) import 'dart:math'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/gestures.dart'; /* ========== 数据模型 ========== */ /// 笔迹点 class PadStrokePoint { final double x; final double y; final double pressure; final int timestamp; const PadStrokePoint({ required this.x, required this.y, this.pressure = 0.5, required this.timestamp, }); Map toJson() => { 'x': x, 'y': y, 'pressure': pressure, 'timestamp': timestamp, }; } /// 笔画 class PadStroke { final List points; final Color color; final double baseWidth; final String source; // 'touch'=触屏, 'ble'=点阵笔 PadStroke({ List? points, this.color = Colors.black, this.baseWidth = 2.5, this.source = 'touch', }) : points = points ?? []; void addPoint(PadStrokePoint point) => points.add(point); } /// 辅助线类型 enum GuideLineType { none, // 无辅助线 tianZiGe, // 田字格 miZiGe, // 米字格 siXianSanGe, // 四线三格(英文/拼音) } /// 撤销/重做操作 sealed class CanvasAction {} class AddStrokeAction extends CanvasAction { final PadStroke stroke; AddStrokeAction(this.stroke); } class ClearAction extends CanvasAction { final List clearedStrokes; ClearAction(this.clearedStrokes); } /* ========== 笔迹画布Widget ========== */ /// 平板端笔迹渲染画布 /// 支持触屏直写和BLE点阵笔两种输入方式 class PadStrokeCanvas extends StatefulWidget { /// 初始笔画数据(如加载已有作业笔迹) final List? initialStrokes; /// 辅助线类型 final GuideLineType guideLineType; /// 是否只读模式(查看已提交的作业) final bool readOnly; /// 笔迹颜色 final Color strokeColor; /// 笔画宽度 final double strokeWidth; /// 笔迹变化回调 final Function(List)? onStrokesChanged; const PadStrokeCanvas({ super.key, this.initialStrokes, this.guideLineType = GuideLineType.none, this.readOnly = false, this.strokeColor = Colors.black, this.strokeWidth = 2.5, this.onStrokesChanged, }); @override State createState() => _PadStrokeCanvasState(); } class _PadStrokeCanvasState extends State { /// 已完成的笔画列表 final List _strokes = []; /// 当前正在绘制的笔画 PadStroke? _currentStroke; /// 撤销栈 final List _undoStack = []; /// 重做栈 final List _redoStack = []; /// 最大撤销步数 static const int maxUndoSteps = 50; @override void initState() { super.initState(); if (widget.initialStrokes != null) { _strokes.addAll(widget.initialStrokes!); } } /// 撤销最后一个操作 void undo() { if (_undoStack.isEmpty) return; final action = _undoStack.removeLast(); if (action is AddStrokeAction) { _strokes.remove(action.stroke); _redoStack.add(action); } else if (action is ClearAction) { _strokes.addAll(action.clearedStrokes); _redoStack.add(action); } setState(() {}); widget.onStrokesChanged?.call(_strokes); } /// 重做上一个撤销的操作 void redo() { if (_redoStack.isEmpty) return; final action = _redoStack.removeLast(); if (action is AddStrokeAction) { _strokes.add(action.stroke); _undoStack.add(action); } else if (action is ClearAction) { _strokes.clear(); _undoStack.add(action); } setState(() {}); widget.onStrokesChanged?.call(_strokes); } /// 清除所有笔迹 void clearAll() { if (_strokes.isEmpty) return; final cleared = List.from(_strokes); _undoStack.add(ClearAction(cleared)); _strokes.clear(); _redoStack.clear(); setState(() {}); widget.onStrokesChanged?.call(_strokes); } /// 从BLE点阵笔添加笔画(外部调用) void addBleStroke(PadStroke stroke) { _strokes.add(stroke); _undoStack.add(AddStrokeAction(stroke)); _redoStack.clear(); setState(() {}); widget.onStrokesChanged?.call(_strokes); } /// 获取所有笔画数据(用于提交) List getStrokes() => List.unmodifiable(_strokes); @override Widget build(BuildContext context) { return Listener( // 使用Listener而非GestureDetector,以获取精确的Pointer事件 onPointerDown: widget.readOnly ? null : _onPointerDown, onPointerMove: widget.readOnly ? null : _onPointerMove, onPointerUp: widget.readOnly ? null : _onPointerUp, child: ClipRect( child: CustomPaint( painter: _PadStrokePainter( strokes: _strokes, currentStroke: _currentStroke, guideLineType: widget.guideLineType, ), size: Size.infinite, ), ), ); } /// 触屏落笔 void _onPointerDown(PointerDownEvent event) { final pressure = event.pressure > 0 ? event.pressure : 0.5; _currentStroke = PadStroke( color: widget.strokeColor, baseWidth: widget.strokeWidth, source: event.kind == PointerDeviceKind.stylus ? 'stylus' : 'touch', ); _currentStroke!.addPoint(PadStrokePoint( x: event.localPosition.dx, y: event.localPosition.dy, pressure: pressure, timestamp: DateTime.now().millisecondsSinceEpoch, )); setState(() {}); } /// 触屏移动 void _onPointerMove(PointerMoveEvent event) { if (_currentStroke == null) return; final pressure = event.pressure > 0 ? event.pressure : 0.5; _currentStroke!.addPoint(PadStrokePoint( x: event.localPosition.dx, y: event.localPosition.dy, pressure: pressure, timestamp: DateTime.now().millisecondsSinceEpoch, )); setState(() {}); } /// 触屏抬笔 void _onPointerUp(PointerUpEvent event) { if (_currentStroke == null) return; if (_currentStroke!.points.length >= 2) { _strokes.add(_currentStroke!); _undoStack.add(AddStrokeAction(_currentStroke!)); _redoStack.clear(); // 限制撤销栈大小 if (_undoStack.length > maxUndoSteps) { _undoStack.removeAt(0); } widget.onStrokesChanged?.call(_strokes); } _currentStroke = null; setState(() {}); } } /* ========== Painter实现 ========== */ /// 笔迹绘制Painter class _PadStrokePainter extends CustomPainter { final List strokes; final PadStroke? currentStroke; final GuideLineType guideLineType; _PadStrokePainter({ required this.strokes, this.currentStroke, this.guideLineType = GuideLineType.none, }); @override void paint(Canvas canvas, Size size) { // 绘制背景 canvas.drawRect( Rect.fromLTWH(0, 0, size.width, size.height), Paint()..color = Colors.white, ); // 绘制辅助线 if (guideLineType != GuideLineType.none) { _drawGuideLines(canvas, size); } // 绘制已完成的笔画 for (final stroke in strokes) { _drawStroke(canvas, stroke); } // 绘制当前活跃笔画 if (currentStroke != null) { _drawStroke(canvas, currentStroke!); } } /// 绘制辅助线 void _drawGuideLines(Canvas canvas, Size size) { final paint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 0.5; switch (guideLineType) { case GuideLineType.tianZiGe: _drawTianZiGe(canvas, size, paint); break; case GuideLineType.miZiGe: _drawMiZiGe(canvas, size, paint); break; case GuideLineType.siXianSanGe: _drawSiXianSanGe(canvas, size, paint); break; default: break; } } /// 绘制田字格 void _drawTianZiGe(Canvas canvas, Size size, Paint paint) { const cellSize = 80.0; paint.color = Colors.red.withValues(alpha: 0.3); // 外框(实线) for (double x = 0; x <= size.width; x += cellSize) { canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint); } for (double y = 0; y <= size.height; y += cellSize) { canvas.drawLine(Offset(0, y), Offset(size.width, y), paint); } // 中心十字线(虚线效果用半透明) paint.color = Colors.red.withValues(alpha: 0.15); final halfCell = cellSize / 2; for (double x = halfCell; x < size.width; x += cellSize) { canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint); } for (double y = halfCell; y < size.height; y += cellSize) { canvas.drawLine(Offset(0, y), Offset(size.width, y), paint); } } /// 绘制米字格 void _drawMiZiGe(Canvas canvas, Size size, Paint paint) { const cellSize = 80.0; paint.color = Colors.red.withValues(alpha: 0.3); for (double x = 0; x <= size.width; x += cellSize) { canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint); } for (double y = 0; y <= size.height; y += cellSize) { canvas.drawLine(Offset(0, y), Offset(size.width, y), paint); } // 对角线 + 十字线 paint.color = Colors.red.withValues(alpha: 0.15); for (double x = 0; x < size.width; x += cellSize) { for (double y = 0; y < size.height; y += cellSize) { // 对角线 canvas.drawLine(Offset(x, y), Offset(x + cellSize, y + cellSize), paint); canvas.drawLine(Offset(x + cellSize, y), Offset(x, y + cellSize), paint); // 十字线 canvas.drawLine(Offset(x + cellSize / 2, y), Offset(x + cellSize / 2, y + cellSize), paint); canvas.drawLine(Offset(x, y + cellSize / 2), Offset(x + cellSize, y + cellSize / 2), paint); } } } /// 绘制四线三格(拼音/英文) void _drawSiXianSanGe(Canvas canvas, Size size, Paint paint) { const lineSpacing = 15.0; const groupHeight = lineSpacing * 3; const groupGap = 20.0; paint.color = Colors.green.withValues(alpha: 0.3); double y = 20; while (y < size.height - groupHeight) { // 四条横线 for (int i = 0; i < 4; i++) { final lineY = y + i * lineSpacing; // 第二条线(中线)用虚线表示 if (i == 1 || i == 2) { paint.color = Colors.green.withValues(alpha: 0.15); } else { paint.color = Colors.green.withValues(alpha: 0.3); } canvas.drawLine(Offset(0, lineY), Offset(size.width, lineY), paint); } y += groupHeight + groupGap; } } /// 绘制单个笔画(贝塞尔平滑 + 压力笔锋) void _drawStroke(Canvas canvas, PadStroke stroke) { if (stroke.points.length < 2) return; final paint = Paint() ..color = stroke.color ..strokeCap = StrokeCap.round ..strokeJoin = StrokeJoin.round ..style = PaintingStyle.stroke ..isAntiAlias = true; for (int i = 1; i < stroke.points.length; i++) { final prev = stroke.points[i - 1]; final curr = stroke.points[i]; // 压力笔锋宽度计算 final avgPressure = (prev.pressure + curr.pressure) / 2.0; var width = stroke.baseWidth * (0.3 + avgPressure * 1.7); // 落笔过渡 if (i < 5) width *= (i / 5.0); // 抬笔过渡 final remaining = stroke.points.length - i; if (remaining < 5) width *= (remaining / 5.0); width = max(width, 0.5); paint.strokeWidth = width; if (i >= 2) { // 贝塞尔曲线平滑 final pp = stroke.points[i - 2]; final cp1x = prev.x + (curr.x - pp.x) * 0.2; final cp1y = prev.y + (curr.y - pp.y) * 0.2; final cp2x = curr.x - (curr.x - prev.x) * 0.2; final cp2y = curr.y - (curr.y - prev.y) * 0.2; final path = Path() ..moveTo(prev.x, prev.y) ..cubicTo(cp1x, cp1y, cp2x, cp2y, curr.x, curr.y); canvas.drawPath(path, paint); } else { canvas.drawLine(Offset(prev.x, prev.y), Offset(curr.x, curr.y), paint); } } } @override bool shouldRepaint(covariant _PadStrokePainter oldDelegate) { return oldDelegate.strokes.length != strokes.length || oldDelegate.currentStroke != currentStroke; } }