Android自定义View开发的那些事儿

a037db4ce940fc48c6836c4cc9794dda.png

/   今日科技快讯   /

近日,腾讯腾讯内容开放平台今日声明宣布终止“黎明计划”项目,同时向UP主道歉。此前不少B站UP主集体称,遭到腾讯企鹅号“黎明计划”诈骗。他们称在入驻企鹅号之后不仅没有流量没有收入,在B站原有内容也会被下架。腾讯表示,由于该计划在执行过程中管理不严,加上该计划在部分产品设计上也不完善,导致了不少问题。已经入驻的创作者,如果希望退出“黎明计划”,注销帐号即可完成。不过腾讯表示,网传“企鹅号利用视频内容向其他平台投诉原创、甚至打击创作者原有的外平台帐号”的说法不实。

/   作者简介   /

本篇文章来自_wangyibo的投稿,文章主要分享了作者总结的自定义View相关的知识,相信会对大家有所帮助!同时也感谢作者贡献的精彩文章。

_wangyibo的博客地址:

https://juejin.cn/user/3667626520751032

/   什么是自定义View?   /

定义

在Android系统中,看到的应用界面都是View,界面也就是由一个个View组成的,AndroidSdk中为开发者提供了形形色色的View,比如显示文字的TextView,显示图片的ImageView,显示列表数据的ListView等等。但是在开发想实现一个折线统计图,这时候系统将不会在满足需求,需要开发者去通过自定义View来实现。

如何实现

自定义View就是通过继承View或者View的子类,并在新的类里面实现相应的处理逻辑(重写相应的方法),以达到自己想要的效果。

View视图框架结构

5fd924a6a1d798b922c218c09caad214.png

通过流程图可以看出View是所有View的父类,实现一个View都是要继承View,并且可以看出View分为两大类View和ViewGroup,带着这个问题我们去看View和ViewGroup有什么不同之处。

/   为什么使用自定义View   /

在开发中,开发者常常会因为下面四个主要原因去自定义View:

  1. 让界面有特定的显示风格、效果;

  2. 让控件具有特殊的交互方式;

  3. 优化布局;

  4. 封装;

让界面有特定的显示风格、效果

在开发中,Android SDK提供了很多控件,但有时,这些控件并不能满足业务需求。例如,想要用一个折线图来展示一组数据,这时如果用系统提供的View就不能实现了,只能通过自定义View来实现。

让控件具有特殊的交互方式

Android SDK提供的控件都有属于它们自己的特定的交互方式,但有时,控件的默认交互方式并不能满足业务的需求。例如,开发者想要缩放ImageView中的图片内容,这时如果用系统提供的ImageView就不能实现了,只能通过自定义ImageView来实现。

优化布局

有时,有些布局如果用系统提供的控件实现起来相当复杂,需要各种嵌套,虽然最终也能实现了想要的效果,但性能极差,此时就可以通过自定义View来减少嵌套层级、优化布局。

封装

有些控件可能在多个地方使用,如大多数App里面的底部Tab,像这样的经常被用到的控件就可以通过自定义View将它们封装起来,以便在多个地方使用。

/   如何自定义View?   /

在了解如何自定义View前,我们先要知道自定义View包含哪些内容?自定义View包含三部分:

  1. layout(View的布局)

  2. draw(View的绘制)

  3. 触摸反馈(View的点击事件)

在布局阶段我们要知道View的尺寸和位置,在绘制阶段我吗要知道View的内容,触摸反馈我们要得到View的点击事件响应。

其中布局阶段包括测量(measure)和布局(layout)两个过程,另外View的绘布局阶段是为View的绘制和触摸反馈做支持的,当确定了View的位置我们才能去绘制View设置View的触摸反馈。

在自定义View和自定义ViewGroup中,布局和绘制流程虽然整体上都是一样的,但在细节方面,自定义View和自定义ViewGroup还是不一样的,所以,接下来分两类进行讨论:

  1. 自定义View布局、绘制流程

  2. 自定义ViewGroup布局、绘制流程

自定义 View 布局、绘制流程

自定义View最基本的流程图

edb61c630d5769ba2e9855deee34c549.png

从View继承一般需要忙活的方法是onDraw这里。

构造函数 (获取自定义参数)

构造函数中我们主要会做一些初始化操作,以及获取自己的自定义属性参数(如果使用自定义属性的话),如果有使用自定义属性的话,我们可以通过AttributeSet对象attrs获取他们的值。

public LineChartView(Context context) {
    super(context);
}

