software copyright
This commit is contained in:
@@ -0,0 +1,468 @@
|
||||
/// 自然写互动课堂手机端应用软件 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user