/* * 自然写互动课堂应用开发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 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 mCompletedStrokes = new ArrayList<>(); /* 当前正在书写的笔画(按笔MAC索引) */ private final Map mActiveStrokes = new HashMap<>(); /* 每支笔的颜色映射 */ private final Map 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 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); } }