public LineChartView(Context context, @Nullable AttributeSet attrs) {
    super(context, attrs);
}

public LineChartView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
}

@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
public LineChartView(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
    super(context, attrs, defStyleAttr, defStyleRes);
}

不管是继承ViewGroup还是View都有四个构造重载方式可供选择,其实四个参数的是API21之后添加的。有三个参数的构造函数中第三个参数是默认的Style,这里的默认的Style是指它在当前Application或Activity所用的Theme中的默认Style,且只有在明确调用的时候才会生效。我们在写自定义View的时候需要关心的通常是有一个和两个参数的构造方法。

自定义View测量阶段

在View的测量阶段会执行两个方法(在测量阶段,View的父View会通过调用View的 measure()方法将父View对View尺寸要求传进来。紧接着View的measure()方法会做一些前置和优化工作,然后调用View的onMeasure()方法,并通过onMeasure()方法将父View对View的尺寸要求传入。

在自定义View中,只有需要修改View的尺寸的时候才需要重写onMeasure()方法。在onMeasure()方法中根据业务需求进行相应的逻辑处理,并在最后通过调用setMeasuredDimension()方法告知父View自己的期望尺寸):

  • measure()

  • onMeasure()

measure():调度方法,主要做一些前置和优化工作,并最终会调用onMeasure()方法执行实际的测量工作;

onMeasure():实际执行测量任务的方法,主要用与测量View尺寸和位置。在自定义View的onMeasure()方法中,View根据自己的特性和父View对自己的尺寸要求算出自己的期望尺寸,并通过setMeasuredDimension()方法告知父View自己的期望尺寸。

onMeasure()计算View期望尺寸方法如下:

参考父View的对View的尺寸要求和实际业务需求计算出View的期望尺寸:

  • 解析widthMeasureSpec;

  • 解析heightMeasureSpec;

  • 将根据实际业务需求计算出View的尺寸根据父View的对View的尺寸要求进行相应的修正得出View的期望尺寸(通过调用resolveSize()方法);

通过setMeasuredDimension()保存View的期望尺寸(实际上是通过setMeasuredDimension()告知父View自己的期望尺寸);

onMeasure(测量View大小)

measure过程要分情况来看,如果只是一个原始的View,那么通过measure方法就完成其测量过程,如果是一个ViewGroup,除了完成自己的测量过程外,还会遍历去调用所有子元素的measure方法。

这里面涉及到一个类MeasureSpec,MeasureSpec代表一个32位int值,高2位代表SpecMode,低30位代表SpecSize。SpecMode是指测量模式,而SpecSize是指在某种测量模式下的规格大小。

SpecMode有三类,每一类都表示特殊的含义

UNSPECIFIED

父容器不对View有任何限制,要多大给多大,这种情况一般用于系统内部。

EXACTLY

父容器已经检测出View所需要的精确大小,这个时候View的最终大小就是SpecSize所以定的值。它对应LayoutParams中的match_parent和具体的数值。

AT_MOST

父容器指定了一个可用大小即SpecSize,View的大小不能大于这个值。她对应于LayoutParams中的wrap_content。

onSizeChanged(确定View的大小)

这个函数在视图大小发生改变时调用。一般情况下onMeasure中就可以把View的大小确定下来了,但是因为View的大小不仅由View本身控制,而且受父控件的影响,所以我们在确定View大小的时候最好使用系统提供的onSizeChanged回调函数。

自定义View布局阶段

layout():保存View的实际尺寸。调用setFrame()方法保存View的实际尺寸,调用onSizeChanged()通知开发者View的尺寸更改了,并最终会调用onLayout()方法让子View布局(如果有子View的话。因为自定义View中没有子View,所以自定义View的onLayout()方法是一个空实现);

onLayout():空实现,什么也不做,因为它没有子View。如果是ViewGroup的话,在onLayout()方法中需要调用子View的layout()方法,将子View的实际尺寸传给它们,让子View保存自己的实际尺寸。因此,在自定义View 中,不需重写此方法,在自定义ViewGroup中,需重写此方法。

自定义View绘制阶段

在View的绘制阶段会执行一个方法——draw(),draw()是绘制阶段的总调度方法,在其中会调用绘制背景的方法 drawBackground()、绘制主体的方法onDraw()、绘制子View的方法 dispatchDraw() 和 绘制前景的方法onDrawForeground()。

draw():绘制阶段的总调度方法,在其中会调用绘制背景的方法drawBackground()、绘制主体的方法onDraw()、绘制子View的方法dispatchDraw()和绘制前景的方法onDrawForeground();

