目录
写在前面
一、自定义View简介
1.1、什么是自定义View?
1.2、构造函数调用场景
1.3、onMeasure()方法
问题延伸(面试题):ScrollView嵌套ListView为什么会显示不全(只显示一条)?
1.4、onDraw()方法
1.5、onTouchEvent()方法
1.6、自定义属性
二、自定义TextView
2.1、自定义属性
2.2、实现TextView
2.3、在布局中使用
2.4、运行效果
今天要说的内容可能对我来说是从做Android以来一直都是迷迷糊糊的一个东西——自定义View,这个标题很大哈,因为它涉及到的东西确实很多,这对安卓的初中级工程师来说可能也确实是一个比较晦涩难懂的东西,比如你们公司的UI设计了一个比较复杂的效果让你去做,可能你就要开始百度或者Google了,我之前也一直是这样的一个状态,因为也是工作了几年了,发现自己也是到了一个瓶颈期,所以这个时候确实需要静下来仔细思考一下,究竟该如何提升自己了,当你静下心来的时候,你会发现其实这一切都会很容易想的通,因为是进阶,所以首先你要确定好一个方向,从技术的角度来说就是先确定好一个细分领域,然后从基础开始稳扎稳打,一步一步的顺着一个技术栈由上到下做好每一个层级上的内容整合,慢慢的给自身形成一个技术体系,这样一个系列一个系列的逐个攻破,说了这么多的废话只是自己的一个思考,想表达的思想很简单就是学会思考学会总结提升自己。
OK,废话不多说了,开始今天的内容。接下来我会分两部分来说今天的内容,第一部分对自定义View做一个介绍,第二部分通过自定义一个系统的TextView作一个入门。
关于自定义View的概念,说实话很难用概念性的语言对它作一个定义,我在网上找了很久也没看到能说的很明白的,所以这里只能用大白话来介绍,自定义View可以分为两类,一类是继承自系统的View,另一类是继承自系统的ViewGroup,ViewGroup底层还是继承自View,并且这两类都是系统没有提供给我们的效果(系统提供的比如TextView/ImageView/Button等等)。
当我们开始自定义View的时候,一般都会重写它的构造方法,那我们知道这些构造方法都是在什么场景下被调用的吗?
package com.jarchie.customview;
import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
import androidx.annotation.Nullable;
/**
* 作者: 乔布奇
* 日期: 2020-04-19 16:11
* 邮箱: [email protected]
* 描述: 自定义TextView
*/
public class TextView extends View {
public TextView(Context context) {
super(context);
}
public TextView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public TextView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public TextView(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
}
第一个构造方法:它会在代码里面new的时候调用,比如你在代码中这样写的时候:TextView tv = new TextView(this);
第二个构造方法:在布局layout中使用的时候,比如我们在布局中这样写:
第三个构造方法:在布局layout中使用但是会有style的时候调用,这个是什么意思呢?比如我们布局中某个控件有很多公共的属性,一般情况下我们会将这些公共属性提取出来放在style中,然后在布局中引用style,比如:
第四个构造方法:关于这个构造方法,直接拿过来你会发现它会报错,这里先不说了,等后面用到的时候再说,防止把大家搞晕了
说到自定义View首先要说的肯定是这个函数onMeasure(),而且绝大多数情况都是离不开它的,这个函数是用来测量布局的宽高的,意思就是自定义View中布局的宽高都是由这个方法去指定的,那么它是如何去测量如何指定的呢?
我们首先来看一下上面这张图,比如蓝色部分是我们的ViewGroup,或者换句话说是父控件,内部垂直摆放两个文本控件,每个文本控件的内容填充的不一样,上面的文本宽度设置的是wrap_content,下面的文本设置的是100dp,思考一下系统是如何对各自指定对应的宽高的呢?这就要引出接下来要介绍的内容了:测量模式。
什么是测量模式呢?如何获取测量模式呢?来看下面的代码:
@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);
}
通过上面的代码,我们可以清楚的看到宽高的测量模式是通过MeasureSpec.getMode()这个方法获取的,这个没啥难度,就是一个简单的api调用,OK,现在关键的是这个MeasureSpec是个啥玩意啊?不懂啊翻译一下,先从字面上看看啥意思:
谷歌给出的结果是测量规格,或者是测量说明书,不管你咋翻译,这玩意看起来都好像或多或少的决定了View的测量过程。不明觉厉,来继续往里翻,点进去看源码去,这里我简单抽取了一部分方便大家理解的:
public static class MeasureSpec {
private static final int MODE_SHIFT = 30;
private static final int MODE_MASK = 0x3 << MODE_SHIFT;
/**
* Measure specification mode: The parent has not imposed any constraint
* on the child. It can be whatever size it wants.
*/
public static final int UNSPECIFIED = 0 << MODE_SHIFT;
/**
* Measure specification mode: The parent has determined an exact size
* for the child. The child is going to be given those bounds regardless
* of how big it wants to be.
*/
public static final int EXACTLY = 1 << MODE_SHIFT;
/**
* Measure specification mode: The child can be as large as it wants up
* to the specified size.
*/
public static final int AT_MOST = 2 << MODE_SHIFT;
/**
* Creates a measure specification based on the supplied size and mode.
* @param size the size of the measure specification
* @param mode the mode of the measure specification
* @return the measure specification based on size and mode
*/
public static int makeMeasureSpec(@IntRange(from = 0, to = (1 << MeasureSpec.MODE_SHIFT) - 1) int size,
@MeasureSpecMode int mode) {
if (sUseBrokenMakeMeasureSpec) {
return size + mode;
} else {
return (size & ~MODE_MASK) | (mode & MODE_MASK);
}
}
/**
* Extracts the mode from the supplied measure specification.
*
* @param measureSpec the measure specification to extract the mode from
* @return {@link android.view.View.MeasureSpec#UNSPECIFIED},
* {@link android.view.View.MeasureSpec#AT_MOST} or
* {@link android.view.View.MeasureSpec#EXACTLY}
*/
@MeasureSpecMode
public static int getMode(int measureSpec) {
//noinspection ResourceType
return (measureSpec & MODE_MASK);
}
/**
* Extracts the size from the supplied measure specification.
*
* @param measureSpec the measure specification to extract the size from
* @return the size in pixels defined in the supplied measure specification
*/
public static int getSize(int measureSpec) {
return (measureSpec & ~MODE_MASK);
}
}
从源码上乍一看好像还挺复杂的,大体扫一遍有与或运算,还有移位运算,仔细一看,其实实现还是很简单的,来详细说说。
MeasureSpec它是一个32位的int值,高2位代表SpecMode(即测量模式),低30位代表SpecSize(即某种测量模式下的规格大小),它通过将SpecMode和SpecSize打包成一个int值来避免过多的对象内存分配,为了方便操作还提供了打包和解包的方法。SpecMode有三大类,每一类都有自己特殊的含义,如下所示:
为什么会出现这种情况呢,其实就是ScrollView在测量子布局的时候会使用UNSPECIFIED这种测量模式,我们到源码中看一下:
首先进入源码中,查看onMeasure()方法,这里只把需要的代码展示出来:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
...
...//此处省略一堆代码
}
发现它调用了父类的onMeasure()方法,我们从super.onMeasure()点击去,跟到这个方法里面看看:
看到了这个measureChild...()方法,点进去这是ViewGroup中的方法:
protected void measureChildWithMargins(View child,
int parentWidthMeasureSpec, int widthUsed,
int parentHeightMeasureSpec, int heightUsed) {
final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
mPaddingLeft + mPaddingRight + lp.leftMargin + lp.rightMargin
+ widthUsed, lp.width);
final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,
mPaddingTop + mPaddingBottom + lp.topMargin + lp.bottomMargin
+ heightUsed, lp.height);
child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}
然后回到子类ScrollView中搜索这个方法,发现该方法在子类中有自己的实现:
@Override
protected void measureChildWithMargins(View child, int parentWidthMeasureSpec, int widthUsed,
int parentHeightMeasureSpec, int heightUsed) {
final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
mPaddingLeft + mPaddingRight + lp.leftMargin + lp.rightMargin
+ widthUsed, lp.width);
final int usedTotal = mPaddingTop + mPaddingBottom + lp.topMargin + lp.bottomMargin +
heightUsed;
final int childHeightMeasureSpec = MeasureSpec.makeSafeMeasureSpec(
Math.max(0, MeasureSpec.getSize(parentHeightMeasureSpec) - usedTotal),
MeasureSpec.UNSPECIFIED);
child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}
childHeightMeasureSpec这个值它的处理方法中传入的是MeasureSpec.UNSPECIFIED。
然后继续找到ListView中的onMeasure()方法:
可以发现它的高度的测量模式是通过ScrollView传递进来的heightMeasureSpec,而这个值正是MeasureSpec.UNSPECIFIED,所以它的高度值最终会走到这个if语句中去:
if (heightMode == MeasureSpec.UNSPECIFIED) {
heightSize = mListPadding.top + mListPadding.bottom + childHeight +
getVerticalFadingEdgeLength() * 2;
}
到这里问题就找到了,这个高度值给的是什么啊?list的top+bottom+一个child的高度,所以就造成了大家遇到的那个问题,滑动的时候界面上始终只展示了一个Item。
问题找到了该如何解决呢?还是看源码,既然进到这个if语句里面有问题,那么我们不让它进到这个if里面不就OK了吗,我们让它进到下面的MeasureSpec.AT_MOST这个if里面去,measureHeightOfChildren内部具体的测量方法这里就不再继续说了,它内部其实就是会不断的测量每个Item并累加最终返回所有Item的高度了,大家有兴趣的自己对着源码看一下吧:
if (heightMode == MeasureSpec.AT_MOST) {
// TODO: after first layout we should maybe start at the first visible position, not 0
heightSize = measureHeightOfChildren(widthMeasureSpec, 0, NO_POSITION, heightSize, -1);
}
其实网上关于这个问题的解决办法还是很多的,大部分人采用的也都是上面这种解决办法,代码量比较少,很方便快捷,我们重写一个MyListView继承自ListView,重写onMeasure()方法,将heightMeasureSpec强行指定为AT_MOST模式:
package com.jk51.clouddoc.ui.widget;
import android.content.Context;
import android.util.AttributeSet;
import android.widget.ListView;
/**
* 作者:created by Jarchie
* 时间:2020/4/20 15:43:48
* 邮箱:[email protected]
* 说明:重写ListView,解决嵌套显示不全问题
*/
public class MyListView extends ListView {
public MyListView(Context context) {
super(context);
}
public MyListView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public MyListView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
//解决显示不全的问题 heightMeasureSpec是一个32位的值,右移两位
heightMeasureSpec = MeasureSpec.makeMeasureSpec(Integer.MAX_VALUE >> 2, MeasureSpec.AT_MOST);
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
}
onDraw()方法在自定义View中就是用来绘制的,它可以绘制的东西有很多,文本、矩形、圆形、圆弧等等,这个方法里面全都是一些api的调用,没什么太多需要介绍的,后面使用到的时候再具体介绍吧:
//绘制的方法
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawText(); //画文本
canvas.drawArc(); //画弧
canvas.drawCircle(); //画圆
}
可以先看一下我之前写的一篇Android自定义绘制基础:https://blog.csdn.net/JArchie520/article/details/78199580
onTouchEvent()方法主要是处理跟用户交互的相关操作,比如:手指触摸按下、移动、抬起等等,这一块涉及到的内容很多信息量很大,包括安卓面试中一个非常著名的问题:安卓的事件分发机制,今天先不分析这一块的实现,后面的话会带着源码分析一下,源码这东西是一看一脸懵逼,进去了就别想出来了,所以对于事件分发事件拦截到后面再看吧,得一步一步来,今天只了解一下最简单的几个api即可:
/**
* 处理跟用户交互的,手指触摸按下、移动、抬起等等
*
* @param event 事件分发事件拦截
* @return
*/
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
Log.e("TAG", "手指按下");
break;
case MotionEvent.ACTION_MOVE:
Log.e("TAG", "手指移动");
break;
case MotionEvent.ACTION_UP:
Log.e("TAG", "手指抬起");
break;
}
return super.onTouchEvent(event);
}
自定义属性就是用来配置的,比如:android:text="Jarchie"是系统的一个自定义属性,当我们在自己写自定义View的时候还这样写可以吗?那当然不可以了,所以我们需要自己去定义你的View的相关属性,那么该如何做呢?
首先我们需要在res/values文件夹下新建一个属性的配置文件,名字可以随意起,但是为了规范,一般命名为attrs.xml,然后xml内部如何编写呢?其实写法都是按套路来就好了,具体的如何定义注释我都放在代码里了,举个栗子:
然后定义了相关属性之后,如何在布局中使用呢?
我们需要声明命名空间:xmlns:jarchie="http://schemas.android.com/apk/res-auto",然后在自己的自定义View中使用,注意命名空间的名称和下方引用的名称需保持一致,名称可以随意起,但是一般都定义为app,我这里用自己的名字做个演示:
最后是如何在自定义View中获取配置的属性呢?其实也都是按套路来的代码了,一起来看一下:
然后你一运行即将发现意外之喜,哎呦报错啦,没错不能这样写哈,因为上面我们在attrs.xml里面定义的那些属性,系统都是有的,也就是说系统不允许你将它已经有的属性重新定义,所以给你一顿报错,该如何解决呢?也很简单啊,属性名称全部改掉,改成系统没有的然后就OK了,比如上面的:
这一部分通过上面介绍的基础知识,来写一个入门的自定义View的案例:自定义TextView。
package com.healthrm.ningxia.ui.view;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Rect;
import android.support.annotation.Nullable;
import android.util.AttributeSet;
import android.util.TypedValue;
import android.view.View;
import com.healthrm.ningxia.R;
/**
* 作者:created by Jarchie
* 时间:2020/4/21 09:15:29
* 邮箱:[email protected]
* 说明:安卓自定义TextView
*/
public class TextView extends View {
private String mText;
private int mTextSize = 15;
private int mTextColor = Color.BLACK;
private Paint mPaint;
public TextView(Context context) {
this(context, null);
}
public TextView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public TextView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
//获取自定义属性
TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.TextView);
mText = array.getString(R.styleable.TextView_archieText);
mTextColor = array.getColor(R.styleable.TextView_archieTextColor, mTextColor);
mTextSize = array.getDimensionPixelSize(R.styleable.TextView_archieTextSize, sp2px(mTextSize));
array.recycle();
mPaint = new Paint();
mPaint.setAntiAlias(true); //抗锯齿
mPaint.setTextSize(mTextSize); //画笔大小
mPaint.setColor(mTextColor); //画笔颜色
}
//sp转px
private int sp2px(int sp) {
return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp, getResources().getDisplayMetrics());
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
//获取宽高的测量模式
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
//1、确定的值,这种情况不需要计算,给的多少就是多少
int width = MeasureSpec.getSize(widthMeasureSpec);
int height = MeasureSpec.getSize(heightMeasureSpec);
//2、wrap_content,需要计算
if (widthMode == MeasureSpec.AT_MOST) {
//计算的宽度与字体的长度、大小有关,用画笔来
Rect bounds = new Rect();
//获取文本的rect
mPaint.getTextBounds(mText, 0, mText.length(), bounds);
width = bounds.width() + getPaddingLeft() + getPaddingRight();
}
if (heightMode == MeasureSpec.AT_MOST) {
Rect bounds = new Rect();
mPaint.getTextBounds(mText, 0, mText.length(), bounds);
height = bounds.height() + getPaddingTop() + getPaddingBottom();
}
//设置控件的宽高
setMeasuredDimension(width, height);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//画文字 text x:开始位置 y:基线 paint
//dy代表的是高度的一半到baseLine的距离
Paint.FontMetricsInt fontMetricsInt = mPaint.getFontMetricsInt();
//top是一个负值 bottom是一个正值 top、bottom的值代表的是baseLine到文字顶部和底部的距离
int dy = (fontMetricsInt.bottom - fontMetricsInt.top) / 2 - fontMetricsInt.bottom;
int baseLine = getHeight() / 2 + dy;
int x = getPaddingLeft();
canvas.drawText(mText, x, baseLine, mPaint);
}
}
这就是我们的一个入门级的自定义View,今天的内容就这么多了,下期再会!