/// 自然写互动课堂手机端应用软件 V1.0 /// 笔迹渲染组件 - CustomPainter实现高性能笔迹绘制与回放 /// /// 功能说明: /// 1. 自定义CustomPainter实现60fps笔迹渲染 /// 2. 贝塞尔曲线平滑算法(消除锯齿) /// 3. 压力感应笔锋效果(笔画粗细随压力变化) /// 4. 笔迹回放动画(逐点重放书写过程) /// 5. 多种笔迹颜色和宽度支持 /// 6. 笔迹缩放与平移(手势操作) /// 7. 双缓冲渲染优化(离屏缓存已绘制内容) import 'dart:async'; import 'dart:math'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; /* ========== 笔迹数据结构 ========== */ /// 笔迹点数据 class StrokePointData { final double x; final double y; final double pressure; final int timestamp; const StrokePointData({ required this.x, required this.y, this.pressure = 0.5, required this.timestamp, }); } /// 笔画数据(一次落笔到抬笔的完整路径) class StrokeData { final List points; final Color color; final double baseWidth; StrokeData({ required this.points, this.color = Colors.black, this.baseWidth = 2.0, }); } /* ========== 笔迹渲染Widget ========== */ /// 笔迹画布Widget - 展示笔迹渲染与回放 class StrokeCanvasWidget extends StatefulWidget { /// 笔迹数据列表 final List strokes; /// 是否启用回放模式 final bool enableReplay; /// 回放速度倍率(1.0=原速,2.0=两倍速) final double replaySpeed; /// 画布背景色 final Color backgroundColor; /// 是否显示坐标网格 final bool showGrid; const StrokeCanvasWidget({ super.key, required this.strokes, this.enableReplay = false, this.replaySpeed = 1.0, this.backgroundColor = Colors.white, this.showGrid = false, }); @override State createState() => _StrokeCanvasWidgetState(); } class _StrokeCanvasWidgetState extends State with SingleTickerProviderStateMixin { /// 回放动画控制器 AnimationController? _replayController; /// 当前回放进度(0.0-1.0) double _replayProgress = 0.0; /// 缩放比例 double _scale = 1.0; /// 平移偏移量 Offset _offset = Offset.zero; /// 缩放手势起始比例 double _previousScale = 1.0; /// 离屏缓存(已绘制的静态笔迹) ui.Image? _cachedImage; /// 是否需要重建缓存 bool _needsRebuildCache = true; @override void initState() { super.initState(); if (widget.enableReplay) { _startReplay(); } } @override void didUpdateWidget(covariant StrokeCanvasWidget oldWidget) { super.didUpdateWidget(oldWidget); if (widget.strokes != oldWidget.strokes) { _needsRebuildCache = true; } if (widget.enableReplay && !oldWidget.enableReplay) { _startReplay(); } } @override void dispose() { _replayController?.dispose(); _cachedImage?.dispose(); super.dispose(); } /// 启动笔迹回放动画 void _startReplay() { // 计算总回放时长(基于笔迹时间跨度) if (widget.strokes.isEmpty) return; int totalDuration = 0; for (final stroke in widget.strokes) { if (stroke.points.isNotEmpty) { totalDuration = max(totalDuration, stroke.points.last.timestamp - stroke.points.first.timestamp); } } // 根据回放速度调整时长 final durationMs = (totalDuration / widget.replaySpeed).round(); _replayController = AnimationController( vsync: this, duration: Duration(milliseconds: max(durationMs, 1000)), ); _replayController!.addListener(() { setState(() { _replayProgress = _replayController!.value; }); }); _replayController!.forward(); } @override Widget build(BuildContext context) { return GestureDetector( // 缩放手势 onScaleStart: (details) { _previousScale = _scale; }, onScaleUpdate: (details) { setState(() { _scale = (_previousScale * details.scale).clamp(0.5, 5.0); _offset += details.focalPointDelta; }); }, // 双击重置缩放 onDoubleTap: () { setState(() { _scale = 1.0; _offset = Offset.zero; }); }, child: ClipRect( child: CustomPaint( painter: StrokePainter( strokes: widget.strokes, replayProgress: widget.enableReplay ? _replayProgress : 1.0, scale: _scale, offset: _offset, backgroundColor: widget.backgroundColor, showGrid: widget.showGrid, ), size: Size.infinite, ), ), ); } } /* ========== 笔迹渲染Painter ========== */ /// CustomPainter实现 - 高性能笔迹绘制 class StrokePainter extends CustomPainter { final List strokes; final double replayProgress; final double scale; final Offset offset; final Color backgroundColor; final bool showGrid; StrokePainter({ required this.strokes, this.replayProgress = 1.0, this.scale = 1.0, this.offset = Offset.zero, this.backgroundColor = Colors.white, this.showGrid = false, }); @override void paint(Canvas canvas, Size size) { // 绘制背景 canvas.drawRect( Rect.fromLTWH(0, 0, size.width, size.height), Paint()..color = backgroundColor, ); // 绘制网格(可选) if (showGrid) { _drawGrid(canvas, size); } // 保存画布状态,应用变换 canvas.save(); canvas.translate(offset.dx, offset.dy); canvas.scale(scale); // 计算当前回放应显示的总点数 int totalPoints = 0; for (final stroke in strokes) { totalPoints += stroke.points.length; } final visiblePoints = (totalPoints * replayProgress).round(); // 逐笔画渲染 int pointCounter = 0; for (final stroke in strokes) { if (pointCounter >= visiblePoints) break; final strokeVisibleCount = min( stroke.points.length, visiblePoints - pointCounter, ); if (strokeVisibleCount > 1) { _drawStroke(canvas, stroke, strokeVisibleCount); } pointCounter += stroke.points.length; } canvas.restore(); } /// 绘制单个笔画(贝塞尔曲线平滑 + 压力笔锋) void _drawStroke(Canvas canvas, StrokeData stroke, int visibleCount) { if (visibleCount < 2) return; final paint = Paint() ..color = stroke.color ..strokeCap = StrokeCap.round ..strokeJoin = StrokeJoin.round ..style = PaintingStyle.stroke ..isAntiAlias = true; // 使用压力感应笔锋渲染 for (int i = 1; i < visibleCount; i++) { final prev = stroke.points[i - 1]; final curr = stroke.points[i]; // 根据压力值计算笔画宽度 // 压力越大,笔画越粗;落笔和抬笔时笔画变细(模拟笔锋效果) final pressureWidth = _calculatePressureWidth( stroke.baseWidth, prev.pressure, curr.pressure, i, visibleCount, ); paint.strokeWidth = pressureWidth; if (i >= 2 && i < visibleCount) { // 三次贝塞尔曲线平滑(消除折线锯齿) final prevPrev = stroke.points[i - 2]; final cp1x = prev.x + (curr.x - prevPrev.x) / 6.0; final cp1y = prev.y + (curr.y - prevPrev.y) / 6.0; final cp2x = curr.x - (curr.x - prev.x) / 6.0; final cp2y = curr.y - (curr.y - prev.y) / 6.0; final path = Path() ..moveTo(prev.x, prev.y) ..cubicTo(cp1x, cp1y, cp2x, cp2y, curr.x, curr.y); canvas.drawPath(path, paint); } else { // 前两个点使用直线连接 canvas.drawLine( ui.Offset(prev.x, prev.y), ui.Offset(curr.x, curr.y), paint, ); } } } /// 根据压力值计算笔画宽度(模拟笔锋效果) /// 落笔时宽度从细变粗,行笔中根据压力变化,抬笔时由粗变细 double _calculatePressureWidth( double baseWidth, double prevPressure, double currPressure, int index, int totalPoints, ) { // 压力插值 final avgPressure = (prevPressure + currPressure) / 2.0; // 基础宽度根据压力缩放(0.3x - 2.0x) double width = baseWidth * (0.3 + avgPressure * 1.7); // 落笔效果:前5个点逐渐增加宽度 if (index < 5) { width *= (index / 5.0); } // 抬笔效果:最后5个点逐渐减小宽度 final remaining = totalPoints - index; if (remaining < 5) { width *= (remaining / 5.0); } return max(width, 0.5); // 最小宽度0.5 } /// 绘制辅助网格 void _drawGrid(Canvas canvas, Size size) { final gridPaint = Paint() ..color = Colors.grey.withValues(alpha: 0.2) ..strokeWidth = 0.5; const gridSize = 20.0; // 竖线 for (double x = 0; x < size.width; x += gridSize) { canvas.drawLine( ui.Offset(x, 0), ui.Offset(x, size.height), gridPaint, ); } // 横线 for (double y = 0; y < size.height; y += gridSize) { canvas.drawLine( ui.Offset(0, y), ui.Offset(size.width, y), gridPaint, ); } } @override bool shouldRepaint(covariant StrokePainter oldDelegate) { return oldDelegate.replayProgress != replayProgress || oldDelegate.strokes != strokes || oldDelegate.scale != scale || oldDelegate.offset != offset; } } /* ========== 笔迹工具函数 ========== */ /// 笔迹数据工具类 class StrokeUtils { /// 道格拉斯-普克算法简化笔迹点(减少数据量) /// epsilon: 简化阈值(越大简化越多) static List simplifyStroke( List points, { double epsilon = 1.0, }) { if (points.length <= 2) return points; // 找到距离首尾连线最远的点 double maxDistance = 0; int maxIndex = 0; final first = points.first; final last = points.last; for (int i = 1; i < points.length - 1; i++) { final d = _perpendicularDistance(points[i], first, last); if (d > maxDistance) { maxDistance = d; maxIndex = i; } } // 如果最大距离大于阈值,递归简化 if (maxDistance > epsilon) { final left = simplifyStroke(points.sublist(0, maxIndex + 1), epsilon: epsilon); final right = simplifyStroke(points.sublist(maxIndex), epsilon: epsilon); return [...left.sublist(0, left.length - 1), ...right]; } else { return [first, last]; } } /// 计算点到线段的垂直距离 static double _perpendicularDistance( StrokePointData point, StrokePointData lineStart, StrokePointData lineEnd, ) { final dx = lineEnd.x - lineStart.x; final dy = lineEnd.y - lineStart.y; if (dx == 0 && dy == 0) { return sqrt(pow(point.x - lineStart.x, 2) + pow(point.y - lineStart.y, 2)); } final t = ((point.x - lineStart.x) * dx + (point.y - lineStart.y) * dy) / (dx * dx + dy * dy); final clampedT = t.clamp(0.0, 1.0); final closestX = lineStart.x + clampedT * dx; final closestY = lineStart.y + clampedT * dy; return sqrt(pow(point.x - closestX, 2) + pow(point.y - closestY, 2)); } /// 计算笔迹边界框(用于视窗适配) static Rect calculateBounds(List strokes) { double minX = double.infinity, minY = double.infinity; double maxX = double.negativeInfinity, maxY = double.negativeInfinity; for (final stroke in strokes) { for (final point in stroke.points) { minX = min(minX, point.x); minY = min(minY, point.y); maxX = max(maxX, point.x); maxY = max(maxY, point.y); } } if (minX == double.infinity) return Rect.zero; return Rect.fromLTRB(minX, minY, maxX, maxY); } /// 计算笔迹书写速度(像素/毫秒) static double calculateWritingSpeed(List points) { if (points.length < 2) return 0; double totalDistance = 0; for (int i = 1; i < points.length; i++) { totalDistance += sqrt( pow(points[i].x - points[i - 1].x, 2) + pow(points[i].y - points[i - 1].y, 2), ); } final totalTime = points.last.timestamp - points.first.timestamp; return totalTime > 0 ? totalDistance / totalTime : 0; } }