android NumberPicker 全面解析

公西承
2023-12-01

NumberPick全面解析

一个自定义控件的逻辑有哪些?或者说一个自定义控件的行为逻辑都是由哪些方法来控制的。

一般而言,首先是构造方法,然后是onMeasure(),然后是onLayout ,然后是onDraw() 。但是以上这些是静态的行为方法,不涉及与用户的交互。

与用户交互的方法有哪些?首先是dispatchTouchEvent(),然后是onInterceptTouchEvent() ,然后是onTouchEvent()

除了这些交互之外,调用者还可以写view.setOnTouchListener()来重写交互方法。这一步就不管了。


好,那既然是全面解析,就是从静态行为方法 + 与用户交互的方法 来一个完全的解析。

分析之前先简单说明一下背景:NumberPicker继承自LinearLayout,也就是说,这是一个ViewGroup。一般来说,ViewGroup不会去重写onDraw(), 都是子ViewonDraw()ViewGroup负责测量和布局就好了。从这个角度来说,NumberPicker是一个特殊的ViewGroup,因为它里面的很多显示逻辑是在自己的onDraw()里面完成的

构造方法解析

从代码上面可以看到,NumberPicker重写了全部的构造方法,但是实际上执行逻辑在4个参数的构造方法里面。也就是android.widget.NumberPicker#NumberPicker(android.content.Context, android.util.AttributeSet, int, int)

由于这个构造方法里面代码比较多,如果全部贴上来,会影响阅读。所以这里不贴上来了。

看几个关键的地方:

第一 :setWillNotDraw(!mHasSelectorWheel);

这句代码意义很大。它决定了,最终NumberPickeronDraw()方法会不会被执行。但是想一想,如果这个onDraw()不被执行,那么,NumperPicker根本就不是现在看到的效果,而是根本看不到这个可以滚动的效果了,界面什么内容都看不到,只看到背景颜色。 其实这种猜测十分重要。另外,如果对猜测不确定,可以通过反射调用一下,看看mHasSelectorWheel是什么,我本地反射的结果为true. ==> 所以,现在肯定可以确定是onDraw()会执行,mHasSelectorWheel==true

想看NumberPicker的效果图?可以看一下这个链接

第二:看mHasSelectorWheel赋值的地方:
final int layoutResId = attributesArray.getResourceId(
                R.styleable.NumberPicker_internalLayout, DEFAULT_LAYOUT_RESOURCE_ID);

mHasSelectorWheel = (layoutResId != DEFAULT_LAYOUT_RESOURCE_ID);

从第一点知道mHasSelectorWheel==true ,也就是说 layoutResId不是DEFAULT_LAYOUT_RESOURCE_ID。所以DEFAULT_LAYOUT_RESOURCE_ID到底是什么样子的,可以不管了。不过还是可以看一下。

// 删除了部分不重要的属性,只保留了 id , background
<merge xmlns:android="http://schemas.android.com/apk/res/android">

    <ImageButton android:id="@+id/increment"
        android:background="@android:drawable/numberpicker_up_btn"
                android:contentDescription="@string/number_picker_increment_button" />

    <EditText
        android:id="@+id/numberpicker_input"

        android:background="@drawable/numberpicker_input" />

    <ImageButton android:id="@+id/decrement"
                android:background="@android:drawable/numberpicker_down_btn"
                />

</merge>

可以看到默认布局是ImageButton + EditText + ImageButton,这个布局很像NumberPicker显示出来的效果。打开Android Studio的预览界面就可以看到。显示的样子就是上下都是按钮一样的,然后中间是一个输入框的样子。点击还可以输入文字。(但是并不一定将输入的文字显示出来,这个后面有分析)。

这个很误导人,我之前一度认为,NumberPicker使用的就是这个默认布局。但是从前面的分析可以看出,NumberPicker应该是从来不会去使用默认布局的,否则onDraw()就不会被执行了 。

然后再整体看一下这个构造方法的全部逻辑:

首先,给 mHasSelectorWheel赋值为true,并且,当前使用的布局并不是默认布局。(实际布局应该跟ActivityTheme有关) 记住这个变量mHasSelectorWheel,后面会多次使用到。

接下来,就是获取自定义属性给成员变量。注意三个变量:mHideWheelUntilFocusedmSolidColormSelectionDividermVirtualButtonPressedDrawable这几个变量后面都会用到。

然后,在自定义属性获取完成之后注意一段代码:

LayoutInflater inflater = (LayoutInflater) getContext().getSystemService(
                Context.LAYOUT_INFLATER_SERVICE);
        inflater.inflate(layoutResId, this, true);

这段代码很简单,但是也很重要。这就表示,NumberPicker就是加载这个布局并显示的。布局文件,就是layoutResId了。

再往下,是对布局文件里面的ImageButton EditText ImageButton进行findviewbyId(id)了。注意到没有,这里会根据mHasSelectorWheel去判断,如果mHasSelectorWheel==true,就没有 上下两个ImageButton了,但是EditText是永远存在的。这里也可以大胆猜测,实际上使用的布局里面可能只有一个EditText没有其他内容了。比如:

// layoutResId 可以使用的布局文件
<?xml version="1.0" encoding="utf-8"?>
<EditText xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/numberpicker_input"
    android:layout_width="fill_parent"
    android:layout_height="wrap_content"
    android:singleLine="true" />

然后是给mInputText设置了一些监听器,不过这些没有什么意义。因为大部分情况下,我们是要禁止NumberPicker的输入。这个交互不友好。即使不禁止,这些监听器,暂时先不看,里面的方法后面都会有分析。

在设置了这些监听器之后,然后又初始化了一些变量,这里注意几个变量:mSelectorWheelPaintmFlingScrollermAdjustScroller

然后进行了界面初始值的设定:updateInputTextView();,再然后,进行了accessibility相关的设置。这里说明一下,所有accessibility这种辅助相关的,都不会去分析。

那就,先看第一个被构造函数调用的方法updateInputTextView()

