本篇将介绍如何为我们的画板应用添加分页展示功能,让用户可以创建多个画布并在它们之间轻松切换。这章没有啥知识点的讲解,主要介绍一下每页保存的数据结构是什么样的。
一、ListView
多页数据的管理我们使用ListView。之前有文章讲过ListView这里就不多赘述了,感兴趣的读者可以看看。Android最常用的控件ListView(详解) 。
直接上图例和代码:
//绑定适配器(传入handler)
adapter = new PictureAdapter(mContext,R.layout.list_item,listDate,handler);
viewMember.lv_tables.setAdapter(adapter);
(1)PictureView.java
//保存某一页的视图信息
public class PictureView {//保存比例信息Matrix matrixMain = new Matrix();//保存撤销和恢复的信息private ArrayList<PaintDates> paintedList = new ArrayList<>();public ArrayList<MessageStrokes> getCancelList() {return cancelList;}public void setCancelList(ArrayList<MessageStrokes> cancelList) {this.cancelList = cancelList;}public ArrayList<MessageStrokes> getRecoverList() {return recoverList;}public void setRecoverList(ArrayList<MessageStrokes> recoverList) {this.recoverList = recoverList;}private ArrayList<MessageStrokes> cancelList = new ArrayList<>();private ArrayList<MessageStrokes> recoverList = new ArrayList<>();//设置一个专门为撤销,回退服务的list//用来保存每一个操作的意义(可能是单笔的,可能是多笔)//view上private Bitmap cacheBitmap;private Canvas cacheCanvas ;public PictureView(int width, int height) {cacheBitmap = Bitmap.createBitmap(width,height,Bitmap.Config.ARGB_4444);cacheCanvas = new Canvas(cacheBitmap);}public ArrayList<PaintDates> getPaintedList() {return paintedList;}public void setPaintedList(ArrayList<PaintDates> paintedList) {this.paintedList = paintedList;}public Bitmap getCacheBitmap() {return cacheBitmap;}public void setCacheBitmap(Bitmap cacheBitmap) {this.cacheBitmap = cacheBitmap;}public Canvas getCacheCanvas() {return cacheCanvas;}public void setCacheCanvas(Canvas cacheCanvas) {this.cacheCanvas = cacheCanvas;}}
PictureView
是一个数据模型类,用于保存画板中某一页的完整状态信息。
- (
cacheBitmap
和cacheCanvas
):保存当前页面的最终渲染结果
paintedList:
存储所有的笔画数据
cancelList
:存储已执行但可撤销的操作
recoverList
:存储已撤销但可恢复的操作
matrixMain
:保存缩放、平移、旋转等变换信息
(2)PictureAdapter.java
//适配器
public class PictureAdapter extends ArrayAdapter<PictureView> {//用来判断当前View上显示的时哪个(默认为第一个)public int localNum = 1;private Handler handler;public PictureAdapter(@NonNull Context context, int resource, @NonNull List<PictureView> objects, Handler handler) {super(context, resource, objects);this.handler = handler;}@SuppressLint("SetTextI18n")@NonNull@Overridepublic View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {PictureView p = getItem(position);View view;//新增一个内部类 ViewHolder,用于对控件的实例进行缓存ViewHolder viewHolder;if (convertView==null){//为每一个子项加载设定的布局view= LayoutInflater.from(getContext()).inflate(R.layout.list_item,parent,false);viewHolder= new ViewHolder();//分别获取 imageview 和 textview 的实例viewHolder.image =view.findViewById(R.id.iv_image);viewHolder.imageNum =view.findViewById(R.id.tv_num);viewHolder.imageDelete=view.findViewById(R.id.bt_delete_item);viewHolder.layout = view.findViewById(R.id.fl_item);view.setTag(viewHolder);//将 viewHolder 存储在 view 中}else {view=convertView;viewHolder= (ViewHolder) view.getTag();//重新获取 viewHolder}// 设置要显示的内容viewHolder.image.setImageBitmap(p.getCacheBitmap());viewHolder.imageNum.setText((position+1)+"");if((position+1)==localNum){//008FFBviewHolder.imageNum.setTextColor(Color.parseColor("#008FFB"));}else {viewHolder.imageNum.setTextColor(Color.WHITE);}//按钮点击事件(使用handler)viewHolder.imageDelete.setOnClickListener(v->{//创建一个线程Thread t = new Thread(() -> {Message m = handler.obtainMessage();m.what = 0x101;m.arg1 = position;handler.sendMessage(m);});t.start();});viewHolder.layout.setOnClickListener(v->{Thread t =new Thread(() -> {Message m = handler.obtainMessage();m.what = 0x102;m.arg1 = position;handler.sendMessage(m);});t.start();});return view;}private static class ViewHolder {TextView imageNum;ImageView image;ImageButton imageDelete;FrameLayout layout;}}
PictureAdapter中的点击事件,通过Handler传递。
(3)Handler
@SuppressLint("HandlerLeak")
private void initHandler() {handler = new Handler(Looper.getMainLooper()){@SuppressLint("SetTextI18n")@Overridepublic void handleMessage(@NonNull Message msg) {switch (msg.what){case 0x101://删除//弹出提升框now = msg.arg1;new AlertDialog.Builder(mContext).setTitle("提示").setMessage("确定要删除 "+ (now+1) +"号视图吗?").setPositiveButton("确定", (dialogInterface, i) -> {//是否为删除的为当前显示的视图System.out.println("AAAAAAAAAA: "+ (now+1) +" "+NowNum);if((now+1)==NowNum&&now==0){if(total == 1){Toast.makeText(mContext, "您无法删除最后一个视图", Toast.LENGTH_SHORT).show();}else {total--;//清空内存clearBitmap(listDate.get(now));listDate.remove(now);adapter.localNum = NowNum;blackboardView1.updateView(NowNum-1);}}else if((now+1)==NowNum&&now!=0){//向上移动total--;NowNum--;clearBitmap(listDate.get(now));listDate.remove(now);adapter.localNum = NowNum;blackboardView1.updateView(NowNum-1);}else if((now+1)!=NowNum&&(now+1)>NowNum){//不动total--;clearBitmap(listDate.get(now));listDate.remove(now);}else {//整体上移total--;NowNum--;clearBitmap(listDate.get(now));listDate.remove(now);adapter.localNum = NowNum;blackboardView1.updateView(NowNum-1);}viewMember.tv_whereForNum.setText(numToString(NowNum)+"/"+numToString(total));adapter.notifyDataSetChanged();}).setNegativeButton("取消",null).show();break;case 0x102://点击试图切换now = msg.arg1;NowNum = now+1;adapter.localNum = NowNum;//发送消息进行上传(目前感觉没必要上传)
// Message message = new Message();
// message.what = 16;
// message.arg1 = NowNum;
// System.out.println("popopopo nowNum:"+NowNum);
// //operateHandler//同时更新的还有底部的数字viewMember.tv_whereForNum.setText(numToString(NowNum)+"/"+numToString(total));
// oldBitmap = blackboardView1.cacheBitmap;
// operateHandler.sendEmptyMessage(100);//通知截屏上传(在没更新之前)blackboardView1.updateView(NowNum-1);adapter.notifyDataSetChanged();break;case 0x103://漫游:显示比例数值viewMember.tv_zoomNum.setText(FTOString((Float) msg.obj));break;case 0x104://down的是时候不让获取获取焦点viewMember.bt_tables.setEnabled(false);viewMember.bt_last.setEnabled(false);viewMember.bt_next.setEnabled(false);viewMember.bt_add.setEnabled(false);viewMember.tv_whereForNum.setEnabled(false);Resources resources_table = mContext.getResources();if (viewMember.lv_tables.getVisibility() == View.VISIBLE) {viewMember.lv_tables.setVisibility(View.GONE);viewMember.tv_whereForNum.setTextColor(Color.WHITE);}if(viewMember.ll_more.getVisibility()==View.VISIBLE){viewMember.ll_more.setVisibility(View.GONE);Drawable imageDrawable = resources_table.getDrawable(R.drawable.tables_uncheck);viewMember.bt_tables.setBackground(imageDrawable);}//开始工具类的按钮//1.首先要让下面一排子的东西点不了viewMember.bt_pen.setEnabled(false);viewMember.bt_eraser.setEnabled(false);viewMember.bt_revoke.setEnabled(false);viewMember.bt_recover.setEnabled(false);viewMember.bt_zoom.setEnabled(false);//2.布局恢复if(viewMember.ll_penWidth.getVisibility() == View.VISIBLE){//这个就证明在画笔的行列viewMember.bt_width_1.setEnabled(false);viewMember.bt_width_2.setEnabled(false);viewMember.bt_width_3.setEnabled(false);viewMember.bt_width_4.setEnabled(false);viewMember.bt_width_5.setEnabled(false);viewMember.bt_penColor.setEnabled(false);if(viewMember.ll_colorAndAlpha.getVisibility() == View.VISIBLE){viewMember.ll_colorAndAlpha.setVisibility(View.GONE);}}if(viewMember.ll_eraser.getVisibility() == View.VISIBLE){viewMember.bt_son_eraser.setEnabled(false);viewMember.bt_handwriting_eraser.setEnabled(false);viewMember.sb_clear_sliding.setEnabled(false);}break;case 0x105://up的时候解封viewMember.bt_tables.setEnabled(true);viewMember.bt_last.setEnabled(true);viewMember.bt_next.setEnabled(true);viewMember.bt_add.setEnabled(true);viewMember.tv_whereForNum.setEnabled(true);viewMember.bt_pen.setEnabled(true);viewMember.bt_eraser.setEnabled(true);viewMember.bt_revoke.setEnabled(true);viewMember.bt_recover.setEnabled(true);viewMember.bt_zoom.setEnabled(true);if(viewMember.ll_penWidth.getVisibility() == View.VISIBLE){//这个就证明在画笔的行列viewMember.bt_width_1.setEnabled(true);viewMember.bt_width_2.setEnabled(true);viewMember.bt_width_3.setEnabled(true);viewMember.bt_width_4.setEnabled(true);viewMember.bt_width_5.setEnabled(true);viewMember.bt_penColor.setEnabled(true);}if(viewMember.ll_eraser.getVisibility() == View.VISIBLE){viewMember.bt_son_eraser.setEnabled(true);viewMember.bt_handwriting_eraser.setEnabled(true);viewMember.sb_clear_sliding.setEnabled(true);}break;case 0x106: //电子笔清除屏幕new AlertDialog.Builder(mContext).setTitle("提示").setMessage("确定要清屏吗?").setPositiveButton("确定", (dialogInterface, i) -> {blackboardView1.clear();blackboardView1.isDialog = false;}).setNegativeButton("取消",(dialogInterface, i) -> {blackboardView1.isDialog = false;}).show().setCanceledOnTouchOutside (false);blackboardView1.clear_hardware();//清除其他笔画}super.handleMessage(msg);}};
}
Handler 涉及到了功能:这里涉及到后面要讲的功能,这里简单说下
0x101 :当ListView点击删除时调用,弹出 AlertDialog 要求用户确定操作。
0x102 :点击切换视图,主界面显示对应页的画布。
0x103 :放大缩小时,显示比例数值。比如50% , 300%。
0x104 :用户画线的时候,不允许操作ListView。
0x105 :没有写画时允许操作。
0x106:电子笔点击按钮后,调用清屏功能。
(4)更新画布 updateView
//切换视图,刷新
public void updateView(int whereView){//对所有数据进行更新ViewNum = whereView;mPaintedList = mListDate.get(ViewNum).getPaintedList();mCancelList = mListDate.get(ViewNum).getCancelList();mRecoverList = mListDate.get(ViewNum).getRecoverList();cacheBitmap = mListDate.get(ViewNum).getCacheBitmap();cacheCanvas = mListDate.get(ViewNum).getCacheCanvas();mMatrixMain = mListDate.get(ViewNum).matrixMain;//传一下handlermMatrixMain.getValues(mainDate);Message m = this.handler.obtainMessage();m.what = 0x103;m.obj = mainDate[0];this.handler.sendMessage(m);cacheCanvas.drawColor(0,PorterDuff.Mode.CLEAR);bottomCanvas.drawColor(0,PorterDuff.Mode.CLEAR);invalidateReason = REASON_RE;invalidate();
}
将 PictureView 中的对象赋值给当前视图即可。
二、PictureView中使用的实体类
这里具体介绍一下 PaintDates 和 MessageStrokes 具体内容。
(1)PaintDates.java
//保存每一笔的情况
//之后要实现笔锋效果(保存的就不是paint和path了)
public class PaintDates {//没必要每次都new一个Paint:就透明的与width不同Paint mPaint;Path mPath; //专门为透明度服务List<PathAndWidth> mOnePaths ;//保存每一笔画的偏移ArrayList<Matrix> mMatrixS = new ArrayList<>();//设置一个model来判断是这个类是点还是线(经历了move就是线,没有就是点)private int lineModel = 1;//首先开始为点final int POINT = 1;//点final int LINE = 2 ;//线final int DOTTED_LINE = 3;//虚线//保存起点的x,y;float mx;float my;float mXToMatrix;float mYToMatrix;//初始宽度(为点和虚线提供)float mWidth;//是否为待删除状态(为笔画删除提供服务)private boolean isDelete = false;private boolean isCut = false;//设置一个与他同病相怜的兄弟集合(说白了保存id)public boolean isCut() {return isCut;}public void setCut(boolean cut) {isCut = cut;}public boolean isDelete() {return isDelete;}public void setDelete(boolean delete) {isDelete = delete;}public PaintDates(Paint paint, List<PathAndWidth> path, float x, float y,float width) {mPaint = paint;mOnePaths = path;mx = x;my = y;mXToMatrix = x;mYToMatrix = y;mWidth = width;}public PaintDates(PaintDates pd){mPaint = pd.mPaint;mPath = pd.mPath;mOnePaths = new ArrayList<>();for (int i = 0; i < pd.mOnePaths.size() ; i++) {mOnePaths.add(new PathAndWidth(pd.mOnePaths.get(i)));}for (int i = 0; i <pd.mMatrixS.size() ; i++) {mMatrixS.add(new Matrix(pd.mMatrixS.get(i)));}mx = pd.mx;my = pd.my;mXToMatrix = pd.mXToMatrix;mYToMatrix = pd.mYToMatrix;mWidth = pd.mWidth;lineModel = pd.getLineModel();}public void draw(Canvas canvas){if(lineModel == POINT){drawPoint(canvas);}else if(lineModel == LINE){drawLine(canvas);}else {drawDottedLine(canvas);}}private void drawDottedLine(Canvas canvas) {canvas.drawPath(mPath,mPaint);}//实现包裹效果//为了实现有笔峰的包裹效果,应该使用一个公式:前一个线的宽度=m,当前线的宽度=n -> m/n + 1.public void drawPlus(Canvas canvas){Paint coverPaint = new Paint(mPaint);int A = coverPaint.getAlpha();coverPaint.setColor(Color.parseColor("#000000")); //先暂时换个黑色coverPaint.setAlpha(A);if(lineModel == POINT){//点的高光操作coverPaint.setStrokeWidth(mWidth*2f);canvas.drawPoint(mx,my,coverPaint);}else if(lineModel == LINE){for (PathAndWidth mPath:mOnePaths) {coverPaint.setStrokeWidth(mPath.width*2f);canvas.drawPath(mPath.path,coverPaint);if(mPath.addPaths!=null){for (int i = 0; i <mPath.addPaths.size() ; i++) {//coverPaint.setStrokeWidth(mPath.width*3f);canvas.drawPath(mPath.addPaths.get(i),coverPaint);}}}}else {//虚线包裹coverPaint.setStrokeWidth(mWidth*2f);canvas.drawPath(mPath,coverPaint);}}//实现橡皮擦的高光效果public void drawDottingRed(Canvas canvas,float p){Paint dotRedPaint = new Paint(mPaint);dotRedPaint.setStrokeCap(Paint.Cap.BUTT);dotRedPaint.setStrokeJoin(Paint.Join.BEVEL);PathEffect effect = new DashPathEffect(new float[]{40f,20f,10f,20f},p);dotRedPaint.setXfermode(null);dotRedPaint.setPathEffect(effect);dotRedPaint.setColor(Color.RED);dotRedPaint.setAlpha(100);canvas.drawPath(mPath,dotRedPaint);}//画点的逻辑public void drawPoint(Canvas canvas){mPaint.setStrokeWidth(mWidth);canvas.drawPoint(mx,my,mPaint);}//画线的逻辑public void drawLine(Canvas canvas){for (PathAndWidth mPath:mOnePaths) {mPaint.setStrokeWidth(mPath.width);if(mPath.addPaths != null){
// Paint RedPaint = new Paint(mPaint);
// RedPaint.setColor(Color.RED);for (int i = 0; i <mPath.addPaths.size() ; i++) {canvas.drawPath(mPath.addPaths.get(i),mPaint);}}canvas.drawPath(mPath.path,mPaint);}}//画最后一段即可public void drawPatch(Canvas canvas){PathAndWidth pw = mOnePaths.get(mOnePaths.size()-1);mPaint.setStrokeWidth(pw.width);canvas.drawPath(pw.path,mPaint);if(pw.addPaths!=null){for (int i = 0; i < pw.addPaths.size(); i++) {//canvas.drawPath(pw.addPaths.get(i),mPaint);canvas.drawPath(pw.addPaths.get(i),mPaint);}}}//添加前面一段路径的笔锋pathpublic void drawFrontAddPath(Canvas canvas){PathAndWidth pw = mOnePaths.get(mOnePaths.size()-2);mPaint.setStrokeWidth(pw.width);if(pw.addPaths!=null){for (int i = 0; i < pw.addPaths.size(); i++) {canvas.drawPath(pw.addPaths.get(i),mPaint);}}}public static class PathAndWidth{Path path;//添加的pathArrayList<Path> addPaths;//这个path已经无法满足需求Float width ;//形成path的后一个点float x;float y;//此点的变形float xToMatrix;float yToMatrix;//判断这个线是否要分割boolean isCut = false;//比例float BL = -1;public PathAndWidth(PathAndWidth paw){if(paw.path!=null){path = new Path(paw.path);width = paw.width;}x = paw.x;y = paw.y;xToMatrix = paw.xToMatrix;yToMatrix = paw.yToMatrix;addPaths = paw.addPaths;}public PathAndWidth(Path path, Float width,float x,float y) {this.path = path;this.width = width;this.x = x;this.y = y;//在没有zoom的情况下与原始点相同xToMatrix = x;yToMatrix = y;}//这是为透明度服务的public PathAndWidth(float x,float y){this.x = x;this.y = y;//在没有zoom的情况下与原始点相同xToMatrix = x;yToMatrix = y;}}public int getLineModel() {return lineModel;}public void setLineModel(int lineModel) {this.lineModel = lineModel;}//颜色变化选项(后续有要求在搞)}//思路1:每两个点之间保存一段路径(性能要求非常高)//思路2:保存点的信息化椭圆(需要保存一个方形)
核心成员变量及其作用:
变量名 | 类型 | 作用 |
---|---|---|
mPaint | Paint | 保存绘制这一笔时所用的画笔样式(颜色、透明度、抗锯齿等) |
mOnePaths | List<PathAndWidth> | 这是最关键的数据。它保存了构成这一笔的所有笔触段(PathAndWidth 对象)。每个笔触段都包含一小段路径 (Path ) 和绘制该段路径时动态变化的笔触宽度,以此来实现笔锋效果(压感、速度感应)。 |
mMatrixS | ArrayList<Matrix> | 保存这一笔画所经历过的所有变换矩阵(如平移、缩放、旋转)。这使得该笔画能够跟随画布进行变换,而自身的原始数据保持不变。 |
lineModel | int | 标识这一笔的类型:POINT (一个点)、LINE (一条连续的线)、DOTTED_LINE (一条虚线)。绘制和擦除逻辑会根据不同类型而变化。 |
mx, my | float | 记录这一笔的起始点坐标。对于POINT 类型,这就是点的位置;对于LINE ,这是moveTo 的起点。 |
mWidth | float | 记录这一笔的初始(或基础)宽度。主要用于绘制POINT 和DOTTED_LINE ,因为LINE 的宽度由mOnePaths 中的每个PathAndWidth 动态管理。 |
isDelete , isCut | boolean | 状态标志。用于实现笔画删除和笔画分割功能。前面橡皮擦那章解释过 |
核心方法及其作用:
方法名 | 作用 |
---|---|
draw(Canvas canvas) | 核心绘制方法。根据lineModel 调用对应的绘制方法(drawPoint , drawLine , drawDottedLine ),将这一笔画到传入的Canvas 上。 |
drawPlus(Canvas canvas) | 绘制包裹高光效果。通常用于实现笔画选中状态。它会用原笔画两倍的宽度和特定颜色(代码中为黑色)再画一遍,形成“包裹”或“高亮”效果,提示用户该笔画被选中。 |
drawDottingRed(Canvas canvas, float p) | 绘制虚线效果。用于橡皮擦功能。当用户使用橡皮擦时,可能用红色的虚线来预览即将被擦除的笔画区域。参数p 用于控制虚线模式的偏移,实现动画效果。 |
drawPatch(Canvas canvas) | 仅绘制最后一小段路径。用于实时绘制(即用户手指还在移动时)。为了提高性能,在用户快速绘画时,不需要重绘整个复杂路径,只需绘制最新的一小段(mOnePaths 的最后一个元素)。 |
drawFrontAddPath(...) | 绘制前一段路径的笔锋。这是一个更细粒度的优化,用于确保在连续绘制时,笔锋的衔接部分也能被正确绘制,避免出现断点。 |
PathAndWidth (路径与宽度)
这是一个内部静态类,是 PaintDates
的组成部分。它可以被称为笔触段数据持有者。它的存在是实现笔锋效果的关键。
核心成员变量及其作用:
变量名 | 类型 | 作用 |
---|---|---|
path | Path | 保存一小段贝塞尔曲线路径(由 quadTo 生成)。 |
width | Float | 保存绘制这一小段路径时所用的笔触宽度。笔锋效果就是通过路径不断变化的同时,宽度也随之变化(模拟压感)来实现的。 |
addPaths | ArrayList<Path> | 附加路径。为了实现笔锋效果,前几章有介绍 |
x, y | float | 记录这一小段路径的终点坐标。 |
xToMatrix, yToMatrix | float | 记录经过变换矩阵作用后,终点坐标应该所在的位置。用于坐标转换计算。 |
isCut | boolean | 标识此笔触段是否处于被分割的状态。 |
(2)MessageStrokes.java
//负责保存每一个操作
public class MessageStrokes {int MassageType; //信息种类ArrayList<IdAndStrokes> paintStrokes;//保存每个笔画的Matrix matrix;Matrix mainMatrix;//用于保存右侧的数字public MessageStrokes(int massageType) {MassageType = massageType;}static class IdAndStrokes{int id ;int num ;//针对于橡皮擦单独设置,用来判断需要删除此ID几次。PaintDates pd ;public IdAndStrokes(int id,PaintDates pd) {this.id = id;this.pd = pd;}}
}
//对于笔画删除而言,一定是倒着删除。所以恢复的时候一定是正着来(id+笔画)
这个类的主要作用是:封装并保存一个完整的用户操作,用于实现撤销 (Undo) 和重做 (Redo) 功能,后面介绍撤销恢复时详细说明。本章节篇幅较少,主要是介绍多画布的框架,为后面的章节打好基础。