drawBackground():绘制背景的方法,不能重写,只能通过xml布局文件或者setBackground()来设置或修改背景;

onDraw():绘制View主体内容的方法,通常情况下,在自定义View的时候,只用实现该方法即可;

dispatchDraw():绘制子View的方法。同onLayout()方法一样,在自定义View中它是空实现,什么也不做。但在自定义ViewGroup中,它会调用ViewGroup.drawChild()方法,在ViewGroup.drawChild()方法中又会调用每一个子View的View.draw()让子View进行自我绘制;

onDrawForeground():绘制View前景的方法,也就是说,想要在主体内容之上绘制东西的时候就可以在该方法中实现。

注意

Android里面的绘制都是按顺序的,先绘制的内容会被后绘制的盖住。

自定义 ViewGroup 布局、绘制流程

自定义ViewGroup测量阶段

同自定义View一样,在自定义ViewGroup的测量阶段会执行两个方法:

  • measure()

  • onMeasure()

measure():调度方法,主要做一些前置和优化工作,并最终会调用onMeasure()方法执行实际的测量工作;

onMeasure():实际执行测量任务的方法,与自定义View不同,在自定义ViewGroup的onMeasure()方法中,ViewGroup会递归调用子View的measure()方法,并通过measure()将ViewGroup对子View的尺寸要求(ViewGroup会根据开发者对子View的尺寸要求、自己的父View(ViewGroup的父View) 对自己的尺寸要求和自己的可用空间计算出自己对子View的尺寸要求)传入,对子View进行测量,并把测量结果临时保存,以便在布局阶段使用。测量出子View的实际尺寸之后,ViewGroup会根据子View的实际尺寸计算出自己的期望尺寸,并通过setMeasuredDimension()方法告知父View(ViewGroup的父View)自己的期望尺寸。

具体流程如下:

  1. 运行前,开发者在xml中写入对ViewGroup和ViewGroup子View的尺寸要求 layout_xxx;

  2. ViewGroup在自己的onMeasure()方法中,根据开发者在xml中写的对ViewGroup子View的尺寸要求、自己的父View(ViewGroup的父View) 对自己的尺寸要求和自己的可用空间计算出自己对子View的尺寸要求,并调用每个子View 的 measure()将ViewGroup对子View的尺寸要求传入,测量子View尺寸;

  3. ViewGroup在子View计算出期望尺寸之后(在ViewGroup的onMeasure()方法中,ViewGroup递归调用每个子View的measure()方法,子View在自己的onMeasure()方法中会通过调用setMeasuredDimension()方法告知父View(ViewGroup) 自己的期望尺寸),得出子View的实际尺寸和位置,并暂时保存计算结果,以便布局阶段使用;

  4. ViewGroup根据子View的尺寸和位置计算自己的期望尺寸,并通过setMeasuredDimension()方法告知父View自己的期望尺寸。如果想要做的更好,可以在「 ViewGroup根据子View的尺寸和位置计算出自己的期望尺寸」之后,再结合ViewGroup的父View对ViewGroup的尺寸要求进行修正(通过resolveSize()方法),这样得出的ViewGroup的期望尺寸更符合ViewGroup的父View对ViewGroup的尺寸要求。

自定义ViewGroup布局阶段

同自定义View一样,在自定义ViewGroup的布局阶段会执行两个方法:

  • layout()

  • onLayout()

layout():保存ViewGroup的实际尺寸。调用setFrame()方法保存ViewGroup的实际尺寸,调用onSizeChanged()通知开发者ViewGroup的尺寸更改了,并最终会调用onLayout()方法让子View布局;

onLayout():ViewGroup会递归调用每个子View的layout()方法,把测量阶段计算出的子View的实际尺寸和位置传给子View,让子View保存自己的实际尺寸和位置。

自定义ViewGroup绘制阶段

同自定义View一样,在自定义ViewGroup的绘制阶段会执行一个方法——draw()。draw()是绘制阶段的总调度方法,在其中会调用绘制背景的方法drawBackground()、绘制主体的方法onDraw()、绘制子View的方法dispatchDraw()和绘制前景的方法onDrawForeground()。

draw():绘制阶段的总调度方法,在其中会调用绘制背景的方法drawBackground()、绘制主体的方法onDraw()、绘制子View的方法dispatchDraw()和绘制前景的方法onDrawForeground();

在ViewGroup中,你也可以重写绘制主体的方法onDraw()、绘制子View的方法dispatchDraw()和绘制前景的方法onDrawForeground()。但大多数情况下,自定义ViewGroup是不需要重写任何绘制方法的。因为通常情况下,ViewGroup的角色是容器,一个透明的容器,它只是用来盛放子View的。

