锦囊篇|Android自定义View

2020-06-23 15:15:07 浏览数 (1)

前言

View,有很多的名称。不论是你熟知的布局,还是控件,他们全部都继承自View

文章内部分图片来源:Carson_Ho大佬的文章

View工作流程

measure

等等通过layout中的第二张图我们就会知道控件大小的计算方法了。

  • height = bottom - top
  • width = right - left
代码语言:javascript复制
对于ViewGroup而言,就是对容器内子控件的遍历和计算了。

因为直接继承自View的控件使用wrap_cotentmatch_parent是显示出来的效果是相同的。需要我们使用MeasureSpec中的getMode()方法来对当前的模式进行区分和比较。

模式

状态

UNSPECIFIED

未指定模式,View想多大就多大,父容器不做限制,一般用于系统内部的测量

AT_MOST

最大模式,对应wrap_content,View的大小不大于SpecSize的值

EXACTLY

精确模式,对应match_parent,View的大小为SpecSize的值

代码语言:javascript复制
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        //用于获取设定的模式
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);

        // 用于获取设定的长度
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);

        // 类似这样的判断,后面不过多复述
        // 用于判断是不是wrap_content
        // 如果不进行处理,效果会是match_parent
        if(widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST){
            setMeasuredDimension(20, 20);
        }
    }

layout

在确定位置时,我们有一个非常需要主要的地方—— 坐标系。Android系统的坐标系和平时画的坐标系并不相同。

所以相对应的,我们的位置计算方法自然和我们原来的正好是相反的。

4个顶点的位置分别由4个值决定:

  • top:子View上边界到所在容器上边界的距离。
  • left:子View左边界到所在容器左边界的距离。
  • bottom:子View下边界到所在容器上边界的距离。
  • right:子View右边界到所在容器左边界的距离。
代码语言:javascript复制
所有的计算都是相对于所在容器才能够开始的。

draw

一共有6个步骤:

  1. 如果需要,则绘制背景 -- drawBackground(canvas);
  2. 保存当前canvas层 -- saveCount = canvas.getSaveCount();
  3. 绘制View的内容 -- if (!dirtyOpaque) onDraw(canvas);
  4. 绘制子View -- dispatchDraw(canvas);
  5. 如果需要,则绘制View的褪色边缘,类似于阴影效果 -- canvas.restoreToCount(saveCount);
  6. 绘制装饰,比如滚动条 -- onDrawForeground(canvas);
代码语言:javascript复制
关于开发者需要重写的方法一般是第三步绘制View的内容对应的onDraw()。
代码语言:javascript复制
    private Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        int width = getWidth();
        int height = getHeight();
        // 在画布上进行类似这样的操作
        canvas.drawLine(0, height/2, width,height/2, paint);
    }

入门自定义View

在日常项目的布局文件中我们经常会使用到xmlns:app="http://schemas.android.com/apk/res-auto"这样标签,其实他就是用来引入我们自定义的标签使用的。

  1. res/values目录下创建attrs
代码语言:javascript复制
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="DefaultView">
        <attr name="color" format="color"/>
    </declare-styleable>
</resources>
  1. DefaultView(Context context, @Nullable AttributeSet attrs)中获取。以下是整个完整代码。
代码语言:javascript复制
public class DefaultView extends View {
    private Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    private int mColor = Color.RED;

    public DefaultView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        initAttrs(context, attrs);
        initDraw();
    }

    private void initAttrs(Context context, @Nullable AttributeSet attrs) {
        TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.DefaultView);
        // 从styleable中获取的名字是系统会生成的,一般是 类名_name 的形式
        mColor = array.getColor(R.styleable.DefaultView_color, Color.GREEN);
        // 获取完资源后即使回收
        array.recycle();
    }

    private void initDraw() {
        paint.setColor(mColor);
        paint.setStrokeWidth(3f);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        int width = getWidth();
        int height = getHeight();
        canvas.drawLine(0, height/2, width,height/2, paint);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);

        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);

        if(widthMode == MeasureSpec.AT_MOST){
            setMeasuredDimension(20, 20);
        }
    }
}

就是如此轻松,你能够直接完成一份简易的自定义View的制作,并且带上来在xml文件中进行自定义的功能。

实战高级的自定义View

代码语言:javascript复制
此处主要以刮刮卡、饼图、柱状图以及折线图为例。

Paint和Canvas的基础使用

在上面我们知道过了View的绘制流程啊之类的东西,这是基础的一部分。但是现在说的PaintCanvas也是一种非常重要的基础了,他们用通俗的话来说,就是笔和画布,而我们见过的这么多绘制哪一个任务不是通过他们来完成的呢?

