android NumberPicker 全面解析
NumberPick
全面解析
-
- NumberPick全面解析
- 构造方法解析
-
- 第一 :setWillNotDraw(!mHasSelectorWheel);
- 第二:看mHasSelectorWheel赋值的地方:
- 然后再整体看一下这个构造方法的全部逻辑:
- updateInputTextView()分析
-
- onMeasure()方法分析
- onLayout()方法分析
-
- 首先,让mInputText居中显示了
- 然后是执行一些初始化操作
- initializeSelectorWheel()的分析
- initializeSelectorWheelIndices()方法分析。
- 执行完了onLayout()到底做了哪些事情?
-
- onDraw()方法分析
- onDraw()完成之后,在用户触摸之前,都做了什么?
- onInterceptTouchEvent()方法分析
-
- mPressedStateHelper.buttonPressDelayed()做了什么?
- postChangeCurrentByOneFromLongPress()做了什么
- changeValueByOne(mIncrement);做了什么
- 被触发的computeScroll()做了什么
- scrollBy()做了什么
- decrementSelectorIndices()做了什么
- setValueInternal()做了什么
- onInterceptTouchEvent()执行完成后,到底做了什么?
-
- onTouchEvent()代码分析
- onTouchEvent #ACTION_MOVE时
- `onTouchEvent #ACTION_UP时`
- 源码大赏
- 构造方法解析
- 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()
方法分析
@Overrideprotected 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 = 55final 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 * selectorIndex
to avoid multiple instantiations of the same string. * 假装翻译:把要显示的selectorIndex 所在行的的 index,value 存储到 Map 里面。 */ private void ensureCachedScrollSelectorValue(int selectorIndex) { SparseArray 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 wheelint[] 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 dividersif (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()@Overridepublic 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()
做了什么
@Overridepublic 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 selectorIndices
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
里面赋值的。
源码大赏
- 实例项目
- NumberPickerD 。
D
是demo
的意思。~
更多相关文章
- Android核心分析 ---- 电话系统之GSMCallTacker
- scaleType / ScaleType
- Android(安卓)倒计时实现,显示剩余时分秒
- android打开系统相册的一些方法
- android studio 中报Error:Execution failed for task ':app:mer
- Android(安卓)4.1 Netd详细分析(三)代码分析1
- Android数据库中查找一条数据使用的方法 query详解
- 某android平板项目开发笔记----aChartEngine图表显示(2)
- Android(安卓)App 退出整个应用