updateInputTextView()分析
private boolean updateInputTextView() {
    /*
     * If we don't have displayed values then use the current number else
     * find the correct value in the displayed values for the current
     * number.
     */
    String text = (mDisplayedValues == null) ? formatNumber(mValue)
            : mDisplayedValues[mValue - mMinValue];
    if (!TextUtils.isEmpty(text) && !text.equals(mInputText.getText().toString())) {
        mInputText.setText(text);
        return true;
    }

    return false;
}

这段代码就很简单了,就是刷新输入框的内容。这里也有一些成员变量:mDisplayedValues , mValue , mMinValue。不过这些值全部都提供的 setter方法了,也就是说,这些值就是暴露出来给调用者去设置的。

里面的mDisplayedValues[mValue - mMinValue]简单看一下,这个很简单,比如你代码里面设置 min不是0, 那数组索引就是进行相应的计算才能得到了。(数组肯定总是从0开始索引的)

嗯,到这里,构造方法大致说完了。然后看一下onMeasure测量方法:

onMeasure()方法分析

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    if (!mHasSelectorWheel) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        return;
    }
    // Try greedily to fit the max width and height.
    final int newWidthMeasureSpec = makeMeasureSpec(widthMeasureSpec, mMaxWidth);
    final int newHeightMeasureSpec = makeMeasureSpec(heightMeasureSpec, mMaxHeight);
    super.onMeasure(newWidthMeasureSpec, newHeightMeasureSpec);
    // Flag if we are measured with width or height less than the respective min.
    final int widthSize = resolveSizeAndStateRespectingMinSize(mMinWidth, getMeasuredWidth(),
            widthMeasureSpec);
    final int heightSize = resolveSizeAndStateRespectingMinSize(mMinHeight, getMeasuredHeight(),
            heightMeasureSpec);
    setMeasuredDimension(widthSize, heightSize);
}

private int makeMeasureSpec(int measureSpec, int maxSize) {
        if (maxSize == SIZE_UNSPECIFIED) {
            return measureSpec;
        }
        final int size = MeasureSpec.getSize(measureSpec);
        final int mode = MeasureSpec.getMode(measureSpec);
        switch (mode) {
            case MeasureSpec.EXACTLY:
                return measureSpec;
            case MeasureSpec.AT_MOST:
                return MeasureSpec.makeMeasureSpec(Math.min(size, maxSize), MeasureSpec.EXACTLY);
            case MeasureSpec.UNSPECIFIED:
                return MeasureSpec.makeMeasureSpec(maxSize, MeasureSpec.EXACTLY);
            default:
                throw new IllegalArgumentException("Unknown measure mode: " + mode);
        }
    }

这段代码不太好解释,都是系统api的调用,但是可以大致看出来,它的意思就是测量之后,设置的宽是在mMinWidthmMaxWidth之间,高是在mMinHeightmMaxHeight之间。嗯,这个就这样子了。。。

然后是onLayout()方法

onLayout()方法分析

这个onLayout()代码不长。全部贴出来看。

    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        if (!mHasSelectorWheel) {
            super.onLayout(changed, left, top, right, bottom);
            return;
        }
        final int msrdWdth = getMeasuredWidth();
        final int msrdHght = getMeasuredHeight();

        // Input text centered horizontally.
        final int inptTxtMsrdWdth = mInputText.getMeasuredWidth();
        final int inptTxtMsrdHght = mInputText.getMeasuredHeight();
        final int inptTxtLeft = (msrdWdth - inptTxtMsrdWdth) / 2;
        final int inptTxtTop = (msrdHght - inptTxtMsrdHght) / 2;
        final int inptTxtRight = inptTxtLeft + inptTxtMsrdWdth;
        final int inptTxtBottom = inptTxtTop + inptTxtMsrdHght;
        mInputText.layout(inptTxtLeft, inptTxtTop, inptTxtRight, inptTxtBottom);
        // 到这里,就是把 mInputText 放到 NumberPicker 的正中间位置了。

        if (changed) {
            // need to do all this when we know our size
            initializeSelectorWheel();
            initializeFadingEdges();
            mTopSelectionDividerTop = (getHeight() - mSelectionDividersDistance) / 2
                    - mSelectionDividerHeight;
            mBottomSelectionDividerBottom = mTopSelectionDividerTop + 2 * mSelectionDividerHeight
                    + mSelectionDividersDistance;
        }
    }
首先,让mInputText居中显示了

先说一下为什么说onLayout()mInputEditText居中显示了。

里面有一段计算,是计算mInputEditText将要layout(l,t,r,b)l , t, r , b的值的。 不知道到底在做什么。进行数值代入计算。

假设 editText 宽高都是 10 , NumberPicker 的宽高都是100。
final int inptTxtLeft = (100 - 10)/2 = 45;
final int inptTxtTop = (100 -10)/2 = 45;
final int inptTxtRight = 45 + 10 = 55
final int inptTxtBottom = 45 + 10 = 55;
mInputText.layout(45, 45, 55, 55); // 没有改变EditText 大小,但是让它居中了。

这个居中显示的分析,在上一篇里面有讲到只是上一篇有点凌乱,不太好阅读。

ps onLayout里面并没有判断如果ImageButton存在,该怎么布局。也许当初设计的时候,就默认这两个ImageButton根本没有机会存在。

然后是执行一些初始化操作

可以确定是,change在第一次肯定是true, 也就是说,if里面的代码至少被执行一次的。
这里首先调用了一个方法 initializeSelectorWheel()

initializeSelectorWheel()的分析
    private void initializeSelectorWheel() {
        initializeSelectorWheelIndices();
        int[] selectorIndices = mSelectorIndices;
        int totalTextHeight = selectorIndices.length * mTextSize; // 每行文字高度的总和
        float totalTextGapHeight = (mBottom - mTop) - totalTextHeight;// 控件高度 - 文字高度总和 = 文字间隙高度总和
        float textGapCount = selectorIndices.length; // 文字间隙高度总量
        mSelectorTextGapHeight = (int) (totalTextGapHeight / textGapCount + 0.5f); // 单行文字间隙高度 [间隙指的应该是“单行高度-单行文字高度”]
        mSelectorElementHeight = mTextSize + mSelectorTextGapHeight; // 得到单行的高度
        // Ensure that the middle item is positioned the same as the text in
        // mInputText
        int editTextTextPosition = mInputText.getBaseline() + mInputText.getTop(); // 得到 editText 的 baseline 距离 NumberPicker 的顶部距离。
        mInitialScrollOffset = editTextTextPosition
                - (mSelectorElementHeight * SELECTOR_MIDDLE_ITEM_INDEX);
        // 得到 去 et 的 baseline 需要执行的偏移量(表达的不好,这一句)
        mCurrentScrollOffset = mInitialScrollOffset;
        updateInputTextView(); // et.setText(); 刷新 et 的文字
    }