视频的概念,其实我们应该也已经有所了解了,就是一帧帧的图片嘛,绘制的速度超过人眼,那么就能够演变成视频了。

代码语言:javascript复制
上面给出一个贝塞尔曲线的绘制过程,当然这个我已经用代码实现了。有兴趣的读者可以直接的去源码中的PathView类中进行查看。

其实我就是模拟了100次而已,但是你就会感觉整个图片他在动,这其实也是动画实现的一种原理了。

这个贝塞尔曲线的模拟,其实可以分为几个部分,两个相同的部分就是贝塞尔曲线他一条线和一个点的模拟,还有就是中间点,也就是贝塞尔曲线的位置变换模拟了。

那在这里我们需要注意的地方就来了,如何获取当前点的位置,显然我们只会使用Paint和Canvas是远远不够的,因为,我们的小点显然需要通过位置变换计算得来的。这里我们就要引入我们一个类Path了。

Path

Path,从字面意思我们就知道,这是一个路径的意思了。

先来观摩一下他的的函数有什么,和Canvas可能不一样的地方就是,从draw变到了add,但是这样的区别,就能让Canvas绘制更加复杂的图形了,因为draw的对象都是已经封装好的圆形、长方形啊等等,而通过add你就能绘制出各种奇形怪状的形状了,而贝塞尔曲线就是他们其中的一份子。

PathMeasure

什么是PathMeasure呢?其实你关注上方,我们讲过了Path,但是终究还是没讲到如何去进行一个位置的计算。而其实这个任务,就是交给我们的这个PathMeasure来交接完成的。

代码语言:javascript复制
fun init(){
    pathMeasure = PathMeasure(path, false)
        pathMeasure?.length.let {
            pathLength = it
            mStep = it?.div(INVALIDATE_TIMES)
        }
}

override fun onDraw(canvas: Canvas?) {
    super.onDraw(canvas)
    if (pathLength!! >= mDistance!!) {
        mDistance?.let { pathMeasure?.getPosTan(it, mPos, mTan) }
        mDistance = mStep?.let { mDistance?.plus(it) } 
        canvas?.drawPoint(mPos?.get(0)!!, mPos?.get(1)!!, paintPoint!!)
        invalidate()
    }
}

具体代码还是需要看我已经完成的PathView,这里做一个简单的介绍,其实这个PathMeasure通过Path来计算长度,然后在通过我们传入的Distance来返回当前的PostionTan值,会有读者问了Tan有什么用,其实一般比较多数的用法就是就是一个角度的调整,如果使用一个点来显示,确实没什么效果,但是如果是一个Bitmap来现实的,没有Tan值,那么我们的图片就不会取转头了。

然后通过获取的PostionTan值来重绘,我们就可以完成我们的贝塞尔曲线绘制了

之前已经知道了我们的这个图片如何去进行绘制,那这个时候我们就继续深化了。

首先第一个问题,什么是刮刮卡?

小二上图!!!

读者: cool!!想知道想知道,这个咋实现呀。

小易: 我现在还不想告诉你,嘿嘿。

第二个问题,他有什么组成部分?

你可能会说,蒙版和底层图片呗。但是这个答案是,也不是。为什么呢?如果只有蒙版和底层图片,其实这个不能够完全实现的,你可能只能实现到下面的步骤。

一个只能看,不能刮的刮刮卡,哈哈哈哈哈哈哈哈!!!

既然我们已经说了,这两个不够,那肯定还有其他组成成分是需要我们去进行考虑的了。其实他就是一支笔,这个刮的部分其实他是通过一支笔来完成的。

这里我们需要补充几个知识点了。

离屏绘制

通过写法如下:

代码语言:javascript复制
override fun onDraw(canvas: Canvas?) {
    super.onDraw(canvas)
    val layerId = canvas?.saveLayer(
            null, null
        )
    canvas?.let { canvasIt ->
        paint?.let {paintIt->
            rectBitmap?.let { canvas.drawBitmap(it,0f,0f, paintIt) }
            paintIt.xfermode = xfermode
            circleBitmap?.let { canvas.drawBitmap(it, 50f, 50f,paintIt) }
            paintIt.xfermode = null
        }
    }
    layerId?.let { canvas.restoreToCount(it) }
}

通过一个短时的离屏缓冲,来完成两个图层的复合来实现。而这个复合就是通过一个layerId来完成判定谁和谁是一家人。

Xfermode

这里又来一个知识点了!!!复合是怎么实现的???

