您的位置:首页 > 大数据 > 人工智能

自定义Textview-Read The Fucking Source Code

2018-02-01 20:02 302 查看
关于自定义view的实现,有很多大神前辈都给出了很好的日志文章。因项目需求,需要做一个显示循环小数的textview控件,本篇文章就记录一下实现过程中使用到和学习到的一些知识。

三部曲

有过开发经验的都知道,自定义view展示需要三个步骤;

步骤回调方法功能说明注释
测量onMeasure(int widthMeasureSpec, int heightMeasureSpec)主要测量当前控件以及子控件的方法
布局onLayout(boolean changed, int left, int top, int right, int bottom)放置子控件的位置,回调方法为 ,如果只是单纯的不包含子控件的控件,即自定义textview,imageview这类view的话,是不需要重写这个方法的。比如LinearLayout和Framlayout,RelativeLayout这些继承自ViewGroup的,可以含子控件的view就需要重写
绘制onDraw(Canvas canvas)使用canvas绘制当前控件

1.三部曲一测量-onMeasure

测量的主要回调方法就是 onMeasure(int widthMeasureSpec, int heightMeasureSpec),

调用时机:

这个方法在布局文件xml文件解析完了之后,父控件测量子控件的时候调用,


两个参数

宽高(widthMeasureSpec 和 heightMeasureSpec)就是android解析xml文件里定义的宽高比如(wrap_content,match_parent,或者具体值)得到的,含测量模式和宽高值
(当然也不一定,因为有些控件会测量多次,那么传入的值和模式可能就会变化)


而这两个参数值的生成却是由一个类来定义的,这个类是View的一个静态内部类 –MeasureSpec

所以清楚android的是怎么测量控件的宽高的,就先要了解MeasureSpec这个类

1.1MeasureSpec测量模式

测量的时候,控件怎么测量子控件,子控件怎么告诉父控件需要的宽高是多少,如果父控件空间不够了,怎么处理?

总的来说就是如何测量,为此

在MeasureSpec类定义了测量的三种模式

/**
* 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;


具体说明

模式xml值举例说明
UNSPECIFIED0x0<<30wrap_content不指定,不限制子控件的大小,子控件需要多大就可以是多大
AT_MOST0x1<<30match_parent(fill_parent)子控件最大化 ,通常就是父控件的大小
EXACTLY0x2<<30100dp等父控件会指定一个大小区域给子控件,可不管子控件想要多大就给多大的空间了

1.2 MeasureSpec构造

我们知道onMeasure方法传入的宽高值是两个int值,这两个int值是怎么包含模式值和控件的真实宽高的,查看MeasureSpec源码却发现没有构造函数,是通过makeSafeMeasureSpec(int size, int mode)方法传入一个size,和mode来构建MeasureSpec值的,

private static final int MODE_MASK  = 0x3 << 30;
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);
}
}

public static int makeSafeMeasureSpec(int size, int mode) {
if (sUseZeroUnspecifiedMeasureSpec && mode == UNSPECIFIED) {
return 0;
}
return makeMeasureSpec(size, mode);
}


方法很简单,结果就是int返回的int值高两位代表测量的模式,后面的表示具体的值,而MeasureSpec也提供了两个静态方法获取测量模式(getMode)和值(getSize)

所以,onMeasure(int widthMeasureSpec, int heightMeasureSpec)通过传入的参数widthMeasureSpec就可以获取到父控件或者是xml里定义的测量值以及测量模式,这样就可以在这个方法里自定义自己的测量模式

回到项目需求,绘制循环数的思路分两部分,一部分是文字的绘制,第二部分是点的绘制,(暂时不考虑换行),所以测量代码就比较简单了宽度就是文字的宽度,高度就是文字的高度+点的高度+点和文字的间距。这里的难点在于文字的宽高不好测量,比如数字1和3和中文所占的宽度就不一样。实际上不用担心android都给我们提供了api就是Paint类里的getTextBounds方法,传入一个String,开始和结束的角标,bounds对象为存储测量的边界,稍微转换一下就是宽高了

/**
* Return in bounds (allocated by the caller) the smallest rectangle that
* encloses all of the characters, with an implied origin at (0,0).
*
* @param text string to measure and return its bounds
* @param start index of the first char in the string to measure
* @param end 1 past the last char in the string to measure
* @param bounds returns the unioned bounds of all the text. Must be allocated by the caller
*/
public void getTextBounds(String text, int start, int end, Rect bounds) {
if ((start | end | (end - start) | (text.length() - end)) < 0) {
throw new IndexOutOfBoundsException();
}
if (bounds == null) {
throw new NullPointerException("need bounds Rect");
}
nGetStringBounds(mNativePaint, mNativeTypeface, text, start, end, mBidiFlags, bounds);
}