这个方法里面本质上是对成员变量mCurrentScrollOffsetmInitialScrollOffset进行赋值。把将要进行的位移值赋值过去。然后updateInputTextView()前面分析过了,就是跟输入框更新内容。

不过还有一个initializeSelectorWheelIndices()方法。

initializeSelectorWheelIndices()方法分析。
/**
     * Resets the selector indices and clear the cached string representation of
     * these indices.
假装翻译:设置将要显示的3行的 index,values的值。【是不是循环显示的,都考虑了】,
把要显示的3行的 index存到 mSelectorIndices 数组里面
把要显示的3行的 index,value 存到 mSelectorIndexToStringCache 里面
     */
    private void initializeSelectorWheelIndices() {
        mSelectorIndexToStringCache.clear();
        int[] selectorIndices = mSelectorIndices;
        int current = getValue();
        for (int i = 0; i < mSelectorIndices.length; i++) {
            int selectorIndex = current + (i - SELECTOR_MIDDLE_ITEM_INDEX);
            if (mWrapSelectorWheel) {
                selectorIndex = getWrappedSelectorIndex(selectorIndex);
            }
            selectorIndices[i] = selectorIndex;
            ensureCachedScrollSelectorValue(selectorIndices[i]);
        }
    }

里面的mSelectorIndices是什么,是一个数组,里面放置的将要显示出来的值的索引。比如一般情况下,你设置的值可能很多比如mMinValue=1,mMaxValue=100,mValue=5,则这个数组里面就是4 , 5 , 6 三个数字了。对应到界面,刚刚好。然后这个数组会被改变的,在你点击或者滑动的时候(后面分析会说)。

    /**
     * Ensures we have a cached string representation of the given <code>
     * selectorIndex</code> to avoid multiple instantiations of the same string.
     * 假装翻译:把要显示的selectorIndex 所在行的的 index,value 存储到 Map 里面。
     */
    private void ensureCachedScrollSelectorValue(int selectorIndex) {
        SparseArray<String> cache = mSelectorIndexToStringCache;
        String scrollSelectorValue = cache.get(selectorIndex);
        if (scrollSelectorValue != null) {
            return;
        }
        if (selectorIndex < mMinValue || selectorIndex > mMaxValue) {
            scrollSelectorValue = "";
        } else {
            if (mDisplayedValues != null) {
                int displayedValueIndex = selectorIndex - mMinValue;
                scrollSelectorValue = mDisplayedValues[displayedValueIndex];
            } else {
                scrollSelectorValue = formatNumber(selectorIndex);
            }
        }
        cache.put(selectorIndex, scrollSelectorValue);
    }

这里的逻辑就是:把index,value映射关联起来,并存到一个map里面。这里的index就是这3行对应的数字索引,value就是设置的要显示的字符串值。如果index > max或者 current < min, 则value="",也就是界面那一行将显示空字符串,不显示文本内容。

最后onLayout还给两个变量赋值了,一个mTopSelectionDividerTop,一个是mBottomSelectionDividerBottom。看一个
mTopSelectionDividerTop = (getHeight() - mSelectionDividersDistance) / 2 - mSelectionDividerHeight;

这里的mSelectionDividersDistance是什么,是输入框的上下分割线的距离,getHeight()肯定就是NumberPicker自己的高度了,mSelectionDividerHeight是分割线的高度。那这一计算,就得到了,NumberPicker顶部到上分割线顶部的距离。,这个距离也就是mTopSelectionDividerTop了。那,mBottomSelectionDividerBottom同理了,是NumberPicker底部到下分割线底部的距离。

到此为止,先做一个小结。

执行完了onLayout()到底做了哪些事情?

  1. 加载了布局文件
  2. 将布局文件中的EditText居中显示
  3. 显示EditText的内容(一个字符串)
  4. 准备了分割线的位置,准备了,输入框上下要显示的内容的索引,以及字符串值。

大致就是这些了,onLayout方法只做了这些事情。既然,构造方法,测量方法,布局方法都说了,接下来,就要看绘制方法了。

onDraw()方法分析


    @Override
    protected void onDraw(Canvas canvas) {
        if (!mHasSelectorWheel) {
            super.onDraw(canvas);
            return;
        }
        final boolean showSelectorWheel = mHideWheelUntilFocused ? hasFocus() : true;
        float x = (mRight - mLeft) / 2;
        float y = mCurrentScrollOffset;

        // draw the virtual buttons pressed state if needed
        if (showSelectorWheel && mVirtualButtonPressedDrawable != null
                && mScrollState == OnScrollListener.SCROLL_STATE_IDLE) {
            if (mDecrementVirtualButtonPressed) {
                mVirtualButtonPressedDrawable.setState(PRESSED_STATE_SET);
                mVirtualButtonPressedDrawable.setBounds(0, 0, mRight, mTopSelectionDividerTop);
                mVirtualButtonPressedDrawable.draw(canvas);
            }
            if (mIncrementVirtualButtonPressed) {
                mVirtualButtonPressedDrawable.setState(PRESSED_STATE_SET);
                mVirtualButtonPressedDrawable.setBounds(0, mBottomSelectionDividerBottom, mRight,
                        mBottom);
                mVirtualButtonPressedDrawable.draw(canvas);
            }
        }

        // draw the selector wheel
        int[] selectorIndices = mSelectorIndices;
        for (int i = 0; i < selectorIndices.length; i++) {
            int selectorIndex = selectorIndices[i];
            String scrollSelectorValue = mSelectorIndexToStringCache.get(selectorIndex);
            // Do not draw the middle item if input is visible since the input
            // is shown only if the wheel is static and it covers the middle
            // item. Otherwise, if the user starts editing the text via the
            // IME he may see a dimmed version of the old value intermixed
            // with the new one.
            if ((showSelectorWheel && i != SELECTOR_MIDDLE_ITEM_INDEX) ||
                (i == SELECTOR_MIDDLE_ITEM_INDEX && mInputText.getVisibility() != VISIBLE)) {
                canvas.drawText(scrollSelectorValue, x, y, mSelectorWheelPaint);
            }
            y += mSelectorElementHeight;
        }

        // draw the selection dividers
        if (showSelectorWheel && mSelectionDivider != null) {
            // draw the top divider
            int topOfTopDivider = mTopSelectionDividerTop;
            int bottomOfTopDivider = topOfTopDivider + mSelectionDividerHeight;
            mSelectionDivider.setBounds(0, topOfTopDivider, mRight, bottomOfTopDivider);
            mSelectionDivider.draw(canvas);

            // draw the bottom divider
            int bottomOfBottomDivider = mBottomSelectionDividerBottom;
            int topOfBottomDivider = bottomOfBottomDivider - mSelectionDividerHeight;
            mSelectionDivider.setBounds(0, topOfBottomDivider, mRight, bottomOfBottomDivider);
            mSelectionDivider.draw(canvas);
        }
    }

