Android 自定义view,画图板练习

项目中想要实现一个简易画图板的需求,功能并不复杂,就是6个很常用的功能

画图板

陈小默同学有一个比较复杂,强大,高效的CrazyPalette,同学间商业互吹下,哈哈。里面基本常用的操作都有,代码写的很好,只是用的Kotlin,不过我需要的只是一个简单的绘图板,我参考了他的一些思路以及另外一篇android项目 之 记事本 ----- 画板功能之撤销、恢复和清空,做了一个简单的PaintView


1. PaintView

之前在网上看到别的博客说写的双缓冲是这个思路,这里感觉有错误,不清楚我写的这种方式算不算双缓冲。等过了这段加班,我再查查问问确认下 20170524 21:13

思路:使用双缓冲思路,有一个mBitmap,来记录最终的绘制。在手指滑动过程中,屏幕上会实时显示出手指滑动时的绘制轨迹。当手指离开屏幕后,显示最终存有内容的mBitmap

  1. 撤销和恢复利用LinkedList来模拟两个储存记录的
  2. 清空,这里偷懒,直接绘制白色,将之前绘制的内容盖住。也可以考虑使用new PorterDuffXfermode(PorterDuff.Mode.CLEAR)。但有些时候,个人感觉这种方式会出现些莫名其妙的情况,能直接绘制成单一纯色,就不使用PorterDuffXfermode
  3. 橡皮擦,这里使用了PorterDuffXfermode,并setBackgroundColor(Color.WHITE)以及把硬件加速关闭了

关于橡皮擦得额外说明下:
橡皮擦使用new PorterDuffXfermode(PorterDuff.Mode.CLEAR)是为了复习下PorterDuffXfermode,踩踩坑

这里有两个坑,硬件加速和背景穿透。当使用PorterDuff.Mode.CLEAR时,利用的是把上次绘制的东西给清除掉,这就导致在保存绘制的图片后,橡皮擦轨迹是透明的,而之前绘制的内容又被擦除了,就会把图片下方的当前系统背景色显示出来

问题

当我在电脑打开保存的图片时,橡皮擦轨迹会透出我电脑桌面背景的颜色。在手机打开就会透出手机背景颜色

解决办法:
init()方法中,setBackgroundColor(Color.WHITE),绘制了一个白色背景,但这样也就导致了过度绘制

根据需求,这里更好的思路就是把橡皮擦的颜色也直接设置成白色,更加简单而且没有PorterDuffXfermode的坑。但既然是练习,就踩踩坑


代码:

public class PaintView extends View {
    private Paint mPaint;
    private Path mPath;
    private Path eraserPath;
    private Paint eraserPaint;
    private Canvas mCanvas;
    private Bitmap mBitmap;
    private float mLastX, mLastY;//上次的坐标
    private Paint mBitmapPaint;
    //使用LinkedList 模拟栈,来保存 Path
    private LinkedList<PathBean> undoList;
    private LinkedList<PathBean> redoList;
    private boolean isEraserModel;


    public PaintView(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    /***
     * 初始化
     */
    private void init() {
        //关闭硬件加速
        //否则橡皮擦模式下,设置的 PorterDuff.Mode.CLEAR ,实时绘制的轨迹是黑色
         setBackgroundColor(Color.WHITE);//设置白色背景
        setLayerType(View.LAYER_TYPE_SOFTWARE, null);
        //画笔
        mPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
        mPaint.setStrokeWidth(4f);
        mPaint.setAntiAlias(true);
        mPaint.setColor(Color.BLACK);
        mPaint.setStyle(Paint.Style.STROKE);
        mPaint.setStrokeJoin(Paint.Join.ROUND);//使画笔更加圆润
        mPaint.setStrokeCap(Paint.Cap.ROUND);//同上
        mBitmapPaint = new Paint(Paint.DITHER_FLAG);
        //保存签名的画布
        post(new Runnable() {//拿到控件的宽和高
            @Override
            public void run() {
                //获取PaintView的宽和高
                //由于橡皮擦使用的是 Color.TRANSPARENT ,不能使用RGB-565
                mBitmap = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_4444);
                mCanvas = new Canvas(mBitmap);
                //抗锯齿
                mCanvas.setDrawFilter(new PaintFlagsDrawFilter(0, Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG));
                //背景色
                mCanvas.drawColor(Color.WHITE);
            }
        });

        undoList = new LinkedList<>();
        redoList = new LinkedList<>();
    }