所以文字的宽高就测量出来了,文字的高+点的高度和点和文字间距就是控件的高度,因为这里不考虑换行,所以文字的宽度就是控件的宽度,具体的测量代码如下

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
mPaint.getTextBounds(mText, 0, mText.length(), mBound);
setMeasuredDimension(measureWidth(widthMeasureSpec), measureHeight(heightMeasureSpec));
}

private int measureWidth(int widthMeasureSpec) {
return resolveSize(mBound.width(),widthMeasureSpec);
}

private int measureHeight(int heightMeasureSpec) {
return resolveSize(mBound.height() + mDotLineHeight, heightMeasureSpec);
}


resolveSize方法的就是兼容各个测量模式下这个值是否可行,源码如下

public static int resolveSize(int size, int measureSpec) {
return resolveSizeAndState(size, measureSpec, 0) & MEASURED_SIZE_MASK;
}

public static int resolveSizeAndState(int size, int measureSpec, int childMeasuredState) {
final int specMode = MeasureSpec.getMode(measureSpec);
final int specSize = MeasureSpec.getSize(measureSpec);
final int result;
switch (specMode) {
case MeasureSpec.AT_MOST:
if (specSize < size) {
result = specSize | MEASURED_STATE_TOO_SMALL;
} else {
result = size;
}
break;
case MeasureSpec.EXACTLY:
result = specSize;
break;
case MeasureSpec.UNSPECIFIED:
default:
result = size;
}
return result | (childMeasuredState & MEASURED_STATE_MASK);
}


就是依据父控件给的measureSpec和我们计算出来的size做兼容,如果父控件给的specSize比我们的计算值还要小,那么当前控件也只能是父控件的宽高了~,到此,测量就基本结束了,(但是比如RelativeLayout相对布局等比较复杂的布局可能会测量多次,因为一次可能测不准子控件的宽高–!)

1.3 测量补充

Paint类的getTextBounds方法只是给出了String的最小边距,并没有考虑文字与文字之间的间距,默认的文字和文字之间的间距是由字体文件决定的,所以这个方法并不能测量很准确的文字的宽度,如果文字多的话,可能会导致测量宽度小,一部分文字显示不出来,所以下面给出一个TextView里测量文字的宽方法(Read The Fucking Source Code)

android.text.Layout类里的方法getDesiredWidth(可以精确的得到一个view展示一行文字需要的宽度)

/**
* Return how wide a layout must be in order to display the specified text with one line per
* paragraph.
*
* <p>As of O, Uses
* {@link TextDirectionHeuristics#FIRSTSTRONG_LTR} as the default text direction heuristics. In
* the earlier versions uses {@link TextDirectionHeuristics#LTR} as the default.</p>
*/
public static float getDesiredWidth(CharSequence source,
TextPaint paint) {
return getDesiredWidth(source, 0, source.length(), paint);
}


所以测量方法需要改宽度的测量,代码如下

private int measureWidth(int widthMeasureSpec) {
return resolveSize((int) Math.ceil(Layout.getDesiredWidth(mText, mPaint)), widthMeasureSpec);
}


测量完成,view要怎么放,放在那里,就需要第二步——布局来完成

2.三部曲-布局-onLayout

如果是单纯的View的话,不是ViewGroup类的话(没有child的自定义view),是不需要复写布局这个方法的。这里就分析LinearLayout的垂直布局是如何完成的就好了

public static final int HORIZONTAL = 0;
public static final int VERTICAL = 1;

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
if (mOrientation == VERTICAL) {
layoutVertical(l, t, r, b);
} else {
layoutHorizontal(l, t, r, b);
}
}