看起来有点多,实际上分3块来看:

  • 第一块,绘制背景 ; 也是整个NumberPicker的背景。但是它不是简单的设置一个背景图片,或者背景颜色的,是根据位置,是在输入框的上面还是下面,当前点击的位置,去做区别绘制的。
  • 第二块,绘制文本内容。也就是整个NumberPicker的内容(3行)。根据前面准备好的值,然后分行绘制出来。
  • 第三块,是绘制分割线。输入框的上下各一条。

好,现在就一块一块的看。

首先是第一块,背景的绘制:

        final boolean showSelectorWheel = mHideWheelUntilFocused ? hasFocus() : true;
        float x = (mRight - mLeft) / 2;
        float y = mCurrentScrollOffset;

        // draw the virtual buttons pressed state if needed
        if (showSelectorWheel && mVirtualButtonPressedDrawable != null
                && mScrollState == OnScrollListener.SCROLL_STATE_IDLE) {
            if (mDecrementVirtualButtonPressed) {
                mVirtualButtonPressedDrawable.setState(PRESSED_STATE_SET);
                mVirtualButtonPressedDrawable.setBounds(0, 0, mRight, mTopSelectionDividerTop);
                mVirtualButtonPressedDrawable.draw(canvas);
            }
            if (mIncrementVirtualButtonPressed) {
                mVirtualButtonPressedDrawable.setState(PRESSED_STATE_SET);
                mVirtualButtonPressedDrawable.setBounds(0, mBottomSelectionDividerBottom, mRight,
                        mBottom);
                mVirtualButtonPressedDrawable.draw(canvas);
            }
        }

这里有一个成员变量:mHideWheelUntilFocused ,这个值是来自自定义属性的,但是是多少呢?在android.jar里面的 res/values/styles_xx.xml里面没有看到对这个属性的赋值。而默认是false。所以可以大胆猜测,这个值就是false。然而,通过本地反射得到,这个值的确是false

那么,showSelectorWheel就永远是true了。所以,只要mVirtualButtonPressedDrawable存在,当前是非滑动状态,就会绘制背景。而virtualButtonPressedDrawableattrs_material.xmlattrs_holo.xml里面都赋值了。所以猜测这个存在。从显示效果也可以看到这个值的确存在。

另外,注意一点只有mDecrementVirtualButtonPressed== true 或者mIncrementVirtualButtonPressed == true的时候才去绘制背景。这个背景是系统默认的按下效果的背景。并且只绘制了指定的区域。要么是输入框的上面,要么是输入框的下面。并且默认是不绘制的,只要用户触摸点击了才会绘制。绘制区域不用说了,里面几个变量代表的位置,前面都说过了。(触摸反馈的分析在后面)

好了,第一块,背景绘制说完了,下面是文字的绘制。

// draw the selector wheel
int[] selectorIndices = mSelectorIndices;
for (int i = 0; i < selectorIndices.length; i++) {
    int selectorIndex = selectorIndices[i];
    String scrollSelectorValue = mSelectorIndexToStringCache.get(selectorIndex);
    // Do not draw the middle item if input is visible since the input
    // is shown only if the wheel is static and it covers the middle
    // item. Otherwise, if the user starts editing the text via the
    // IME he may see a dimmed version of the old value intermixed
    // with the new one.
    if ((showSelectorWheel && i != SELECTOR_MIDDLE_ITEM_INDEX) ||
            (i == SELECTOR_MIDDLE_ITEM_INDEX && mInputText.getVisibility() != VISIBLE)) {
        canvas.drawText(scrollSelectorValue, x, y, mSelectorWheelPaint);
    }
    y += mSelectorElementHeight;
}

这里就是把每行都绘制出来,如果输入框是可见的,输入框所在的一行就不绘制了(因为输入框存在,没必要重复绘制)。

再下面是绘制分割线了。

// draw the selection dividers
if (showSelectorWheel && mSelectionDivider != null) {
    // draw the top divider
    int topOfTopDivider = mTopSelectionDividerTop;
    int bottomOfTopDivider = topOfTopDivider + mSelectionDividerHeight;
    mSelectionDivider.setBounds(0, topOfTopDivider, mRight, bottomOfTopDivider);
    mSelectionDivider.draw(canvas);

    // draw the bottom divider
    int bottomOfBottomDivider = mBottomSelectionDividerBottom;
    int topOfBottomDivider = bottomOfBottomDivider - mSelectionDividerHeight;
    mSelectionDivider.setBounds(0, topOfBottomDivider, mRight, bottomOfBottomDivider);
    mSelectionDivider.draw(canvas);
}

这个也很简单了,位置,在onLayout()里面已经计算出来了,这里就是根据前面的计算结果,在指定位置绘制两条分割线。(分别是输入框的上面和下面)。

onDraw()完成之后,在用户触摸之前,都做了什么?

  1. 加载布局文件
  2. 将输入框居中显示
  3. 显示输入框的内容
  4. 绘制文本(输入框的上面的文本以及下面的文本)

这样,一个静态的界面就出来了。

