美文网首页android开发技巧Android自定义View自定义View
听说懂Canvas的人运气都不会太差!

听说懂Canvas的人运气都不会太差!

作者: 吴愣 | 来源:发表于2017-10-16 14:37 被阅读520次

    1.前言

    逛街的时候,看到一篇Android Canvas 方法总结,这篇文章将Canvas一些基本操作介绍的很详细。从零开始的朋友可以先去刷点经验,剩下的同学拿起手术刀,我们一起来将Canvas血腥解剖吧。

    2.Canvas简介

    官方文档介绍如下

    The Canvas class holds the "draw" calls. To draw something, you need 4 basic components:
    a Bitmap to hold the pixels, 
    a Canvas to host the draw calls (writing into the bitmap), 
    a drawing primitive (e.g. Rect, Path, text, Bitmap), 
    a paint (to describe the colors and styles for the drawing).
    

    用人话说大概是这样

    一个Canvas类对象有四大基本要素
    1、用来保存像素的Bitmap
    2、用来在Bitmap上进行绘制操作的Canvas
    3、要绘制的东西
    4、绘制用的画笔Paint
    

    Bitmap和Canvas的关系类似于画板与画布,不理解没有关系,后面还会详细介绍的。

    3.Canvas基本绘制

    一开始,先来点简单的基础题。因为太懒了不想从头写起,我就假设大家都看过了Android Canvas 方法总结,来写一些这里面没有介绍的方法。

    3.1 圆角矩形

    画笔初始化

    mPaint = new Paint();
            mPaint.setStyle(Paint.Style.STROKE);
            mPaint.setStrokeWidth(10);
            mPaint.setColor(Color.RED);
    

    onDraw()中进行绘制,参数二、三越大,圆角半径越大

     private void drawRoundRect(Canvas canvas) {
            RectF r = new RectF(100, 100, 400, 500);
            //x-radius ,y-radius圆角的半径
            canvas.drawRoundRect(r, 80, 80, mPaint);
        }
    

    效果图如下

    圆角矩形.png

    3.2 圆角矩形路径

    那么有的时候,我不需要那么整齐的圆角,该怎么办呢

    private void drawRoundRectPath(Canvas canvas) {
            RectF r = new RectF(100, 100, 400, 500);
            Path path = new Path();
            float radii[] = {80, 80, 80, 80, 80, 80, 20, 20};
            path.addRoundRect(r, radii, Path.Direction.CCW);
            canvas.drawPath(path, mPaint);
        }
    

    这里的radii就定义了四个角的弧度,接着使用Path就可以将其绘制出来。事实上,Path是无所不能的,我们将在以后单独介绍他。

    圆角矩形路径.png

    3.3 Region区域

    Region是区域的意思,它表示的Canvas图层上的一块封闭的区域。

    来看看它的构造方法

     /** Create an empty region
        */
        public Region() {
            this(nativeConstructor());
        }
    
        /** Return a copy of the specified region
        */
        public Region(Region region) {
            this(nativeConstructor());
            nativeSetRegion(mNativeRegion, region.mNativeRegion);
        }
    
        /** Return a region set to the specified rectangle
        */
        public Region(Rect r) {
            mNativeRegion = nativeConstructor();
            nativeSetRect(mNativeRegion, r.left, r.top, r.right, r.bottom);
        }
    
        /** Return a region set to the specified rectangle
        */
        public Region(int left, int top, int right, int bottom) {
            mNativeRegion = nativeConstructor();
            nativeSetRect(mNativeRegion, left, top, right, bottom);
        }
    

    看上去挺万金油的,什么都能传进去,那么Region能干嘛呢?我们可以用它来进行一些交并补集的操作,比如下面代码就能展示两个region的交集

    region1.op(region2, Region.Op.INTERSECT);//交集部分 region1是调用者A,region2是求交集的B
    

    去源码里看看这个Op,发现是个枚举类

    public enum Op {
            DIFFERENCE(0),
            INTERSECT(1),
            UNION(2),
            XOR(3),
            REVERSE_DIFFERENCE(4),
            REPLACE(5);
        ...
        }
    

    可见交并补的类型还挺多,我们用一张图来介绍吧


    Region_op.png

    那么这里就出现了一个问题,这些Op过后的region都是不规则的了,系统要如何将他们绘制出来呢?
    嘿嘿嘿,请回忆起当年被微积分支配的恐惧吧!

    private void drawRegion(Canvas canvas){
            RectF r = new RectF(100, 100, 400, 500);
            Path path = new Path();
            float radii[] = {80, 80, 80, 80, 80, 80, 20, 20};
            path.addRoundRect(r, radii, Path.Direction.CCW);
    
            //创建一块矩形的区域
            Region region = new Region(100, 100, 600, 800);
            Region region1 = new Region();
            region1.setPath(path, region);//path的椭圆区域和矩形区域进行交集
    
            //结合区域迭代器使用(得到图形里面的所有的矩形区域)
            RegionIterator iterator = new RegionIterator(region1);
    
            Rect rect = new Rect();
            mPaint.setStrokeWidth(1);
            while (iterator.next(rect)) {
                canvas.drawRect(rect, mPaint);
            }
        }
    

    看看代码,这里通过迭代器用一个个小矩形填满整个region控件,为了方便展示,我们把paint的类型设成stroke,效果图如下

    region绘制.png

    解释下,这里首先绘制了最大的那个矩形,然后在极限的距离上缩小矩形,再通过这些小矩形将剩下的部分都填充起来。

    4.Canvas基本变换

    4.1 Canavas坐标系

    Canvas里面牵扯两种坐标系:Canvas自己的坐标系、绘图坐标系

    Canvas的坐标系,它就在View的左上角,从坐标原点往右是X轴正半轴,往下是Y轴的正半轴,有且只有一个,唯一不变

    绘图坐标系,它不是唯一不变的,它与Canvas的Matrix有关系,当Matrix发生改变的时候,绘图坐标系对应的进行改变,同时这个过程是不可逆的(通过相反的矩阵还原),而Matrix又是通过我们设置translate、rotate、scale、skew来进行改变的

    上面这段话还是挺好理解的,我们用代码来验证下

     private void drawMatrix(Canvas canvas){
            // 绘制坐标系
            RectF r = new RectF(0, 0, 400, 500);
            mPaint.setColor(Color.GREEN);
            canvas.drawRect(r, mPaint);
    
            // 第一次绘制坐标轴
            canvas.drawLine(0,0,canvas.getWidth(),0,mPaint);// X 轴
            mPaint.setColor(Color.BLUE);
            canvas.drawLine(0,0,0,canvas.getHeight(),mPaint);// Y 轴
    
            //平移--即改变坐标原点
            canvas.translate(50, 50);
            // 第二次绘制坐标轴
            mPaint.setColor(Color.GREEN);
            canvas.drawLine(0,0,canvas.getWidth(),0,mPaint);// X 轴
            mPaint.setColor(Color.BLUE);
            canvas.drawLine(0,0,0,canvas.getHeight(),mPaint);// Y 轴
    
            canvas.rotate(45);
            // 第三次绘制坐标轴
            mPaint.setColor(Color.GREEN);
            canvas.drawLine(0,0,canvas.getWidth(),0,mPaint);// X 轴
            mPaint.setColor(Color.BLUE);
            canvas.drawLine(0,0,0,canvas.getHeight(),mPaint);// Y 轴
        }
    

    运行结果如下

    Canvas坐标系

    剩下的translate、rotate、scale、skew等方法,在之前推荐的那篇文章里就有,这里就不再重复劳动了

    5.Canvas状态保存

    5.1 状态栈

    状态栈通过save、 restore方法来保存和还原变换操作Matrix以及Clip剪裁,也可以通过restoretoCount直接还原到对应栈的保存状态。需要注意的是,一开始canvas就是在栈1的位置,执行一次save就进栈一次(此时为2),执行一次restore就出栈一次。

     private void saveRestore(Canvas canvas){
            RectF r = new RectF(0, 0, 400, 500);
            mPaint.setColor(Color.GREEN);
            canvas.drawRect(r, mPaint);
            canvas.save();
            //平移
            canvas.translate(50, 50);
            mPaint.setColor(Color.BLUE);
            canvas.drawRect(r, mPaint);
            canvas.restore();
            mPaint.setColor(Color.YELLOW);
            r = new RectF(0, 0, 200, 200);
            canvas.drawRect(r, mPaint);
        }
    

    效果图如下

    状态栈

    有的同学可能会把状态栈理解为图层,其实这是不对滴。我们来看下save方法的源码

     /**
         * Saves the current matrix and clip onto a private stack.
         * <p>
         * Subsequent calls to translate,scale,rotate,skew,concat or clipRect,
         * clipPath will all operate as usual, but when the balancing call to
         * restore() is made, those calls will be forgotten, and the settings that
         * existed before the save() will be reinstated.
         *
         * @return The value to pass to restoreToCount() to balance this save()
         */
        public int save() {
            return native_save(mNativeCanvasWrapper, MATRIX_SAVE_FLAG | CLIP_SAVE_FLAG);
        }
    

    注释上说的很明白,save只是保存了matrix和clip的状态,并没有保存一个真正的图层。真正的图层是在Layer栈中保存的。

    5.2 Layer栈

    Layer栈通过saveLayer新建一个透明的图层,并且会将saveLayer之前的一些Canvas操作延续过来,后续的绘图操作都在新建的layer上面进行,当我们调用restore或者 restoreToCount 时更新到对应的图层和画布上。

    下面这段代码要仔细看

    private void saveLayer(Canvas canvas) {
            RectF rectF = new RectF(0, 0, 400, 500);
            Paint paint = new Paint();
            paint.setStyle(Paint.Style.STROKE);
            paint.setStrokeWidth(10);
            paint.setColor(Color.GREEN);
    
            canvas.drawRect(rectF, paint);
            canvas.translate(50, 50);
    
            canvas.saveLayer(0, 0, canvas.getWidth(), canvas.getHeight(), null, Canvas.ALL_SAVE_FLAG);
            canvas.drawColor(Color.BLUE);// 通过drawColor可以发现saveLayer是新建了一个图层,
            paint.setColor(Color.YELLOW);
            canvas.drawRect(rectF, paint);
            canvas.restore();
    
            RectF rectF1 = new RectF(0, 0, 300, 400);
            paint.setColor(Color.RED);
            canvas.drawRect(rectF1, paint);
    
        }
    

    先上效果图

    saveLayer

    我们慢慢解释。一开始画了个绿色的框,之后移动了(50,50)的距离,再通过saveLayer新建了一个图层。注意这里用到了Canvas.ALL_SAVE_FLAG,别的FLAG还有只保存Matrix的,只保存Clip的等等,大家可以自己去看。接着在新的图层上画了蓝色背景和黄色的矩形,从结果可以看出之前的一些Canvas操作会被延续到新的图层。调用restore后,两个图层合二为一,由于图层2是蓝色背景,因此就把图层1的绿色边框覆盖了。最后再绘制另一个红色的框,此时只有一个图层了,所以就绘制在当前图层的最上方。

    文章开头说Bitmap和Canvas的关系类似于画板与画布,就是因为每次Canvas执行saveLayer时都会新建一个透明的图层,与之前的图层叠加后更新到Bitmap上,从而将绘制的内容展示出来。

    这些大概就是save与saveLayer的区别所在了,如果觉得自己明白了,就去看看给女朋友化妆系列的代码,看看是否可以理解其中的save与saveLayer操作,以及为什么要这样做。

    6.Drawable与Canvas

    6.1 Drawable简介

    下面我们将用一个例子来加深学习效果,不过开始前还需要将Drawable这位兄弟介绍给大家。首先上一段官方注释:

    A Drawable is a general abstraction for "something that can be drawn."
    

    顾名思义,Drawable就是可以被画出来的一个东西,这是一个抽象类,继承自它的实现类如下(windows中查询类继承关系的快捷键是Ctrl+H)

    drawable继承结构.png

    是不是发现有些熟悉的字眼?比如layer、shape、color等等。对啦,就是可以在drawable文件夹中进行定义的xml资源文件,系统会将这些xml转换成都解析成相应的drawable对象。

    由于drawable是可绘制的对象,canvas是绘制的画纸,因此这两位是密不可分的好基友。除去上图中系统实现的drawable外,我们还可以根据需要自定义drawable。事实上自定义drawable才是日常会用到的东西,下面一起来看看这种基本操作。

    6.1 基本操作

    这个自定义控件的功能不太好描述,先展示下效果图

    效果图.png

    最外层是一个HorizontalScrollView,里面包裹着许多ImageView,可以拖动,中间选中的区域呈现灰色,其余的显示彩色。这些灰色、彩色的图是两套资源。

    要实现上述功能,我们需要两个控件,一个是外层控制触摸事件的ViewGroup,可以通过继承HorizontalScrollView来完成。另一个是用来注入ImageView变换颜色的Drawable,这就需要自定义来实现了。

    6.1.1 RevealDrawable

    创建RevealDrawable继承自Drawable,需要重写public void draw(@NonNull Canvas canvas)方法。

    对于每一部分的图片而言,会有以下几种绘制情况:

    1.灰色
    2.彩色
    3.左灰右彩
    4.左彩右灰
    

    灰色和彩色好办,直接将两种图片当做参数传入RevealDrawable中,在需要时通过draw(canvas)绘制出来即可。那么混合色该怎么做?又要如何去判断左右或者说灰彩各占的比例呢?

    对于问题一,我们可以用之前所说的canvas裁剪来完成,需要注意save()restore()的调用;至于问题二,Drawable源码中有这么一行参数:private int mLevel = 0;,很显然,Google早已考虑到Drawable的这种使用场景,而mLevel就是用来确定比例的,其值为0~10000,可以由我们在外层动态去设置。

    总结一下,整体思路就是HorizontalScrollView根据Scroll的距离为RevealDrawable动态设置level,而RevealDrawable则根据被设置的level展示出不同的图像效果。剩下的就是数学问题了。

    这里就展示其核心的绘制方法,注释都有,完整的代码等闲了整理下一起放到大型同性交友平台上。

    @Override
        public void draw(Canvas canvas) {
            // 绘制
            int level = getLevel();//from 0 (minimum) to 10000 
            //三个区间
            //右边区间和左边区间--设置成灰色
            if(level == 10000|| level == 0){
                mUnselectedDrawable.draw(canvas);
            }
            else if(level==5000){//全部选中--设置成彩色
                mSelectedDrawable.draw(canvas);
            }else{
                //混合效果的Drawable
                /**
                 * 将画板切割成两块-左边和右边
                 */
                final Rect r = mTmpRect;
                //得到当前自身Drawable的矩形区域
                Rect bounds = getBounds();
                {
                    //1.先绘制灰色部分
                    //level 0~5000~10000
                    //比例
                    float ratio = (level/5000f) - 1f;
                    int w = bounds.width();
                    if(mOrientation==HORIZONTAL){
                        w = (int) (w* Math.abs(ratio));
                    }
                    int h = bounds.height();
                    if(mOrientation==VERTICAL){
                        h = (int) (h* Math.abs(ratio));
                    }
                    
                    int gravity = ratio < 0 ? Gravity.LEFT : Gravity.RIGHT;
                    //从一个已有的bounds矩形边界范围中抠出一个矩形r
                    Gravity.apply(
                            gravity,//从左边还是右边开始抠
                            w,//目标矩形的宽 
                            h, //目标矩形的高
                            bounds, //被抠出来的rect
                            r);//目标rect
                    
                    canvas.save();//保存画布
                    canvas.clipRect(r);//切割
                    mUnselectedDrawable.draw(canvas);//画
                    canvas.restore();//恢复之前保存的画布
                }
                {
                    //2.再绘制彩色部分
                    //level 0~5000~10000
                    //比例
                    float ratio = (level/5000f) - 1f;
                    int w = bounds.width();
                    if(mOrientation==HORIZONTAL){
                        w -= (int) (w* Math.abs(ratio));
                    }
                    int h = bounds.height();
                    if(mOrientation==VERTICAL){
                        h -= (int) (h* Math.abs(ratio));
                    }
                    
                    int gravity = ratio < 0 ? Gravity.RIGHT : Gravity.LEFT;
                    //从一个已有的bounds矩形边界范围中抠出一个矩形r
                    Gravity.apply(
                            gravity,//从左边还是右边开始抠
                            w,//目标矩形的宽 
                            h, //目标矩形的高
                            bounds, //被抠出来的rect
                            r);//目标rect
                    canvas.save();//保存画布
                    canvas.clipRect(r);//切割
                    mSelectedDrawable.draw(canvas);//画
                    canvas.restore();//恢复之前保存的画布
                }       
            }
        }
    
    6.1.2 GalleryHorizontalScrollView

    外层的GalleryHorizontalScrollView继承自HorizontalScrollView,需要处理滑动事件与level设置,别忘了ScrollView的子View必须是ViewGroup

     private void init() {
            LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
                    ViewGroup.LayoutParams.WRAP_CONTENT,
                    ViewGroup.LayoutParams.WRAP_CONTENT
            );
            container = new LinearLayout(getContext());
            container.setLayoutParams(params);
            setOnScrollChangeListener(this);
    
        }
    

    onLayout()的作用是在控件初始化时设置Padding值,以便于一开始,将第一个ImageView展示在正中间的位置(为了好看,没什么特别的用处)

     @Override
        protected void onLayout(boolean changed, int l, int t, int r, int b) {
            super.onLayout(changed, l, t, r, b);
            View v = container.getChildAt(0);
            icon_width = v.getWidth();//单个图片的宽度
            centerX = getWidth() / 2;//整个sv的宽度
            centerX = centerX - icon_width/2;
            container.setPadding(centerX, 0, centerX, 0);
        }
    
    起始图.png

    起初触摸事件是在touch方法中完成的,后来发现这个方法精度太高,滑动抖动明显,因此换在scroll方法中执行。

    private void reveal() {
            // 渐变效果
            //得到hzv滑出去的距离
            int scrollX = getScrollX();
            Log.d(TAG, "reveal: "+scrollX);
            //找到两张渐变的图片的下标--左,右
            int index_left = scrollX/icon_width;
            int index_right = index_left + 1;
            //设置图片的level
            for (int i = 0; i < container.getChildCount(); i++) {
                if(i==index_left||i==index_right){
                    //变化
                    //比例:
                    float ratio = 5000f/icon_width;
                    ImageView iv_left = (ImageView) container.getChildAt(index_left);
                    //scrollX%icon_width:代表滑出去的距离
                    //滑出去了icon_width/2  icon_width/2%icon_width
                    iv_left.setImageLevel(
                            (int)(5000-scrollX%icon_width*ratio)
                    );
                    //右边
                    if(index_right<container.getChildCount()){
                        ImageView iv_right = (ImageView) container.getChildAt(index_right);
                        //scrollX%icon_width:代表滑出去的距离
                        //滑出去了icon_width/2  icon_width/2%icon_width
                        iv_right.setImageLevel(
                                (int)(10000-scrollX%icon_width*ratio)
                        );
                    }
                }else{
                    //灰色
                    ImageView iv = (ImageView) container.getChildAt(i);
                    iv.setImageLevel(0);
                }
            }
        }
    

    最后是添加图片的方法

    public void addImageViews(Drawable[] revealDrawables){
            for (int i = 0; i < revealDrawables.length; i++) {
                ImageView img = new ImageView(getContext());
                img.setImageDrawable(revealDrawables[i]);
                container.addView(img);
                if(i==0){
                    img.setImageLevel(5000);
                }
            }
            addView(container);
        }
    

    7.Canvas缓存

    在上面的例子中我们介绍了Canvas与自定义Drawable的配合使用,接下来我们上一个Canvas与Bitmap混合实现缓存的效果。其实这句话是废话,因为创建Canvas时就必须传入Bitmap为参,毕竟Bitmap才是真正保存像素的地方。

    缓存的思想就是先将像素保存到CacheCanvas的CacheBitmap中,再将这个CacheBitmap保存到View的Canvas上。

    我们在初始化方法中创建缓存对象。

    private void init() {
            //创建一个与该VIew相同大小的缓冲区
            cacheBitmap = Bitmap.createBitmap(VIEW_WIDTH, VIEW_HEIGHT, Bitmap.Config.ARGB_8888);
            //创建缓冲区Cache的Canvas对象
            cacheCanvas = new Canvas();
            path = new Path();
            //设置cacheCanvas将会绘制到内存的bitmap上
            cacheCanvas.setBitmap(cacheBitmap);
            paint = new Paint();
            paint.setColor(Color.RED);
            paint.setFlags(Paint.DITHER_FLAG);
            paint.setStyle(Paint.Style.STROKE);
            paint.setStrokeWidth(5);
            paint.setAntiAlias(true);
            paint.setDither(true);//防抖动,比较清晰
        }
    

    接着onTouch()中根据手势进行绘制,注意是绘制到缓存canvas上

     @Override
        public boolean onTouchEvent(MotionEvent event) {
            //获取拖动时间的发生位置
            float x = event.getX();
            float y = event.getY();
            switch (event.getAction()) {
                case MotionEvent.ACTION_DOWN:
                    path.moveTo(x, y);
                    preX = x;
                    preY = y;
                    break;
                case MotionEvent.ACTION_MOVE:
                    path.quadTo(preX, preY, x, y);//绘制圆滑曲线
                    preX = x;
                    preY = y;
                    break;
                case MotionEvent.ACTION_UP:
                    //这是是调用了cacheBitmap的Canvas在绘制
                    cacheCanvas.drawPath(path, paint);
                    path.reset();
                    break;
            }
            invalidate();//在UI线程刷新VIew
            return true;
        }
    

    invalidate()会回调draw()方法,此时再将缓存bitmap绘制到View的canvas中。

      @Override
        protected void onDraw(Canvas canvas) {
            Paint p = new Paint();
            //将cacheBitmap绘制到该View
            canvas.drawBitmap(cacheBitmap, 0, 0, p);
        }
    

    这样一来,手指滑动轨迹就会略有延迟后再绘制到用户界面上,类似于写字板的效果。

    cache缓存.png

    8.总结

    关于canvas的介绍就到此为止,了解canvas的基本绘制,知道它的两个坐标系,学会和drawable、bitmap混合使用基本就差不多了。希望能给大家带来好运。

    前段时间秋招找工作实习什么的断更好久,从今天开始,立个FLAG在此,一天一篇!

    相关文章

      网友评论

        本文标题:听说懂Canvas的人运气都不会太差!

        本文链接:https://www.haomeiwen.com/subject/utqvrxtx.html