所以,如果我们在使用LinearLayout如果不指定Orientation,默认的是横向布局哒,下面看layoutVertical方法实现,代码比较长,分段分析

final int paddingLeft = mPaddingLeft;

int childTop;
int childLeft;

// Where right end of child should go
final int width = right - left;
int childRight = width - mPaddingRight;

// Space available for child
int childSpace = width - paddingLeft - mPaddingRight;

final int count = getVirtualChildCount();

final int majorGravity = mGravity & Gravity.VERTICAL_GRAVITY_MASK;
final int minorGravity = mGravity & Gravity.RELATIVE_HORIZONTAL_GRAVITY_MASK;

switch (majorGravity) {
case Gravity.BOTTOM:
// mTotalLength contains the padding already
childTop = mPaddingTop + bottom - top - mTotalLength;
break;

// mTotalLength contains the padding already
case Gravity.CENTER_VERTICAL:
childTop = mPaddingTop + (bottom - top - mTotalLength) / 2;
break;

case Gravity.TOP:
default:
childTop = mPaddingTop;
break;
}


这部分代码主要是计算padding,因为android里的坐标系是左上角为原点,这里解释下开始的childTop在Gravity.BOTTOM的时候的计算,如果LinearLayout里设置Gravity是从底部开始布局,那么childTop的计算就不是单纯的paddingTop了,计算方式如下

childTop = mPaddingTop + bottom - top - mTotalLength;


很奇怪,一下子看不懂呀~

回归正常思维,如果从底部开始布局的话,那么第一个child的top应该是LinearLayout的高(bottom - top)减去所有child的高,以及child之间的间距,再减去底部的padding(mPaddingBottom)

也就是说是

childTop=bottom - top-(child的高+child之间的间距)-mPaddingBottom


再看看mTotalLength是个什么鬼,这个值需要从onMeasure里看了,这里不详细贴源码分析了(下面是关键的代码),mTotalLength在竖直布局的时候,

mTotalLength = child的高+child之间的间距 + mPaddingBottom + mPaddingTop


所以源码里的这个计算没问题,只是有点绕,下面是mTotalLength在竖直布局下的关键计算部分(同样的就可以理解居中放置child的计算了,然后发现padding的设置会影响居中,也就是说如果paddingtop很大,paddingbottom很小,那么会导致Gravity.CENTER_VERTICAL的效果就是偏底部的啦)。

void measureVertical(int widthMeasureSpec, int heightMeasureSpec) {
mTotalLength = 0;
for (int i = 0; i < count; ++i) {
... ...
// 计算divider的高度
if (hasDividerBeforeChildAt(i)) {
mTotalLength += mDividerHeight;
}
... ...
if (heightMode == MeasureSpec.EXACTLY && useExcessSpace) {
......
// 计算MeasureSpec.EXACTLY 模式下child的高度
mTotalLength = Math.max(totalLength, totalLength + lp.topMargin + lp.bottomMargin);
skippedMeasure = true;
} else {
......
// 计算child的高度
mTotalLength = Math.max(totalLength, totalLength + childHeight + lp.topMargin +
lp.bottomMargin + getNextLocationOffset(child));

}
}
......
// Add in our padding 计算LinearLayout的padding
mTotalLength += mPaddingTop + mPaddingBottom;
......
}


ok,第一个child的top位置计算好了,下面就是从这个位置一个个的放置child就好了,下面继续看layoutVertical布局child的部分