然后是看触摸反馈的逻辑了。

onInterceptTouchEvent()方法分析

一般触摸反馈是放在onTouchEvent()里面,但是onInterceptTouchEvent()会在onTouchEvent()之前调用,所有,放这里面也可以。似乎更保险。


    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (!isEnabled() || !mHasSelectorWheel) {
            return false;
        }
        if (mVelocityTracker == null) {
            mVelocityTracker = VelocityTracker.obtain();
        }
        mVelocityTracker.addMovement(event);
        int action = event.getActionMasked();
        switch (action) {
            case MotionEvent.ACTION_MOVE: {
                if (mIgnoreMoveEvents) {
                    break;
                }
                float currentMoveY = event.getY();
                if (mScrollState != NumberPicker.OnScrollListener.SCROLL_STATE_TOUCH_SCROLL) {
                    int deltaDownY = (int) Math.abs(currentMoveY - mLastDownEventY);
                    if (deltaDownY > mTouchSlop) {
                        removeAllCallbacks();
                        onScrollStateChange(NumberPicker.OnScrollListener.SCROLL_STATE_TOUCH_SCROLL);
                    }
                } else {
                    int deltaMoveY = (int) ((currentMoveY - mLastDownOrMoveEventY));
                    scrollBy(0, deltaMoveY);
                    invalidate();
                }
                mLastDownOrMoveEventY = currentMoveY;
            }
            break;
            case MotionEvent.ACTION_UP: {
                removeBeginSoftInputCommand();
                removeChangeCurrentByOneFromLongPress();
                mPressedStateHelper.cancel();
                VelocityTracker velocityTracker = mVelocityTracker;
                velocityTracker.computeCurrentVelocity(1000, mMaximumFlingVelocity);
                int initialVelocity = (int) velocityTracker.getYVelocity();
                if (Math.abs(initialVelocity) > mMinimumFlingVelocity) {
                    fling(initialVelocity);
                    onScrollStateChange(NumberPicker.OnScrollListener.SCROLL_STATE_FLING);
                } else {
                    int eventY = (int) event.getY();
                    int deltaMoveY = (int) Math.abs(eventY - mLastDownEventY);
                    long deltaTime = event.getEventTime() - mLastDownEventTime;
                    if (deltaMoveY <= mTouchSlop && deltaTime < ViewConfiguration.getTapTimeout()) {
                        if (mPerformClickOnTap) {
                            mPerformClickOnTap = false;
                            performClick();
                        } else {
                            int selectorIndexOffset = (eventY / mSelectorElementHeight)
                                    - SELECTOR_MIDDLE_ITEM_INDEX;
                            if (selectorIndexOffset > 0) {
                                changeValueByOne(true);
                                mPressedStateHelper.buttonTapped(
                                        PressedStateHelper.BUTTON_INCREMENT);
                            } else if (selectorIndexOffset < 0) {
                                changeValueByOne(false);
                                mPressedStateHelper.buttonTapped(
                                        PressedStateHelper.BUTTON_DECREMENT);
                            }
                        }
                    } else {
                        ensureScrollWheelAdjusted();
                    }
                    onScrollStateChange(NumberPicker.OnScrollListener.SCROLL_STATE_IDLE);
                }
                mVelocityTracker.recycle();
                mVelocityTracker = null;
            }
            break;
        }
        return true;
    }

这个方法有点长了,不过注意,这里只对按下的动作做了处理,移动,抬起,这些动作,并没有去处理。这里面的逻辑也是分两块的。

  • 第一块:
// onInterceptTouchEvent 第一块逻辑
// Handle pressed state before any state change.
if (mLastDownEventY < mTopSelectionDividerTop) {
    if (mScrollState == NumberPicker.OnScrollListener.SCROLL_STATE_IDLE) {
        mPressedStateHelper.buttonPressDelayed(
                PressedStateHelper.BUTTON_DECREMENT);
    }
} else if (mLastDownEventY > mBottomSelectionDividerBottom) {
    if (mScrollState == NumberPicker.OnScrollListener.SCROLL_STATE_IDLE) {
        mPressedStateHelper.buttonPressDelayed(
                PressedStateHelper.BUTTON_INCREMENT);
    }
}

这里可以大致看到,如果当前不是滑动的状态,并且点击区域在输入框的上方或者输入框的下方,就去执行一个逻辑:mPressedStateHelper.buttonPressDelayed()。那就去看一下这个方法做了什么。

mPressedStateHelper.buttonPressDelayed()做了什么?

对应的逻辑是:

// PressedStateHelper#run()
case BUTTON_INCREMENT: {
    mIncrementVirtualButtonPressed = true;
    invalidate(0, mBottomSelectionDividerBottom, mRight, mBottom);
}
break;
case BUTTON_DECREMENT: {
    mDecrementVirtualButtonPressed = true;
    invalidate(0, 0, mRight, mTopSelectionDividerTop);
}

看到这里就明白了,就是把 mIncrementVirtualButtonPressed 或者 mDecrementVirtualButtonPressed设置为true ,然后刷新对应的区域。(回顾一下onDraw()的第一块逻辑:)

// onDraw()
if (mDecrementVirtualButtonPressed) {
    mVirtualButtonPressedDrawable.setState(PRESSED_STATE_SET);
    mVirtualButtonPressedDrawable.setBounds(0, 0, mRight, mTopSelectionDividerTop);
    mVirtualButtonPressedDrawable.draw(canvas);
}
if (mIncrementVirtualButtonPressed) {
    mVirtualButtonPressedDrawable.setState(PRESSED_STATE_SET);
    mVirtualButtonPressedDrawable.setBounds(0, mBottomSelectionDividerBottom, mRight,
            mBottom);
    mVirtualButtonPressedDrawable.draw(canvas);
}

也就是说,这里的处理逻辑就是如果点击了输入框的上方或者下方,就把对应的区域显示成按下的效果。(效果是系统默认的,不是自定义的)

好,onInterceptTouchEvent()第一块逻辑指定了,是用来改变绘制背景的按下效果的;然后是第二块逻辑:

