469 lines
12 KiB
Dart
469 lines
12 KiB
Dart
/// 自然写互动课堂手机端应用软件 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<StrokePointData> 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<StrokeData> 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<StrokeCanvasWidget> createState() => _StrokeCanvasWidgetState();
|
||
}
|
||
|
||
class _StrokeCanvasWidgetState extends State<StrokeCanvasWidget>
|
||
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<StrokeData> 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<StrokePointData> simplifyStroke(
|
||
List<StrokePointData> 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<StrokeData> 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<StrokePointData> 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;
|
||
}
|
||
}
|