software copyright

This commit is contained in:
jiahong
2026-03-22 15:24:40 +08:00
parent e303bb868a
commit 60f336e345
155 changed files with 127262 additions and 0 deletions
@@ -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;
}
}