// onInterceptTouchEvent 第二块逻辑
// Make sure we support flinging inside scrollables.
getParent().requestDisallowInterceptTouchEvent(true);
if (!mFlingScroller.isFinished()) {
    mFlingScroller.forceFinished(true);
    mAdjustScroller.forceFinished(true);
    onScrollStateChange(NumberPicker.OnScrollListener.SCROLL_STATE_IDLE);
} else if (!mAdjustScroller.isFinished()) {
    mFlingScroller.forceFinished(true);
    mAdjustScroller.forceFinished(true);
} else if (mLastDownEventY < mTopSelectionDividerTop) {
    postChangeCurrentByOneFromLongPress(
            false, ViewConfiguration.getLongPressTimeout());
} else if (mLastDownEventY > mBottomSelectionDividerBottom) {
    postChangeCurrentByOneFromLongPress(
            true, ViewConfiguration.getLongPressTimeout());
} else {
    mPerformClickOnTap = true;
    postBeginSoftInputOnLongPressCommand();
}

这部分逻辑,显示判断,如果当前滑动还没有停止,先强制停止滑动。然后还是根据按下的区域去分别执行一个postChangeCurrentByOneFromLongPress()的方法。那就去看一下这个方法。

postChangeCurrentByOneFromLongPress()做了什么
private void postChangeCurrentByOneFromLongPress(boolean increment, long delayMillis) {
    if (mChangeCurrentByOneFromLongPressCommand == null) {
        mChangeCurrentByOneFromLongPressCommand = new ChangeCurrentByOneFromLongPressCommand();
    } else {
        removeCallbacks(mChangeCurrentByOneFromLongPressCommand);
    }
    mChangeCurrentByOneFromLongPressCommand.setStep(increment);
    postDelayed(mChangeCurrentByOneFromLongPressCommand, delayMillis);
}

这个方法真的啥也没干,主要的逻辑肯定是在ChangeCurrentByOneFromLongPressCommand#run()方法里面。

// ChangeCurrentByOneFromLongPressCommand#run()
@Override
public void run() {
    changeValueByOne(mIncrement);
    postDelayed(this, mLongPressUpdateInterval);
}

这个 run方法简单明了,调用了一个 changeValueByOne(mIncrement);。那就看一下 这个方法

changeValueByOne(mIncrement);做了什么
private void changeValueByOne(boolean increment) {
    if (mHasSelectorWheel) {
        mInputText.setVisibility(View.INVISIBLE);
        if (!moveToFinalScrollerPosition(mFlingScroller)) {
            moveToFinalScrollerPosition(mAdjustScroller);
        }
        mPreviousScrollerY = 0;
        if (increment) {
            mFlingScroller.startScroll(0, 0, 0, -mSelectorElementHeight, SNAP_SCROLL_DURATION);
        } else {
            mFlingScroller.startScroll(0, 0, 0, mSelectorElementHeight, SNAP_SCROLL_DURATION);
        }
        invalidate();
    } else {
        if (increment) {
            setValueInternal(mValue + 1, true);
        } else {
            setValueInternal(mValue - 1, true);
        }
    }
}

这段代码逻辑稍微有一点长,但是前面我们已经知道,mHasSelectorWheel==true,所有只看 if(mHasSelectorWheel){}的逻辑。

首先,把输入框设置成不可见。
然后,如果当前没有滑动到上次滑动的终点,先滑动过去。
然后,就是根据点击区域是在输入框的上面还是下面,然后执行对应方法的滑动。比如:mFlingScroller.startScroll(0, 0, 0, -mSelectorElementHeight, SNAP_SCROLL_DURATION);

嗯,到这一步,好像onInterceptTouchEvent()就结束了。因为全部的代码都执行了。不过,Scroller.startScroll()这种方法会触发View.computeScroll()方法。那就看一下这个被触发的方法。

被触发的computeScroll()做了什么

    @Override
    public void computeScroll() {
        Scroller scroller = mFlingScroller;
        if (scroller.isFinished()) {
            scroller = mAdjustScroller;
            if (scroller.isFinished()) {
                return;
            }
        }
        scroller.computeScrollOffset();
        int currentScrollerY = scroller.getCurrY();
        if (mPreviousScrollerY == 0) {
            mPreviousScrollerY = scroller.getStartY();
        }
        scrollBy(0, currentScrollerY - mPreviousScrollerY);
        mPreviousScrollerY = currentScrollerY;
        if (scroller.isFinished()) {
            onScrollerFinished(scroller);
        } else {
            invalidate();
        }
    }

核心代码只有一句:scrollBy(0, currentScrollerY - mPreviousScrollerY);看到这里我当时懵逼了。根据NumberPicker的现实效果,无论是滚动,还是点击,显示的内容也是不断变化的。但是这里只是触发了滚动,那内容更新在哪呢?

找了很久才发现,NumberPicker重写了scrollBy()方法,然后更新内容,是在这里面执行的。

scrollBy()做了什么
@Override
public void scrollBy(int x, int y) {
   int[] selectorIndices = mSelectorIndices;
   if (!mWrapSelectorWheel && y > 0
           && selectorIndices[SELECTOR_MIDDLE_ITEM_INDEX] <= mMinValue) {
       mCurrentScrollOffset = mInitialScrollOffset;
       return;
   }
   if (!mWrapSelectorWheel && y < 0
           && selectorIndices[SELECTOR_MIDDLE_ITEM_INDEX] >= mMaxValue) {
       mCurrentScrollOffset = mInitialScrollOffset;
       return;
   }
   mCurrentScrollOffset += y;
   while (mCurrentScrollOffset - mInitialScrollOffset > mSelectorTextGapHeight) {
       mCurrentScrollOffset -= mSelectorElementHeight;
       decrementSelectorIndices(selectorIndices);
       setValueInternal(selectorIndices[SELECTOR_MIDDLE_ITEM_INDEX], true);
       if (!mWrapSelectorWheel && selectorIndices[SELECTOR_MIDDLE_ITEM_INDEX] <= mMinValue) {
           mCurrentScrollOffset = mInitialScrollOffset;
       }
   }
   while (mCurrentScrollOffset - mInitialScrollOffset < -mSelectorTextGapHeight) {
       mCurrentScrollOffset += mSelectorElementHeight;
       incrementSelectorIndices(selectorIndices);
       setValueInternal(selectorIndices[SELECTOR_MIDDLE_ITEM_INDEX], true);
       if (!mWrapSelectorWheel && selectorIndices[SELECTOR_MIDDLE_ITEM_INDEX] >= mMaxValue) {
           mCurrentScrollOffset = mInitialScrollOffset;
       }
   }
}