下面给出一张图片来解释,这是Android内置的Xfermode,也就是复合模式是如何的,理解图片的意思即可,也不用专门去学怎么算的,归根结底,就是对RGB某一个色块的突出或者隐藏。

好了,知道了这几个知识点,我们回归正题,刮刮卡怎么实现的问题?我们知道了复合,知道了离屏绘制,怎么用呢?

其实最后要讨论的就是蒙版是什么?底层图片做什么?

底层图片自然是给我们看的,我们总不能手画一下,然后被我们抹掉吧,那这个图层显然是不能被我们的覆盖掉的。而蒙版呢,我们需要通过离屏绘制,把图片和我们走过的路径进行重叠消除,来完成看到图片的效果。那这个效果应该是那种模式来完成呢?我们看看上面给出的图片,那个最合适。

刮的过程就是我们的Dst和蒙版就是我们的Src,这个是应该就是XOR这个模式是最符合我们的要求的,我们绘制过的和Src重叠的地方全部消去了。

但是看过了源码的读者估计会问一个问题,为什么我要创建一个DBitmap来完成这个任务?其实不实现也可以的,但是你会出现下图的情况了,刮刮卡下方的图层一片乌漆麻黑,其实这个DBitmap起到的是一个限制的作用了,给了Paint的一个作用范围,那么实现的就更加舒服了,当然如果你限制了View在整合布局中的大小,正好占满也是可以解决这个问题的,但是并不推荐。

最后就是一个简单的也比较重要的点了,这是Android的事件分发机制都会问到的一个点,我们应该重写什么方法,来完成绘制路径的添加。建议还是在OnTouch中完成,因为OnTouch的响应其实是先于OnTouchEvent的。

Canvas的高级使用

上面我们讲过了一个Path,但是如果全用Path去实现,也不是说不能够实现,但是并不适用于很多的场景情况。

这里的话,我们主要讲的就是关于Canvas的使用了。之前我们在Path中用到过这样的一个函数moveTo,也就是把路径从当前的位置移动到新的坐标上。那我们的Canvas是否有这样的功能呢?

自然是有的了,这里直接作出一个解答,他的函数就是translate,但是有一点要注意!!!这个函数是一个相对移动,而不是绝对移动。

代码语言:javascript复制
canvas?.translate(60f, 80f)
paint?.let { canvas?.drawCircle(0f, 0f, 100f, it) }
canvas?.translate(60f, 80f)paint?.color = Color.BLUE
paint?.let { canvas?.drawCircle(0f, 0f, 100f, it) }

上述就是实现代码以及一个贴图了。你可以直观的感受到这个移动其实是基于当前的基础来完成的。但是读者会说了,这种效果完全可以让我通过Path来完成啊,没必要通过Canvas,ok,确实可以,你也可以通过Path来实现。

但是难点现在才来,下图的样式该怎么实现??

在知道怎么写代码之前,那我们要知道肯定是他的组成成分到底是什么了。

  1. 两条线的组合。从直观上就可以看出,这个折线应该是两条直线进行组合
  2. 两行文字的组合。也就是图片中的描述四和6.0%

如果让我们直接去获取绝对坐标,然后进行绘制,那是不是会非常麻烦呢?所以这里要引入一个Canavs的操作方法。

Canvas操作

在使用之前需要注意,画布是需要保存的,不然画布将不断的保留上一次的状态进行绘制,那整体就会呈现一种叠加混乱的局面。而这个方法就是save()restore()的成对使用。

代码语言:javascript复制
for (i in 0..2) {
        canvas?.save()
        paint?.color = Color.BLUE
        canvas?.translate(60f, 80f)
        canvas?.scale(0.5f, 0.5f)
        paint?.let { canvas?.drawCircle(0f, 0f, 100f, it) }
        canvas?.restore()
    }

使用前

使用后

你可以把这个操作理解为画布归位,或者说画布重置。而这个重置对应的就是保存了save()之前的操作过程。

Scale

这个效果不再赘述了。

Rotate

和其他的都是一样的会有两种画布的操作函数

  1. rotate(float degrees, float px, float py),就是自定义旋转圆心
  2. rotate(float degrees),画布当前位置作为旋转圆心
代码语言:javascript复制
canvas?.save()
paint?.color = Color.BLUE
canvas?.translate(60f, 80f)
canvas?.rotate(45f)
// 以自身中心作为圆点旋转
// canvas?.rotate(45f,30f, 40f)
paint?.let { canvas?.drawRect(0f, 0f, 100f, 100f, it) }
canvas?.restore()
代码语言:javascript复制
饼图的实现其实就是基于这个函数来完成的。

Shew