    /**
     * 绘制
     */
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        if (mBitmap != null) {
            canvas.drawBitmap(mBitmap, 0, 0, mBitmapPaint);//将mBitmap绘制在canvas上,最终的显示
            if (!isEraserModel) {
                if (null != mPath) {//显示实时正在绘制的path轨迹
                    canvas.drawPath(mPath, mPaint);
                }
            } else {
                if (null != eraserPath) {
                    canvas.drawPath(eraserPath, eraserPaint);
                }
            }
        }
    }

    /**
     * 撤销操作
     */
    public void undo() {
        if (!undoList.isEmpty()) {
            clearPaint();//清除之前绘制内容
            PathBean lastPb = undoList.removeLast();//将最后一个移除
            redoList.add(lastPb);//加入 恢复操作
            //遍历,将Path重新绘制到 mCanvas
            for (PathBean pb : undoList) {
                mCanvas.drawPath(pb.path, pb.paint);
            }
            invalidate();
        }
    }


    /**
     * 恢复操作
     */
    public void redo() {
        if (!redoList.isEmpty()) {
            PathBean pathBean = redoList.removeLast();
            mCanvas.drawPath(pathBean.path, pathBean.paint);
            invalidate();
            undoList.add(pathBean);
        }
    }


    /**
     * 设置画笔颜色
     */
    public void setPaintColor(@ColorInt int color) {
        mPaint.setColor(color);
    }

    /**
     * 清空,包括撤销和恢复操作列表
     */
    public void clearAll() {
        clearPaint();
        mLastY = 0f;
        //清空 撤销 ,恢复 操作列表
        redoList.clear();
        undoList.clear();
    }

    /**
     * 设置橡皮擦模式
     */
    public void setEraserModel(boolean isEraserModel) {
        this.isEraserModel = isEraserModel;
        if (eraserPaint == null) {
            eraserPaint = new Paint(mPaint);
            eraserPaint.setStrokeWidth(15f);
            eraserPaint.setColor(Color.TRANSPARENT);
            eraserPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
        }
    }

    /**
     * 保存到指定的文件夹中
     */
    public boolean saveImg(String filePath, String imgName) {
        boolean isCanSave = mBitmap != null && mLastY != 0f && !undoList.isEmpty();
        if (isCanSave) {//空白板时,就不保存
            //保存图片
            File file = new File(filePath + File.separator + imgName);
            FileOutputStream fileOutputStream = null;
            try {
                fileOutputStream = new FileOutputStream(file);
                if (mBitmap.compress(Bitmap.CompressFormat.JPEG, 100, fileOutputStream)) {
                    fileOutputStream.flush();
                    return true;
                }
            } catch (java.io.IOException e) {
                e.printStackTrace();
            } finally {
                closeStream(fileOutputStream);
            }
        }
        return false;
    }

    /**
     * 是否可以撤销
     */
    public boolean isCanUndo() {
        return undoList.isEmpty();
    }

    /**
     * 是否可以恢复
     */
    public boolean isCanRedo() {
        return redoList.isEmpty();
    }

    /**
     * 清除绘制内容
     * 直接绘制白色背景
     */
    private void clearPaint() {
        mCanvas.drawColor(Color.WHITE);
        invalidate();
    }


    /**
     * 触摸事件 触摸绘制
     */
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (!isEraserModel) {
            commonTouchEvent(event);
        } else {
            eraserTouchEvent(event);
        }
        invalidate();
        return true;
    }

    /**
     * 橡皮擦事件
     */
    private void eraserTouchEvent(MotionEvent event) {
        int action = event.getAction();
        float x = event.getX();
        float y = event.getY();
        switch (action) {
            case MotionEvent.ACTION_DOWN:
                //路径
                eraserPath = new Path();
                mLastX = x;
                mLastY = y;
                eraserPath.moveTo(mLastX, mLastY);
                break;
            case MotionEvent.ACTION_MOVE:
                float dx = Math.abs(x - mLastX);
                float dy = Math.abs(y - mLastY);
                if (dx >= 3 || dy >= 3) {//绘制的最小距离 3px
                    eraserPath.quadTo(mLastX, mLastY, (mLastX + x) / 2, (mLastY + y) / 2);
                }
                mLastX = x;
                mLastY = y;
                break;
            case MotionEvent.ACTION_UP:
                mCanvas.drawPath(eraserPath, eraserPaint);//将路径绘制在mBitmap上
                eraserPath.reset();
                eraserPath = null;
                break;
        }
    }

    /**
     * 普通画笔事件
     */
    private void commonTouchEvent(MotionEvent event) {
        int action = event.getAction();
        float x = event.getX();
        float y = event.getY();
        switch (action) {
            case MotionEvent.ACTION_DOWN:
                //路径
                mPath = new Path();
                mLastX = x;
                mLastY = y;
                mPath.moveTo(mLastX, mLastY);
                break;
            case MotionEvent.ACTION_MOVE:
                float dx = Math.abs(x - mLastX);
                float dy = Math.abs(y - mLastY);
                if (dx >= 3 || dy >= 3) {//绘制的最小距离 3px
                    //利用二阶贝塞尔曲线,使绘制路径更加圆滑
                    mPath.quadTo(mLastX, mLastY, (mLastX + x) / 2, (mLastY + y) / 2);
                }
                mLastX = x;
                mLastY = y;
                break;
            case MotionEvent.ACTION_UP:
                mCanvas.drawPath(mPath, mPaint);//将路径绘制在mBitmap上
                Path path = new Path(mPath);//复制出一份mPath
                Paint paint = new Paint(mPaint);
                PathBean pb = new PathBean(path, paint);
                undoList.add(pb);//将路径对象存入集合
                mPath.reset();
                mPath = null;
                break;
        }
    }

    /**
     * 关闭流
     */
    private void closeStream(Closeable closeable) {
        if (closeable != null) {
            try {
                closeable.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * 测量
     */
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int wSpecMode = MeasureSpec.getMode(widthMeasureSpec);
        int wSpecSize = MeasureSpec.getSize(widthMeasureSpec);
        int hSpecMode = MeasureSpec.getMode(heightMeasureSpec);
        int hSpecSize = MeasureSpec.getSize(heightMeasureSpec);

        if (wSpecMode == MeasureSpec.EXACTLY && hSpecMode == MeasureSpec.EXACTLY) {
            setMeasuredDimension(widthMeasureSpec, heightMeasureSpec);
        } else if (wSpecMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(200, hSpecSize);
        } else if (hSpecMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(wSpecSize, 200);
        }
    }

    /**
     * 路径对象
     */
    class PathBean {
        Path path;
        Paint paint;

        PathBean(Path path, Paint paint) {
            this.path = path;
            this.paint = paint;
        }
    }

}

代码中,重要地方都有注释


2. Activity

布局代码:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <android.support.v7.widget.CardView
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_margin="15dp"
        android:layout_weight="1"
        app:cardElevation="4dp"
        app:cardUseCompatPadding="true">

        <com.example.gcc.okhttpl.richeditor.PaintView
            android:id="@+id/activity_paint_pv"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />
    </android.support.v7.widget.CardView>


    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="40dp"
        android:gravity="center_vertical"
        android:orientation="horizontal">

        <TextView
            android:id="@+id/activity_paint_undo"
            style="@style/paint_menu_text_view_style"
            android:text="撤销" />

        <TextView
            android:id="@+id/activity_paint_redo"
            style="@style/paint_menu_text_view_style"
            android:text="恢复" />

        <TextView
            android:id="@+id/activity_paint_color"
            style="@style/paint_menu_text_view_style"
            android:text="红色" />

        <TextView
            android:id="@+id/activity_paint_clear"
            style="@style/paint_menu_text_view_style"
            android:text="清空" />

        <TextView
            android:id="@+id/activity_paint_eraser"
            style="@style/paint_menu_text_view_style"
            android:text="橡皮擦" />

        <TextView
            android:id="@+id/activity_paint_save"
            style="@style/paint_menu_text_view_style"
            android:text="保存" />

    </LinearLayout>
</LinearLayout>

Activity代码

public class PaintViewActivity extends AppCompatActivity {
    private PaintView paintView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_paint_view);
        initView();
        initMenu();
    }

    /**
     * 初始化
     */
    private void initView() {
        paintView = (PaintView) findViewById(R.id.activity_paint_pv);
    }

    /**
     * 初始化底部菜单
     */
    private void initMenu() {
        //撤销
        menuItemSelected(R.id.activity_paint_undo, new MenuSelectedListener() {
            @Override
            public void onMenuSelected() {
                ToastUtils.show(PaintViewActivity.this, "撤销");
                paintView.undo();
            }
        });
        //恢复
        menuItemSelected(R.id.activity_paint_redo, new MenuSelectedListener() {
            @Override
            public void onMenuSelected() {
                ToastUtils.show(PaintViewActivity.this, "恢复");
                paintView.redo();
            }
        });

        //颜色
        menuItemSelected(R.id.activity_paint_color, new MenuSelectedListener() {
            @Override
            public void onMenuSelected() {
                ToastUtils.show(PaintViewActivity.this, "红色");
                paintView.setPaintColor(Color.RED);
            }
        });
        //清空
        menuItemSelected(R.id.activity_paint_clear, new MenuSelectedListener() {
            @Override
            public void onMenuSelected() {
                ToastUtils.show(PaintViewActivity.this, "清空");
                paintView.clearAll();
            }
        });

        //橡皮擦
        menuItemSelected(R.id.activity_paint_eraser, new MenuSelectedListener() {
            @Override
            public void onMenuSelected() {
                ToastUtils.show(PaintViewActivity.this, "橡皮擦");
                paintView.setEraserModel(true);
            }
        });

        //保存
        menuItemSelected(R.id.activity_paint_save, new MenuSelectedListener() {
            @Override
            public void onMenuSelected() {
                String path = Environment.getExternalStorageDirectory().getPath()
                        + File.separator + Strings.FILE_PATH + File.separator + Strings.CACHE_PATH;
                String imgName = "paint.jpg";
                if (paintView.saveImg(path,imgName)) {
                    ToastUtils.show(PaintViewActivity.this, "保存成功");
                }
            }
        });
    }

    /**
     * 选中底部 Menu 菜单项
     */
    private void menuItemSelected(int viewId, final MenuSelectedListener listener) {
        findViewById(viewId).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                listener.onMenuSelected();
            }
        });

    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        ToastUtils.cancel();
    }

    interface MenuSelectedListener {
        void onMenuSelected();
    }
}

这是橡皮擦使用PorterDuffXfermode踩坑思路


2.1 橡皮擦直接绘制白色背景思路

简单修改PaintView代码:

1. 首先把硬件加速打开,也就是把init()方法里,下面行代码注释掉:
  //setBackgroundColor(Color.WHITE);
  //setLayerType(View.LAYER_TYPE_SOFTWARE, null);

2.修改橡皮擦颜色
 eraserPaint.setColor(Color.WHITE);
 //eraserPaint.setColor(Color.TRANSPARENT);
 //eraserPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));

这种思路即不会导致过度绘制,也不会有硬件加速的坑,但前提是绘图板背景颜色是纯色的


3. 最后

即使在使用过渡绘制思路的情况下,暂时感觉效率也可以,在低端机上也没有明显的卡顿感,绘制轨迹蛮跟手的。个人感觉这种绘图板并不需要SurfaceView

有错误,请指出

共勉 : )

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,602评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,442评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,878评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,306评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,330评论 5 373
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,071评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,382评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,006评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,512评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,965评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,094评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,732评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,283评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,286评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,512评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,536评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,828评论 2 345

推荐阅读更多精彩内容