这里前面先是判断一下,如果不是循环显示,滑动到顶部或者底部,并且还像继续往这个方向滑动,就不让滑动了,直接return;结束这个方法。否则的话,也是分上滑还是下滑的。这里看一个。

mCurrentScrollOffset += y;
while (mCurrentScrollOffset - mInitialScrollOffset > mSelectorTextGapHeight) {
    mCurrentScrollOffset -= mSelectorElementHeight;
    decrementSelectorIndices(selectorIndices);
    setValueInternal(selectorIndices[SELECTOR_MIDDLE_ITEM_INDEX], true);
    if (!mWrapSelectorWheel && selectorIndices[SELECTOR_MIDDLE_ITEM_INDEX] <= mMinValue) {
        mCurrentScrollOffset = mInitialScrollOffset;
    }
}

这个是下滑。当前要滑动的目标位置-初始位置,要大于一行的高度。也就是整体内容预期是向下滚动。然后执行了decrementSelectorIndices()这个方法。

decrementSelectorIndices()做了什么

 /**
  * Decrements the <code>selectorIndices</code> whose string representations
  * will be displayed in the selector.
  */
 private void decrementSelectorIndices(int[] selectorIndices) {
     for (int i = selectorIndices.length - 1; i > 0; i--) {
         selectorIndices[i] = selectorIndices[i - 1];
     }
     int nextScrollSelectorIndex = selectorIndices[1] - 1;
     if (mWrapSelectorWheel && nextScrollSelectorIndex < mMinValue) {
         nextScrollSelectorIndex = mMaxValue;
     }
     selectorIndices[0] = nextScrollSelectorIndex;
     ensureCachedScrollSelectorValue(nextScrollSelectorIndex);
 }

这个逻辑就简单了,就是
先对数组里面的每个值减一。

比如现在selectorIndices里面的值是,[5,6,7] ,那么就变成[4,5,6](每个值-1)。

然后,是判断,如果是循环显示,那数组里面的最小值可能小于mMinValue,那就把这个最小值设置成mMaxValue。这样也就是做到循环显示内容了。
然后,调用了ensureCachedScrollSelectorValue(pos)方法,这个方法前面说过了。

是 把要显示的selectorIndex 所在行的的 index,value 存储到 Map 里面。

不过注意,这个方法,只改变传入的索引对于的行的内容,而不是全部。也就是说decrementSelectorIndices()对显示区域的文本内容的改变是不彻底的,只改变了一行。而不是全部行。【不过,对索引数组的改变是彻底的,每个索引都-1了(如果是循环显示,数组最小值可能变成最大值)】

然后再 decrementSelectorIndices()之后,又执行了一个方法:setValueInternal()

setValueInternal()做了什么

    private void setValueInternal(int current, boolean notifyChange) {
        if (mValue == current) {
            return;
        }
        // Wrap around the values if we go past the start or end
        if (mWrapSelectorWheel) {
            current = getWrappedSelectorIndex(current);
        } else {
            current = Math.max(current, mMinValue);
            current = Math.min(current, mMaxValue);
        }
        int previous = mValue;
        mValue = current;
        // If we're flinging, we'll update the text view at the end when it becomes visible
        if (mScrollState != NumberPicker.OnScrollListener.SCROLL_STATE_FLING) {
            updateInputTextView();
        }
        if (notifyChange) {
            notifyChange(previous, current);
        }
        initializeSelectorWheelIndices();
        invalidate();
    }

这个方法就厉害了,前面的都不重要,判断一下要不要更新,是不是内容循环显示的。最后是调用了initializeSelectorWheelIndices();这个方法。这个方法前面分析了。

假装翻译:设置将要显示的3行的 index,values的值。【是不是循环显示的,都考虑了】,
把要显示的3行的 index存到 mSelectorIndices 数组里面
把要显示的3行的 index,value 存到 mSelectorIndexToStringCache 里面。

是的,然后是大招invalidate(); ,这些内容都设置好了,然后重新绘制一下。

到此为止,onInterceptTouchEvent()里面的交互调用的方法基本算是讲完了。

onInterceptTouchEvent()执行完成后,到底做了什么?

用户手指按下,并且没有滑动的情况下 (滑动的情况不是在这里处理的)

  1. 更新被点击的区域(只有是输入框的上方或者下方)的状态变成按下的状态
  2. 更新内容,假设界面上面本来显示的是5 , 6 , 7 。那么,点击上方之后,变成4 , 5 ,6。【实际上可以是任何字符串,不一定是这种数字型字符串】

大致的调用栈:好像不好表示的,算了,不表示了。相信这个分析看了就知道了。

然后就是 onTouchEvent()了。