/   实战演练   /

折线统计图,可以拖动竖线,并且提示当前点的数值。

7931eb77f5d11df93c33483228f0257e.gif

自定义属性的声明与获取

<?xml version="1.0" encoding="utf-8"?>
<resources>

    <declare-styleable name="LineChartView">
        <attr name="horizontal_dotted_color" format="reference|color" />
        <attr name="horizontal_color" format="reference|color" />
        <attr name="vertical_color" format="reference|color" />
    </declare-styleable>

</resources>

TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.LineChartView);
horizontal_color = typedArray.getColor(R.styleable.LineChartView_horizontal_color, Color.BLUE);
horizontal_dotted_color = typedArray.getColor(R.styleable.LineChartView_horizontal_dotted_color, Color.GRAY);
vertical_color = typedArray.getColor(R.styleable.LineChartView_vertical_color, Color.RED);
typedArray.recycle();

重写onDraw

对View的绘制,首先要重写onDraw方法,在绘制过程中,我们需要用到的两个关键对象:

  1. Paint:画笔,使用画笔对象设置画笔的设置例如颜色,线条的粗细等。

  2. Path:设置绘制点的开始和结束位置。

  3. Canvas:画布,通过drawPath将Paint和Path进行绑定后绘制完成。

//Canvas:画布
@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
}

举例使用

//1.1用来绘制文本的画笔
private Paint textPaint() {
    Paint textPaint = new Paint();
    textPaint.setColor(horizontal_color);
    textPaint.setStrokeWidth(3);
    textPaint.setTextSize(30);
    return textPaint;
}

//2.1用来绘制虚线的画笔
private Paint dottedPaint() {
    Paint dottedPaint = new Paint();
    dottedPaint.setAntiAlias(true);
    dottedPaint.setStyle(Paint.Style.STROKE);
    dottedPaint.setStrokeWidth(2);
    dottedPaint.setColor(horizontal_dotted_color);
    return dottedPaint;
}
//2.2虚线 用来显示折线统计图x轴的线
Paint dottedPaint = dottedPaint();
PathEffect pathEffect = new DashPathEffect(new float[]{5, 5, 5, 5}, 2);
dottedPaint.setPathEffect(pathEffect);

//2.3用来绘制虚线的
private void drawLineX(Canvas canvas, Paint textPaint, Paint dottedPaint) {

    for (int i = 0; i < lineNumY; i++) {
        if (lineNumY - 1 == i) {//绘制折线图x轴最底部的线
            Paint paint = new Paint();
            Path path = new Path();
            paint.setColor(Color.BLUE);
            paint.setStrokeWidth(3);
            paint.setStyle(Paint.Style.STROKE);
            paint.setAntiAlias(true);
            path.moveTo(marginLeftRight, marginTopBottom + (i * meanHeight));
            path.lineTo(getWidth() - marginLeftRight, marginTopBottom + (i * meanHeight));
            canvas.drawPath(path, paint);
            paint.reset();
        } else {//绘制折线图x轴的虚线
            Path linePath = new Path();
            linePath.moveTo(marginLeftRight, marginTopBottom + (i * meanHeight));
            linePath.lineTo(getWidth() - marginLeftRight, marginTopBottom + (i * meanHeight));
            canvas.drawPath(linePath, dottedPaint);
        }
        //x轴最右边的数值
        String s = (((lineNumY - 1) - i) * meanValue) + "";
        float method = textPaint.measureText(s);
        canvas.drawText(s, marginLeftRight - method - 10, marginTopBottom + (i * meanHeight) + 10, textPaint);
    }

    dottedPaint.reset();
}



