416 lines
14 KiB
Java
416 lines
14 KiB
Java
/*
|
||
* 自然写互动课堂应用开发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);
|
||
}
|
||
}
|