Files
system-design/software-copyright/06-writech-app-mobile/ui/common/stroke_canvas.dart
T
2026-03-22 15:24:40 +08:00

469 lines
12 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 自然写互动课堂手机端应用软件 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;
}
}