//用来绘制折线的画笔
private Paint brokenLinePaint() {
    Paint brokenLinePaint = new Paint();
    brokenLinePaint.setColor(vertical_color);
    brokenLinePaint.setStrokeWidth(3);
    brokenLinePaint.setStyle(Paint.Style.STROKE);
    brokenLinePaint.setAntiAlias(true);
    return brokenLinePaint;
}
//绘制折线
private void drawLineChart(Canvas canvas, Paint brokenLinePaint) {
    coordBeans.clear();
    Path pathChart = new Path();
    //lintNumX  代表折线共有几个点   折线的点是根据是把x轴平均分成了多少份求出的
    for (int i = 0; i < lintNumX; i++) {
        if (i == 0) {//折线的起点
            pathChart.moveTo(marginLeftRight, marginTopBottom + ((lineNumY - 1) * meanHeight) - axisDataY[i]);
        }
        pathChart.lineTo(marginLeftRight + meanWidth * i, marginTopBottom + ((lineNumY - 1) * meanHeight) - axisDataY[i] / meanVH);//marginTopBottom + ((lineNumY - 1) * meanHeight) - math[i%6])
        canvas.drawCircle(marginLeftRight + meanWidth * i, marginTopBottom + ((lineNumY - 1) * meanHeight) - axisDataY[i] / meanVH, 3, brokenLinePaint);
        coordBeans.add(new CoordBean(marginLeftRight + meanWidth * i, marginTopBottom + ((lineNumY - 1) * meanHeight) - axisDataY[i] / meanVH, axisDataY[i]));
    }
    canvas.drawPath(pathChart, brokenLinePaint);

}

以上是绘制折线图统计图绘制线条的流程。

重写onTouchEvent(MotionEvent event)

接下来我们来看一看,当我们触摸屏幕是如何获取到折线的触摸点。

  • 首先要重写onTouchEvent(MotionEvent event),然后在关注。

  • 手指按下操作MotionEvent.ACTION_DOWN

  • 手指移动操作MotionEvent.ACTION_MOVE

@Override
public boolean onTouchEvent(MotionEvent event) {


   switch (event.getAction()) {
          //按下
       case MotionEvent.ACTION_DOWN:
           downX = event.getX();
           downY = event.getY();
           //按下后获取按下的点是否和折线图的坐标点是否重合,重合的话提示按下的点的数值,绘制y轴的线条
           for (int i = 0; i < coordBeans.size(); i++) {
               if (Math.abs(downX - coordBeans.get(i).getCoordX()) < paddingPath / 2
                       && Math.abs(downY - coordBeans.get(i).getCoordY()) < paddingPath / 2) {
                   isClick = true;
                   isClickIndex = i;
                   scrollX = coordBeans.get(i).getCoordX();
                   invalidate();
                   showDetails(isClickIndex);
                   break;
               }
           }
           return true;
           //移动
       case MotionEvent.ACTION_MOVE:

           float x = event.getX();
           float y = event.getY();
         //按下滑动后获取当前的点是否和折线图的坐标点是否重合,重合的话提示按下的点的数值,绘制y轴的线条
           Log.i("onTouchEvent", x + "---" + y);
           if (x >= startX && x <= endX && y >= startY && y <= endY) {
               CoordBean coorBean = getCoorBean(x);
               if (coorBean != null) {
                   invalidate();
                   showDetails(isClickIndex);
               }
           }

           return true;

   }


   return super.onTouchEvent(event);
}

悬浮提示框

private void showDetails(int index) {
    if (mPopWin != null) mPopWin.dismiss();
    TextView tv = new TextView(getContext());
    tv.setTextColor(Color.WHITE);
    tv.setBackgroundColor(Color.RED);
    tv.setPadding(20, 0, 20, 0);
    tv.setGravity(Gravity.CENTER);
    tv.setText(coordBeans.get(index).getClickCoord() + "");
    mPopWin = new PopupWindow(tv, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
    mPopWin.setBackgroundDrawable(new ColorDrawable(0));
    mPopWin.setFocusable(false);
    // 根据坐标点的位置计算弹窗的展示位置
    int xoff = (int) (coordBeans.get(index).getCoordX() - 0.5 * paddingPath);
    int yoff = (int) (coordBeans.get(index).getCoordY() - paddingPath);
    Log.i("showDetails", coordBeans.get(index).getCoordX() + "---" + coordBeans.get(index).getCoordY());
    mPopWin.showAsDropDown(this, xoff, yoff - getHeight());
    mPopWin.update();
}

以上就是自定义折线统计图的基本思路和流程。

细节

  • invalidate()作用、使用场景、注意事项

  • View(ViewGroup)事件分发

/   总结   /

自定义View主要是三部分:

  1. 绘制draw

  2. 布局layout

  3. 触摸时间event

其中layout决定了View大小个尺寸,是为View下面的绘制和设置触摸事件做基础,一个完整的自定义View三者缺一不可。

推荐阅读:

我的新书,《第一行代码 第3版》已出版!

如何更好地使用Kotlin语法糖封装工具类

Activity Result API详解

欢迎关注我的公众号

学习技术或投稿

923bc09a9eb3085b94d55f381036ae7f.png

a68e4f8493723cd8a01382213114aebe.png

长按上图,识别图中二维码即可关注

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值