和其他的不同了,他并不再通过我们的坐标圆心,而是对标我们XY

代码语言:javascript复制
canvas?.save()
paint?.color = Color.BLUE
canvas?.translate(60f, 80f)
canvas?.skew(0f, 1f)
paint?.let { canvas?.drawRect(0f, 0f, 100f, 100f, it) }
canvas?.restore()

传入的数据其实就是分别与XY轴的正切值,通过截图工具,你能够明显的发现这个问题。画了绿色框框的部分,你可以看到的是正好一个45度的大小。

通过画图你也可以这样理解,就是Y轴顺时针方向旋转45度。

饼图绘制的基本思路

上面讲过了画布的一些基本操作,那我们要具体讲一下这个饼图我们该怎么绘制了。讲实话,我代码里已经基本注释了,所以具体的以代码为准,这里只做简单的讲解。几个考虑部分:

  1. 圆和文字怎么兼容,这是一个重难点,我也只能说通过多次尝试,到圆为0.3倍的宽度最为合适了。因为0.5倍,正好占满;0.4倍,文字太小。这个值你可以自行做一个修改处理。
  2. 上面说过的介绍线怎么画。文字的的布局肯定直接根据介绍线来变动了,那这个介绍线就是至关重要的一点了。
  3. 弧形的颜色怎么变换。

基本就是以上问题,问题1我已经做出来回答,后续两个问题将一个个作出解释。

介绍线怎么画

在我的代码中,其实分为两块,一是圆弧,一是介绍,这也是他们的绘制顺序了。但是需要考虑一个问题:介绍线的位置确定?

这个问题你需要看一下我的图例了,你有没有注意到,我的介绍线,好像都是关于单个圆弧居中的呢?

那我们的方案其实就来了,上面我们讲到过了什么?Rotate还记得这个函数嘛!!通过数据运算,有了每个圆弧的大小,那我们还不能去进行绘制嘛?不就是先划线,再旋转吗。然后旋转就是这样的一个公式要去计算。

代码语言:javascript复制
// 旋转角度 = 前面的弧度   当前弧度的一半
private fun getRatioSum(j: Int): Float {
        var sum = 0f
        for (i in 0 until j) {
            sum  = mRatios!![i]
        }
        return sum
    }

    private fun getRatioHalfSumDegrees(j: Int): Float {
        var sum = getRatioSum(j)
        sum  = mRatios!![j] / 2
        return sum * 360
    }

弧形的颜色怎么变换

跟前面的圆弧一样,但是是一个个圆弧来组成圆,那这个时候,就是要知道前面的弧度,然后对当前的弧度的计算,也就是扫过的区间进行计算了。

代码语言:javascript复制
private fun drawArc(canvas: Canvas) {
        val drawArc = 360 * scale
        for (i in mRatios!!.indices) {
            mArcPaint?.color = mArcColors!![i]
            mArcPaint?.let {
                canvas.drawArc(
                    arcRect!!,
                    getRatioSum(i) * drawArc,
                    mRatios!![i] * drawArc,
                    true,
                    it
                )
            }
        }
    }

基本上就是靠上述的两个来完成的,是不是还是比较简单的呢。冲冲冲,Demo抄起来,你就有一份自己的饼图绘制了。

是不是总觉得还缺了什么?

在之前我们讲到过了PaintCanvas的一些用法,但是其实还不能解决全部问题,在我们的项目中你是不是有需要这样的东西——滑动和动画。对!!如何实现像RecyclerView的滑动效果,和一个动画效果,接下来我们将会讲到。

滑动

其实为了寻求方便,直接使用的是Android已经封装好的类GestureDetector。内部存在一个简单的手势分辨的内部类SimpleOnGestureListener,我们可以直接调用然后完成滑动这个动作。

代码语言:javascript复制
注意!!!他有一个bug,因为实现了速度监测,滑动过快时就可能滑出。
代码语言:javascript复制
private inner class BarGesture : SimpleOnGestureListener() {

        override fun onDown(e: MotionEvent?): Boolean {
            return true
        }

        override fun onScroll(
            e1: MotionEvent?,
            e2: MotionEvent?,
            distanceX: Float,
            distanceY: Float
        ): Boolean {
            if (mBarShowNum <= mMinBarScrollShowNum) return false

            val position = dp2px(context, scrollX.toFloat())
            if (distanceX >= 0) {
                if (position <= mBarMaxWidth) {
                    scrollBy(distanceX.toInt(), 0)
                }
            } else {
                if (distanceX >= -1 * position) {
                    scrollBy(distanceX.toInt(), 0)
                }
            }
            return false
        }
    }