onTouchEvent()代码分析

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (!isEnabled() || !mHasSelectorWheel) {
            return false;
        }
        if (mVelocityTracker == null) {
            mVelocityTracker = VelocityTracker.obtain();
        }
        mVelocityTracker.addMovement(event);
        int action = event.getActionMasked();
        switch (action) {
            case MotionEvent.ACTION_MOVE: {
                if (mIgnoreMoveEvents) {
                    break;
                }
                float currentMoveY = event.getY();
                if (mScrollState != OnScrollListener.SCROLL_STATE_TOUCH_SCROLL) {
                    int deltaDownY = (int) Math.abs(currentMoveY - mLastDownEventY);
                    if (deltaDownY > mTouchSlop) {
                        removeAllCallbacks();
                        onScrollStateChange(OnScrollListener.SCROLL_STATE_TOUCH_SCROLL);
                    }
                } else {
                    int deltaMoveY = (int) ((currentMoveY - mLastDownOrMoveEventY));
                    scrollBy(0, deltaMoveY);
                    invalidate();
                }
                mLastDownOrMoveEventY = currentMoveY;
            } break;
            case MotionEvent.ACTION_UP: {
                removeBeginSoftInputCommand();
                removeChangeCurrentByOneFromLongPress();
                mPressedStateHelper.cancel();
                VelocityTracker velocityTracker = mVelocityTracker;
                velocityTracker.computeCurrentVelocity(1000, mMaximumFlingVelocity);
                int initialVelocity = (int) velocityTracker.getYVelocity();
                if (Math.abs(initialVelocity) > mMinimumFlingVelocity) {
                    fling(initialVelocity);
                    onScrollStateChange(OnScrollListener.SCROLL_STATE_FLING);
                } else {
                    int eventY = (int) event.getY();
                    int deltaMoveY = (int) Math.abs(eventY - mLastDownEventY);
                    long deltaTime = event.getEventTime() - mLastDownEventTime;
                    if (deltaMoveY <= mTouchSlop && deltaTime < ViewConfiguration.getTapTimeout()) {
                        if (mPerformClickOnTap) {
                            mPerformClickOnTap = false;
                            performClick();
                        } else {
                            int selectorIndexOffset = (eventY / mSelectorElementHeight)
                                    - SELECTOR_MIDDLE_ITEM_INDEX;
                            if (selectorIndexOffset > 0) {
                                changeValueByOne(true);
                                mPressedStateHelper.buttonTapped(
                                        PressedStateHelper.BUTTON_INCREMENT);
                            } else if (selectorIndexOffset < 0) {
                                changeValueByOne(false);
                                mPressedStateHelper.buttonTapped(
                                        PressedStateHelper.BUTTON_DECREMENT);
                            }
                        }
                    } else {
                        ensureScrollWheelAdjusted();
                    }
                    onScrollStateChange(OnScrollListener.SCROLL_STATE_IDLE);
                }
                mVelocityTracker.recycle();
                mVelocityTracker = null;
            } break;
        }
        return true;
    }

这个代码比较长,但是也是分成两块的,第一块是,当前是手指滑动的状态,第二块是,手指抬起之后的状态。

onTouchEvent #ACTION_MOVE

这里是先判断,如果当前不是滑动的状态,先设置成滑动的状态;如果当前已经是滑动的状态,就更新文字的内容。这里的更新文字内容的方法,前面已经分析过了,就不重复说。(滑动和点击不一样,点击的话,会去更新点击区域的背景颜色,而滑动,值更新文字内容,不关心背景了。)

ACTION_MOVE就这些了。

`onTouchEvent #ACTION_UP时`

先是判断当前的速度满不满足fling(initialVelocity);的条件,满足的话就去fling(initialVelocity);,而fling(initialVelocity);里面做了什么?

    private void fling(int velocityY) {
        mPreviousScrollerY = 0;

        if (velocityY > 0) {
            mFlingScroller.fling(0, 0, 0, velocityY, 0, 0, 0, Integer.MAX_VALUE);
        } else {
            mFlingScroller.fling(0, Integer.MAX_VALUE, 0, velocityY, 0, 0, 0, Integer.MAX_VALUE);
        }

        invalidate();
    }

看到这里就知道了,fling()肯定也会触发computeScroll()。触发computeScroll()的效果前面分析了,就是会去更新显示的文字内容并显示出来。

那,如果不是要滑动呢?不是去滑动的话,又分两种情况,第一种,点击区域正好是输入框的区域;第二种,点击区域不是输入框的区域。

第一种情况下就很好处理,就是弹出软键盘,让用户输入。

    @Override
    public boolean performClick() {
        if (!mHasSelectorWheel) {
            return super.performClick();
        } else if (!super.performClick()) {
            showSoftInput();
        }
        return true;
    }

第二种情况就是,点击的地方要么是输入框上面,要么是输入框的下面。这时候做的事情和ACTION_DOWN一样了【代码逻辑差不多】,就是先改变被点击区域的背景效果,然后是改变文字显示效果。

// onTouchEvent#ACTION_UP 部分代码
int selectorIndexOffset = (eventY / mSelectorElementHeight)
                                    - SELECTOR_MIDDLE_ITEM_INDEX;
if (selectorIndexOffset > 0) {
    changeValueByOne(true);
    mPressedStateHelper.buttonTapped(
            PressedStateHelper.BUTTON_INCREMENT);
} else if (selectorIndexOffset < 0) {
    changeValueByOne(false);
    mPressedStateHelper.buttonTapped(
            PressedStateHelper.BUTTON_DECREMENT);
}

到这里,算是分析完成了NumberPicker的大部分逻辑。

当然,还是有一些没有分析到的,比如点击弹出输入框之后,用户输入之后的效果。
然后还有Accessibility相关的逻辑。
也许还有其他的没有分析,是我没有发现的。

以上就是整个分析了。应该不算辜负“全面解析”这四个大字。


幕后花絮:

最后,感谢黄猫[一个不愿透露姓名与联系方式的前同事] 指导分析。以及黄猫改造的一个NumberPickerX

NumberPickerX是什么?
强行解释一下:是黄猫基于原生的NumberPicker代码,去掉所有Accessibility相关代码,以及所有非系统不能调用的代码,而形成的一个本地可以运行的,效果类似原生的NumberPicker的山寨版本:NumberPickerX。【嗯,相信黄猫对我的解释会比较满意~】

对于代码分析过程中,有时候找不到调用的地方,我选择在NumberPickerX里面利用LogUtils.e(new Throwable()); 来找到调用栈。之前 scrollBy就是通过这种方式找到的。


在分析了 NumberPicker的源码之后,我也山寨了一个NumberPicker,可以支持多行,可以修改分割线颜色,点击效果,不弹出输入框。

先说一下山寨的方式:就是把NumberPicker的代码全部拷贝出来,然后删除用不到的部分,比如辅助服务相关的。再把里面的变量该赋值的赋值。
然后是,修改一下一些想要自定义的部分,比如行数,分割线颜色,点击效果等等。
另外,除了复制出来java代码之外,对应的attr,layout也要复制出来。
然后是调用的时候,也要注意一下,这些自定义属性往往需要赋值的。尤其是:app:internalLayout="@layout/number_picker_material"这个必须赋值,否则根本没有任何效果。系统的也复制了,只不过不是让我们赋值,可能在主题的Styles里面赋值的。

源码大赏

 类似资料: