android开发技巧Android自定义View自定义View

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

2017-10-16  本文已影响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在此,一天一篇!

上一篇 下一篇

猜你喜欢

热点阅读