for (int i = 0; i < count; i++) {
final View child = getVirtualChildAt(i);
if (child == null) {
childTop += measureNullChild(i);
} else if (child.getVisibility() != GONE) {
final int childWidth = child.getMeasuredWidth();
final int childHeight = child.getMeasuredHeight();

final LinearLayout.LayoutParams lp =
(LinearLayout.LayoutParams) child.getLayoutParams();

int gravity = lp.gravity;
if (gravity < 0) {
gravity = minorGravity;
}
final int layoutDirection = getLayoutDirection();
final int absoluteGravity = Gravity.getAbsoluteGravity(gravity, layoutDirection);
switch (absoluteGravity & Gravity.HORIZONTAL_GRAVITY_MASK) {
case Gravity.CENTER_HORIZONTAL:
childLeft = paddingLeft + ((childSpace - childWidth) / 2)
+ lp.leftMargin - lp.rightMargin;
break;

case Gravity.RIGHT:
childLeft = childRight - childWidth - lp.rightMargin;
break;

case Gravity.LEFT:
default:
childLeft = paddingLeft + lp.leftMargin;
break;
}

if (hasDividerBeforeChildAt(i)) {
childTop += mDividerHeight;
}

childTop += lp.topMargin;
setChildFrame(child, childLeft, childTop + getLocationOffset(child),
childWidth, childHeight);
childTop += childHeight + lp.bottomMargin + getNextLocationOffset(child);

i += getChildrenSkipCount(child, i);
}
}


这部分就是一个for循环,循环计算每个child的位置(left,top,right,bottom),然后调用child的onlayout方法啦,这样布局就完成,其他的布局比如RelativeLayout,FrameLayout等有不同的特性布局,都是依据这两步骤来设置哒,

2.2 LinearLayout的Divider

之前没发现LinearLayout还有divider的功能呀,通过看源码发现了LinearLayout的Divider用法!!!

就是可以给每个child设置一个分隔图片,对应的api如下

方法说明
setDividerDrawable(Drawable divider)设置间隔图片
setDividerPadding(int padding)设置分隔的间距
setShowDividers(@DividerMode int showDividers)设置Divider显示的模式,开始显示,结束显示,还是child之间显示,不显示;
第二步layout也就完成了

3 三部曲-绘制-onDraw

onDraw方法里就是对内容进行绘制了,先提一些问题:

1.onDraw是绘制所有的view的内容么?
2.setBackground或者在xml里设置的Background会不会因为复写的onDraw方法没执行super.onDraw()导致不显示?
3.绘制顺序是怎么样的?


绘制内容的话主要就是使用Canvas的api了,画文字图片,线段等等,需要怎么绘制就找对应的api就好了

这里需要注意的是canvas.drawText方法是从文字的底部开始绘制的,传入的第三个参数即y 需要计算文字的高度,toppadding等值的!

绘制显示循环小数的完整代码如下:

public class DotNumView extends View {
public DotNumView(Context context) {
super(context);
init();
}

public DotNumView(Context context, AttributeSet attrs) {
super(context, attrs);
initAttrs(context, attrs);
init();
}

public DotNumView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initAttrs(context, attrs);
init();
}

private void initAttrs(Context context, AttributeSet attrs) {
TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.DotNumView);
if (ta != null) {
mTextColor = ta.getColor(R.styleable.DotNumView_android_textColor, DEFAULT_TEXT_COLOR);
mTextSize = ta.getDimensionPixelSize(R.styleable.DotNumView_android_textSize,
(int) DEFAULT_TEXT_SIZE);
mText = ta.getString(R.styleable.DotNumView_android_text);
mDotPadding = ta.getDimension(R.styleable.DotNumView_dotPadding, DEFAULT_DOT_PADDING);
mDotRadius = ta.getDimension(R.styleable.DotNumView_dotRadius, DEFAULT_DOT_RADIUS);
ta.recycle();
}

}

/**
* 默认文字大小
*/
private static final float DEFAULT_TEXT_SIZE = 30;

/**
* 默认的点半径
*/
private static final float DEFAULT_DOT_RADIUS = 2;

/**
* 默认的点和文字的距离
*/
private static final float DEFAULT_DOT_PADDING = 5;
private static final int DEFAULT_TEXT_COLOR = Color.BLACK;

/**
* 默认的右侧padding
*/
private static final float DEFAULT_PADDING_RIGHT = 3;

/**
* 文字大小
*/
float mTextSize = DEFAULT_TEXT_SIZE;

/**
* 点半径
*/
float mDotRadius = DEFAULT_DOT_RADIUS;

/**
* 点和文字的距离
*/
float mDotPadding = DEFAULT_DOT_PADDING;

/**
* 需要绘制的文字
*/
String mText = "";
int mTextColor = DEFAULT_TEXT_COLOR;

/**
* 文字边框
*/
Rect mBound = new Rect();

