NumberPick
全面解析一个自定义控件的逻辑有哪些?或者说一个自定义控件的行为逻辑都是由哪些方法来控制的。
一般而言,首先是构造方法,然后是onMeasure(),然后是onLayout ,然后是onDraw() 。但是以上这些是静态的行为方法,不涉及与用户的交互。
与用户交互的方法有哪些?首先是dispatchTouchEvent(),然后是onInterceptTouchEvent() ,然后是onTouchEvent() 。
除了这些交互之外,调用者还可以写view.setOnTouchListener()
来重写交互方法。这一步就不管了。
好,那既然是全面解析,就是从静态行为方法 + 与用户交互的方法 来一个完全的解析。
分析之前先简单说明一下背景:
NumberPicker
继承自LinearLayout
,也就是说,这是一个ViewGroup
。一般来说,ViewGroup
不会去重写onDraw()
, 都是子View
去onDraw()
。ViewGroup
负责测量和布局就好了。从这个角度来说,NumberPicker
是一个特殊的ViewGroup
,因为它里面的很多显示逻辑是在自己的onDraw()
里面完成的。
从代码上面可以看到,NumberPicker
重写了全部的构造方法,但是实际上执行逻辑在4个参数的构造方法里面。也就是android.widget.NumberPicker#NumberPicker(android.content.Context, android.util.AttributeSet, int, int)
由于这个构造方法里面代码比较多,如果全部贴上来,会影响阅读。所以这里不贴上来了。
看几个关键的地方:
setWillNotDraw(!mHasSelectorWheel);
这句代码意义很大。它决定了,最终NumberPicker
的onDraw()
方法会不会被执行。但是想一想,如果这个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
,并且,当前使用的布局并不是默认布局。(实际布局应该跟Activity
的Theme
有关) 记住这个变量mHasSelectorWheel
,后面会多次使用到。接下来,就是获取自定义属性给成员变量。注意三个变量:
mHideWheelUntilFocused
,mSolidColor
,mSelectionDivider
,mVirtualButtonPressedDrawable
这几个变量后面都会用到。然后,在自定义属性获取完成之后注意一段代码:
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
的输入。这个交互不友好。即使不禁止,这些监听器,暂时先不看,里面的方法后面都会有分析。在设置了这些监听器之后,然后又初始化了一些变量,这里注意几个变量:
mSelectorWheelPaint
,mFlingScroller
,mAdjustScroller
。然后进行了界面初始值的设定:
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
的调用,但是可以大致看出来,它的意思就是测量之后,设置的宽是在mMinWidth
,mMaxWidth
之间,高是在mMinHeight
,mMaxHeight
之间。嗯,这个就这样子了。。。
然后是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 的文字
}
这个方法里面本质上是对成员变量mCurrentScrollOffset
,mInitialScrollOffset
进行赋值。把将要进行的位移值赋值过去。然后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()
到底做了哪些事情?EditText
居中显示EditText
的内容(一个字符串)大致就是这些了,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
存在,当前是非滑动状态,就会绘制背景。而virtualButtonPressedDrawable
在attrs_material.xml
与attrs_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()
完成之后,在用户触摸之前,都做了什么?这样,一个静态的界面就出来了。
然后是看触摸反馈的逻辑了。
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()
执行完成后,到底做了什么?用户手指按下,并且没有滑动的情况下 (滑动的情况不是在这里处理的)
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
里面赋值的。
D
是demo
的意思。~