自定义Textview-Read The Fucking Source Code

关于自定义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.总结

  1. onMeasure测量有3种模式,关键类MeasureSpec
  2. 文字的测量需要使用TextPaint,Layout,Paint类的getTextBounds获取的是文字的最小区域,
  3. 常用的布局(LinearLayout等)都是在onLayout里处理其特性
  4. 画文字是从文字底部开始绘制
  5. 绘制顺序:background->onDraw->绘制child->绘制阴影-> Foreground
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
要在Android自定义TextView中显示全部内容,可以使用以下两种方法: 1. 使用setEllipsize()方法 通过设置setEllipsize()方法,可以在TextView的末尾添加省略号,从而指示文本被截断。你可以使用以下代码来实现: ``` yourTextView.setEllipsize(TextUtils.TruncateAt.END); yourTextView.setSingleLine(true); ``` 上述代码将设置TextView只显示一行并在末尾添加省略号。 2. 自定义TextView 你可以从TextView类继承一个新类,并覆盖onMeasure()方法以测量控件的高度和宽度。 你可以使用以下代码实现: ``` public class CustomTextView extends TextView { public CustomTextView(Context context) { super(context); } public CustomTextView(Context context, AttributeSet attrs) { super(context, attrs); } public CustomTextView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); //获取TextView的内容 CharSequence text = getText(); if (text != null) { //测量TextView的高度 int width = getMeasuredWidth(); int height = getMeasuredHeight(); int lineCount = getLineCount(); int lineHeight = getLineHeight(); int totalHeight = lineCount * lineHeight; if (totalHeight > height) { setMeasuredDimension(width, totalHeight); } } } } ``` 上述代码将测量TextView的高度,如果文本的高度超出了TextView的高度,则调整TextView的高度以适应文本。然后你可以使用此自定义TextView来显示你的文本。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值