private TextPaint mPaint;

/**
* 需要画点的文字角标
*/
private int[] mDotIndexs;

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
mPaint.getTextBounds(mText, 0, mText.length(), mBound);
setMeasuredDimension(measureWidth(widthMeasureSpec), measureHeight(heightMeasureSpec));
}

private int measureWidth(int widthMeasureSpec) { return resolveSize((int) Math.ceil(Layout.getDesiredWidth(mText, mPaint)), widthMeasureSpec); }
private int measureHeight(int heightMeasureSpec) {
return resolveSize((int) (mBound.height() + mDotRadius * 4 + mDotPadding * 4),
heightMeasureSpec);
}

private void init() {
mPaint = new TextPaint();
mPaint.setColor(mTextColor);
mPaint.setTextSize(mTextSize);
mPaint.setAntiAlias(true);
mPaint.setStyle(Paint.Style.FILL);

if (mText == null) {
mText = "";
}
}

public void setTypeFace(Typeface textFont) {
mPaint.setTypeface(textFont);
}

/**
* 设置文字颜色
*
* @param color
*/
public void setTextColor(int color) {
mTextColor = color;
invalidate();
}

/**
* 设置点和文字的间距
*/
public void setDotPadding(int padding) {
mDotPadding = padding;
refresh();
}

/**
* 设置文字
*
* @param text
*/
public void setText(String text) {
if (text == null) {
return;
}
this.mText = text;
refresh();
}

/**
* 设置文字 index为需要画点的文字角标
*
* @param text
*/
public void setTextWithDotIndex(String text, int... index) {
if (text == null) {
return;
}
this.mText = text;
mDotIndexs = index;
refresh();
}

private void refresh() {
requestLayout();
invalidate();
}

@Override
protected void onDraw(Canvas canvas) {
// super.onDraw(canvas);
mPaint.setColor(mTextColor);
mPaint.getTextBounds(mText, 0, mText.length(), mBound);
canvas.drawText(mText, 0, mBound.height() + mDotRadius * 2 + mDotPadding * 2, mPaint);

if (mDotIndexs == null) {
return;
}
for (int index : mDotIndexs) {
drawDot(canvas, index);
}
}

/**
* 绘制点
*
* @param canvas
* @param i
*/
private void drawDot(Canvas canvas, int i) {
if (i >= mText.length()) {
return;
}
Rect dotBound = new Rect();
mPaint.getTextBounds(mText, 0, i, dotBound);
int left = dotBound.right;
mPaint.getTextBounds(mText, 0, i + 1, dotBound);
float x = (dotBound.right + left) / 2.0f + mDotRadius;
canvas.drawCircle(x, mDotRadius + mDotPadding, mDotRadius, mPaint);
}
}


3.1 onDraw并不是绘制全部

这里我们看View里的源码就好了,View里draw方法,关键代码和注释如下

@CallSuper
public void draw(Canvas canvas) {
......
// Step 1, draw the background, if needed
if (!dirtyOpaque) {
drawBackground(canvas);
}
.....
// Step 2, save the canvas' layers
.....
// Step 3, draw the content
if (!dirtyOpaque) onDraw(canvas);

// Step 4, draw the children
dispatchDraw(canvas);

// Step 5, draw the fade effect and restore layers
......
// Step 6, draw decorations (foreground, scrollbars)
onDrawForeground(canvas);
.....
}


简单翻译一下 ,

1.如果需要,绘制背景
2.保存canvas状态
3.绘制内容,也就是调用ondraw方法
4.绘制child
5.绘制阴影等效果,恢复canvas的状态
6.绘制前景色,绘制scrollbars等


所以即使自定义view的时候,background,ondraw内容,child,Foreground等是层级绘制上去的,后面的会覆盖前面(如果有重叠)。

实际上里面还有

4.总结

onMeasure测量有3种模式,关键类MeasureSpec

文字的测量需要使用TextPaint,Layout,Paint类的getTextBounds获取的是文字的最小区域,

常用的布局(LinearLayout等)都是在onLayout里处理其特性

画文字是从文字底部开始绘制

绘制顺序:background->onDraw->绘制child->绘制阴影-> Foreground
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息