Files
2026-03-22 15:24:40 +08:00

416 lines
14 KiB
Java
Raw Permalink 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.
/*
* 自然写互动课堂应用开发SDK软件 V1.0
* StrokeCanvas - Android端笔迹渲染自定义View
*
* 功能说明:
* 1. 实时笔迹渲染(贝塞尔曲线平滑绘制)
* 2. 压力感应笔锋效果(根据压力值动态调整线宽)
* 3. 多笔同屏渲染(不同颜色区分不同学生)
* 4. 笔迹重播动画(按时间序列回放书写过程)
* 5. 离屏缓冲双缓冲渲染(避免闪烁)
* 6. 触摸与点阵笔混合输入支持
*/
package com.writech.sdk.android;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.PorterDuff;
import android.graphics.RectF;
import android.os.SystemClock;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 笔迹渲染画布组件
* 支持实时绘制点阵笔和触摸屏输入的笔迹数据
*/
public class StrokeCanvas extends View {
private static final String TAG = "WritechStrokeCanvas";
/* 默认画笔颜色 */
private static final int DEFAULT_STROKE_COLOR = Color.BLACK;
/* 默认最小线宽(像素) */
private static final float MIN_STROKE_WIDTH = 1.5f;
/* 默认最大线宽(像素) */
private static final float MAX_STROKE_WIDTH = 8.0f;
/* 最大压力值(点阵笔12位ADC) */
private static final float MAX_PRESSURE = 4095.0f;
/* ========== 内部数据结构 ========== */
/** 单个采样点(包含坐标、压力、时间戳) */
private static class StrokePoint {
float x;
float y;
float pressure; /* 归一化压力 0.0~1.0 */
long timestamp; /* 毫秒时间戳 */
StrokePoint(float x, float y, float pressure, long timestamp) {
this.x = x;
this.y = y;
this.pressure = pressure;
this.timestamp = timestamp;
}
}
/** 一笔数据(从落笔到抬笔) */
private static class Stroke {
String penMac; /* 来源笔MAC地址 */
int color; /* 笔迹颜色 */
List<StrokePoint> points; /* 采样点列表 */
Stroke(String penMac, int color) {
this.penMac = penMac;
this.color = color;
this.points = new ArrayList<>();
}
}
/* ========== 成员变量 ========== */
/* 离屏缓冲Bitmap(双缓冲渲染) */
private Bitmap mBufferBitmap;
private Canvas mBufferCanvas;
/* 绘制画笔 */
private final Paint mStrokePaint;
/* 背景清除画笔 */
private final Paint mClearPaint;
/* 已完成的笔画列表(历史记录) */
private final List<Stroke> mCompletedStrokes = new ArrayList<>();
/* 当前正在书写的笔画(按笔MAC索引) */
private final Map<String, Stroke> mActiveStrokes = new HashMap<>();
/* 每支笔的颜色映射 */
private final Map<String, Integer> mPenColorMap = new HashMap<>();
/* 笔迹颜色分配计数器 */
private int mColorIndex = 0;
/* 预定义的笔迹颜色列表(用于多学生区分) */
private static final int[] STROKE_COLORS = {
Color.BLACK,
Color.parseColor("#1565C0"), /* 蓝色 */
Color.parseColor("#C62828"), /* 红色 */
Color.parseColor("#2E7D32"), /* 绿色 */
Color.parseColor("#E65100"), /* 橙色 */
Color.parseColor("#6A1B9A"), /* 紫色 */
Color.parseColor("#00838F"), /* 青色 */
Color.parseColor("#4E342E"), /* 棕色 */
};
/* 是否启用压力感应笔锋 */
private boolean mPressureEnabled = true;
/* 笔迹重播相关 */
private boolean mIsReplaying = false;
private int mReplayStrokeIndex = 0;
private int mReplayPointIndex = 0;
private long mReplayStartTime = 0;
/* ========== 构造函数 ========== */
public StrokeCanvas(Context context) {
this(context, null);
}
public StrokeCanvas(Context context, AttributeSet attrs) {
super(context, attrs);
/* 初始化笔迹画笔 */
mStrokePaint = new Paint();
mStrokePaint.setAntiAlias(true); /* 抗锯齿 */
mStrokePaint.setDither(true); /* 防抖动 */
mStrokePaint.setStyle(Paint.Style.STROKE);
mStrokePaint.setStrokeJoin(Paint.Join.ROUND); /* 圆角连接 */
mStrokePaint.setStrokeCap(Paint.Cap.ROUND); /* 圆头笔触 */
/* 初始化清除画笔 */
mClearPaint = new Paint();
mClearPaint.setColor(Color.WHITE);
}
/* ========== View生命周期 ========== */
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
/* 创建离屏缓冲Bitmap */
if (mBufferBitmap != null) {
mBufferBitmap.recycle();
}
mBufferBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
mBufferCanvas = new Canvas(mBufferBitmap);
mBufferCanvas.drawColor(Color.WHITE);
/* 重绘所有历史笔画到缓冲区 */
redrawAllStrokes();
}
@Override
protected void onDraw(Canvas canvas) {
/* 将离屏缓冲Bitmap绘制到屏幕 */
if (mBufferBitmap != null) {
canvas.drawBitmap(mBufferBitmap, 0, 0, null);
}
/* 绘制当前活跃的笔画(实时部分) */
for (Stroke stroke : mActiveStrokes.values()) {
drawStrokeRealtime(canvas, stroke);
}
}
/* ========== 点阵笔数据输入接口 ========== */
/**
* 接收笔落下事件(开始新的一笔)
* @param penMac 笔设备MAC地址
*/
public void onPenDown(String penMac) {
int color = getPenColor(penMac);
Stroke stroke = new Stroke(penMac, color);
mActiveStrokes.put(penMac, stroke);
}
/**
* 接收笔迹坐标数据
* @param penMac 笔MAC
* @param screenX 屏幕X坐标(已经过坐标变换)
* @param screenY 屏幕Y坐标
* @param pressure 原始压力值(0-4095
*/
public void onStrokePoint(String penMac, float screenX, float screenY,
int pressure) {
Stroke stroke = mActiveStrokes.get(penMac);
if (stroke == null) {
/* 如果没有活跃笔画,自动创建 */
onPenDown(penMac);
stroke = mActiveStrokes.get(penMac);
}
/* 归一化压力值 */
float normalizedPressure = Math.min(1.0f, (float) pressure / MAX_PRESSURE);
long timestamp = SystemClock.elapsedRealtime();
stroke.points.add(new StrokePoint(screenX, screenY, normalizedPressure, timestamp));
/* 触发重绘(仅绘制增量部分,避免全量刷新) */
int pointCount = stroke.points.size();
if (pointCount >= 2) {
StrokePoint prev = stroke.points.get(pointCount - 2);
StrokePoint curr = stroke.points.get(pointCount - 1);
/* 仅刷新受影响的矩形区域(性能优化) */
float padding = MAX_STROKE_WIDTH + 2;
float left = Math.min(prev.x, curr.x) - padding;
float top = Math.min(prev.y, curr.y) - padding;
float right = Math.max(prev.x, curr.x) + padding;
float bottom = Math.max(prev.y, curr.y) + padding;
invalidate((int) left, (int) top, (int) right, (int) bottom);
}
}
/**
* 接收笔抬起事件(一笔结束)
* 将当前笔画固化到缓冲区并归档
*/
public void onPenUp(String penMac) {
Stroke stroke = mActiveStrokes.remove(penMac);
if (stroke != null && stroke.points.size() > 1) {
/* 绘制到离屏缓冲区(固化) */
drawStrokeToBuffer(stroke);
/* 添加到已完成列表 */
mCompletedStrokes.add(stroke);
}
invalidate();
}
/* ========== 笔迹渲染核心算法 ========== */
/**
* 实时渲染笔画(使用贝塞尔曲线平滑)
* 在每次onDraw中调用,绘制当前活跃的笔画
*/
private void drawStrokeRealtime(Canvas canvas, Stroke stroke) {
List<StrokePoint> points = stroke.points;
if (points.size() < 2) return;
mStrokePaint.setColor(stroke.color);
for (int i = 1; i < points.size(); i++) {
StrokePoint p0 = points.get(i - 1);
StrokePoint p1 = points.get(i);
/* 根据压力计算线宽 */
float width = calculateStrokeWidth(p0.pressure, p1.pressure);
mStrokePaint.setStrokeWidth(width);
if (i >= 2) {
/* 使用二次贝塞尔曲线平滑绘制 */
StrokePoint pPrev = points.get(i - 2);
float midX0 = (pPrev.x + p0.x) / 2;
float midY0 = (pPrev.y + p0.y) / 2;
float midX1 = (p0.x + p1.x) / 2;
float midY1 = (p0.y + p1.y) / 2;
Path path = new Path();
path.moveTo(midX0, midY0);
path.quadTo(p0.x, p0.y, midX1, midY1);
canvas.drawPath(path, mStrokePaint);
} else {
/* 前两个点直接画直线 */
canvas.drawLine(p0.x, p0.y, p1.x, p1.y, mStrokePaint);
}
}
}
/**
* 将完成的笔画绘制到离屏缓冲区
*/
private void drawStrokeToBuffer(Stroke stroke) {
if (mBufferCanvas == null) return;
drawStrokeRealtime(mBufferCanvas, stroke);
}
/**
* 根据压力值计算线宽(笔锋效果)
* 使用两个相邻点的平均压力,平滑过渡
*
* @param pressure0 前一点压力(归一化)
* @param pressure1 当前点压力(归一化)
* @return 线宽(像素)
*/
private float calculateStrokeWidth(float pressure0, float pressure1) {
if (!mPressureEnabled) {
return (MIN_STROKE_WIDTH + MAX_STROKE_WIDTH) / 2;
}
float avgPressure = (pressure0 + pressure1) / 2.0f;
/* 压力-宽度映射曲线(使用幂函数增加笔锋感) */
float normalized = (float) Math.pow(avgPressure, 0.7);
return MIN_STROKE_WIDTH + normalized * (MAX_STROKE_WIDTH - MIN_STROKE_WIDTH);
}
/* ========== 多笔颜色管理 ========== */
/** 获取或分配笔的颜色 */
private int getPenColor(String penMac) {
Integer color = mPenColorMap.get(penMac);
if (color == null) {
color = STROKE_COLORS[mColorIndex % STROKE_COLORS.length];
mPenColorMap.put(penMac, color);
mColorIndex++;
}
return color;
}
/** 手动设置某支笔的颜色 */
public void setPenColor(String penMac, int color) {
mPenColorMap.put(penMac, color);
}
/* ========== 画布操作 ========== */
/** 清除所有笔迹 */
public void clearAll() {
mCompletedStrokes.clear();
mActiveStrokes.clear();
if (mBufferCanvas != null) {
mBufferCanvas.drawColor(Color.WHITE);
}
invalidate();
}
/** 撤销最后一笔 */
public boolean undo() {
if (mCompletedStrokes.isEmpty()) return false;
mCompletedStrokes.remove(mCompletedStrokes.size() - 1);
redrawAllStrokes();
invalidate();
return true;
}
/** 重绘所有历史笔画到缓冲区 */
private void redrawAllStrokes() {
if (mBufferCanvas == null) return;
mBufferCanvas.drawColor(Color.WHITE);
for (Stroke stroke : mCompletedStrokes) {
drawStrokeToBuffer(stroke);
}
}
/** 导出当前画布为Bitmap */
public Bitmap exportBitmap() {
Bitmap export = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888);
Canvas exportCanvas = new Canvas(export);
draw(exportCanvas);
return export;
}
/** 获取已完成的笔画数量 */
public int getStrokeCount() {
return mCompletedStrokes.size();
}
/** 设置是否启用压力笔锋效果 */
public void setPressureEnabled(boolean enabled) {
mPressureEnabled = enabled;
}
/* ========== 触摸屏输入支持 ========== */
@Override
public boolean onTouchEvent(MotionEvent event) {
/* 使用"touch"作为虚拟笔MAC */
String touchMac = "touch_input";
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
onPenDown(touchMac);
onStrokePoint(touchMac, event.getX(), event.getY(),
(int)(event.getPressure() * MAX_PRESSURE));
return true;
case MotionEvent.ACTION_MOVE:
/* 处理历史点(Android会批量发送MOVE事件) */
for (int i = 0; i < event.getHistorySize(); i++) {
onStrokePoint(touchMac,
event.getHistoricalX(i),
event.getHistoricalY(i),
(int)(event.getHistoricalPressure(i) * MAX_PRESSURE));
}
onStrokePoint(touchMac, event.getX(), event.getY(),
(int)(event.getPressure() * MAX_PRESSURE));
return true;
case MotionEvent.ACTION_UP:
onStrokePoint(touchMac, event.getX(), event.getY(),
(int)(event.getPressure() * MAX_PRESSURE));
onPenUp(touchMac);
return true;
}
return super.onTouchEvent(event);
}
}