为了滑动,自动会有一个距离的要求了,我们调用View已经定义好的ScrollBy,就可以进行挪移了。

总体来说,其实他已经给我们封装好了,要做的就是一个方向的判断。另外在代码中我们能看到scrollX这个变量,这是View已经定义好的一个变量,记住他等等有大用。

关于性能

在之前我们讲到过一个问题,就是Android的坐标系是怎样的。有所遗忘的读者们可以重温一下上面的文章。对于超出屏幕宽度的图案我们是否会进行绘制呢?

代码语言:javascript复制
override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
        // 1
        var count:Int = 0
        for (i in mData!!.indices) {
//            if (!checkIsNeedDraw(i)) {
                val height = (mMaxData - mData!![i]) / mMaxData * mBarMaxHeight
                mBarPaint?.shader = null
                drawBarValue(canvas, i, height)
                drawDescriptions(canvas, i)
                mBarPaint?.shader = shader
                drawBars(canvas, height)
                // 2
                count  
//            }
            if (i != mData!!.size - 1) {
                canvas?.translate(mBarSingleWidth, 0f)
            }
        }
        // 3
        Log.e("onDraw", count.toString())
    }

标注的代码在BarChartView类的源码中加一下就好了。我给出的数据量是7个,那看看结果给出是几次呢!!!

Oh my God!!!7次,真的是7次,我的天呐,那不就是说,我超出的部分你也要画????我要你有啥用啊,你别给我画呀。

算了算了,知道了要绘制,那我们就要找解决方案了。我们刚刚提及到了一个scrollX这个变量,其实就是记录我们当前屏幕最左边相对于坐标轴而言的,在坐标X轴的哪个位置了,而屏幕大小又是固定的,那我们就可以引申出这样的想法。

引申出的代码如下:

代码语言:javascript复制
private fun checkIsNeedDraw(i: Int): Boolean {
        if (mBarSingleWidth * (i   1) < scrollX) return true
        if (mBarSingleWidth * i > scrollX   mWidth) return true
        return false
    }

看看加了他的效果把。

出现了6着数值,说明我们绘制的数量明显减少了不是。证明方法有效!!!!

动画

动画其实是一个很大的知识点了,这里不会仔细讲这个,一方面是因为他的知识容量大,一方面是我的接触的还不够全面。

代码语言:javascript复制
private fun initAnimation() {
        animator = ValueAnimator.ofFloat(0.3f, 1f)
        // 通过插值器来完成动画
        animator?.interpolator = LinearInterpolator()
        animator?.duration = 1500
        animator?.addUpdateListener { animation ->
            scale = animation.animatedValue as Float
            postInvalidate()
        }
    }

先来看看我的代码好了。我们能看到一个LinearInterpolator()这样的玩意儿,定位一下他,就可以看到他的大家族成员有谁了。

通过动画时间的设定,和一个scale也就是值的改变,动态的更改数据,其实也就是不断重绘,来完成我们所能够看到的逐渐增长的效果。想想也应该没有这么困难了,你可以直接调用这些类,来完成一些简单的动画效果。

柱状图的绘制

重头戏,重头戏!!!

其实我们之前已经考虑了很多我在写柱状图的时候遇到的一些问题了,所以大致上的话还是以看源码为基准,这里主要展示的还是我的一个设计稿,因为你要去绘制一个柱状图的话,肯定是要有一定的计算的。

基本就是靠这个简陋的设计稿完成的我的这个类的了,有Bug请见谅。

基础的性能优化

首先的话我们先了解如何去知道一个View是否被过度绘制了?

其实在我们手机中的开发模式已经存在这个选项了。

开启前

开启后

下方给出绘制的次数对应图

那如何做到性能优化呢?

在这个问题之前,需要了解什么是过度绘制,你可以理解为同一位置的控件不断的叠加而产生的无用数据,那我们就来说说集中解决方案吧。

方案1: 减少嵌套层数。

使用线性布局

使用约束布局

因为只是一个案例,想说的意思,如果多个LinearLayout嵌套实现的效果,如果能被一个ConstraintLayout直接实现,那么就用后者替代,因为不会这样在同一个区域重复出现

方案2: 去除默认的背景

这个解决方案其实针对的背景会被自动绘制的问题,如果我们把这个层次消去,从绘制角度老说也是一种提升了。正如图示一般直接减少了一层的绘制。

在代码中的具体表现,通过对style.xml中的Theme进行修改:

代码语言:javascript复制
<item name="android:windowBackground">@null</item>

方案n,详见下图:

希望你能Star一下我的这个项目View_How_To_Make_It

0 人点赞