diff options
author | Svetoslav Ganov <svetoslavganov@google.com> | 2010-11-19 00:04:05 -0800 |
---|---|---|
committer | Svetoslav Ganov <svetoslavganov@google.com> | 2010-11-29 15:47:55 -0800 |
commit | 206316a61f904ea0a6b106137dd7715a2c246d4c (patch) | |
tree | a44aee5e797435b94cc090ad0fce270550e8f4bc /core/java/android/widget/NumberPicker.java | |
parent | 360a102f41098d36dccc4fe245e192c6e2f0ceeb (diff) | |
download | frameworks_base-206316a61f904ea0a6b106137dd7715a2c246d4c.zip frameworks_base-206316a61f904ea0a6b106137dd7715a2c246d4c.tar.gz frameworks_base-206316a61f904ea0a6b106137dd7715a2c246d4c.tar.bz2 |
New Number picker widget
Change-Id: I834e725b58682e7a48cc3f3302c93c57b35d4e27
Diffstat (limited to 'core/java/android/widget/NumberPicker.java')
-rw-r--r-- | core/java/android/widget/NumberPicker.java | 1380 |
1 files changed, 1114 insertions, 266 deletions
diff --git a/core/java/android/widget/NumberPicker.java b/core/java/android/widget/NumberPicker.java index 4482b5b..0c298b0 100644 --- a/core/java/android/widget/NumberPicker.java +++ b/core/java/android/widget/NumberPicker.java @@ -18,80 +18,134 @@ package android.widget; import com.android.internal.R; +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.animation.ValueAnimator; import android.annotation.Widget; import android.content.Context; -import android.os.Handler; +import android.content.res.ColorStateList; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.Paint.Align; import android.text.InputFilter; import android.text.InputType; import android.text.Spanned; +import android.text.TextUtils; import android.text.method.NumberKeyListener; import android.util.AttributeSet; +import android.util.SparseArray; +import android.view.KeyEvent; import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.VelocityTracker; import android.view.View; +import android.view.ViewConfiguration; +import android.view.LayoutInflater.Filter; +import android.view.animation.OvershootInterpolator; +import android.view.inputmethod.InputMethodManager; /** - * A view for selecting a number + * A view for selecting a number For a dialog using this view, see + * {@link android.app.TimePickerDialog}. * - * For a dialog using this view, see {@link android.app.TimePickerDialog}. * @hide */ @Widget public class NumberPicker extends LinearLayout { /** - * The callback interface used to indicate the number value has been adjusted. + * The index of the middle selector item. */ - public interface OnChangedListener { - /** - * @param picker The NumberPicker associated with this listener. - * @param oldVal The previous value. - * @param newVal The new value. - */ - void onChanged(NumberPicker picker, int oldVal, int newVal); - } + private static final int SELECTOR_MIDDLE_ITEM_INDEX = 2; /** - * Interface used to format the number into a string for presentation + * The coefficient by which to adjust (divide) the max fling velocity. */ - public interface Formatter { - String toString(int value); - } + private static final int SELECTOR_MAX_FLING_VELOCITY_ADJUSTMENT = 8; - /* - * Use a custom NumberPicker formatting callback to use two-digit - * minutes strings like "01". Keeping a static formatter etc. is the - * most efficient way to do this; it avoids creating temporary objects - * on every call to format(). - */ - public static final NumberPicker.Formatter TWO_DIGIT_FORMATTER = - new NumberPicker.Formatter() { - final StringBuilder mBuilder = new StringBuilder(); - final java.util.Formatter mFmt = new java.util.Formatter(mBuilder); - final Object[] mArgs = new Object[1]; - public String toString(int value) { - mArgs[0] = value; - mBuilder.delete(0, mBuilder.length()); - mFmt.format("%02d", mArgs); - return mFmt.toString(); - } - }; + /** + * The the duration for adjusting the selector wheel. + */ + private static final int SELECTOR_ADJUSTMENT_DURATION_MILLIS = 800; - private final Handler mHandler; - private final Runnable mRunnable = new Runnable() { - public void run() { - if (mIncrement) { - changeCurrent(mCurrent + 1); - mHandler.postDelayed(this, mSpeed); - } else if (mDecrement) { - changeCurrent(mCurrent - 1); - mHandler.postDelayed(this, mSpeed); - } + /** + * The the delay for showing the input controls after a single tap on the + * input text. + */ + private static final int SHOW_INPUT_CONTROLS_DELAY_MILLIS = ViewConfiguration + .getDoubleTapTimeout(); + + /** + * The update step for incrementing the current value. + */ + private static final int UPDATE_STEP_INCREMENT = 1; + + /** + * The update step for decrementing the current value. + */ + private static final int UPDATE_STEP_DECREMENT = -1; + + /** + * The strength of fading in the top and bottom while drawing the selector. + */ + private static final float TOP_AND_BOTTOM_FADING_EDGE_STRENGTH = 0.9f; + + /** + * The numbers accepted by the input text's {@link Filter} + */ + private static final char[] DIGIT_CHARACTERS = new char[] { + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' + }; + + /** + * Use a custom NumberPicker formatting callback to use two-digit minutes + * strings like "01". Keeping a static formatter etc. is the most efficient + * way to do this; it avoids creating temporary objects on every call to + * format(). + */ + public static final NumberPicker.Formatter TWO_DIGIT_FORMATTER = new NumberPicker.Formatter() { + final StringBuilder mBuilder = new StringBuilder(); + + final java.util.Formatter mFmt = new java.util.Formatter(mBuilder); + + final Object[] mArgs = new Object[1]; + + public String toString(int value) { + mArgs[0] = value; + mBuilder.delete(0, mBuilder.length()); + mFmt.format("%02d", mArgs); + return mFmt.toString(); } }; - private final EditText mText; - private final InputFilter mNumberInputFilter; + /** + * The increment button. + */ + private final ImageButton mIncrementButton; + + /** + * The decrement button. + */ + private final ImageButton mDecrementButton; + + /** + * The text for showing the current value. + */ + private final EditText mInputText; + + /** + * The height of the text. + */ + private final int mTextSize; + /** + * The values to be displayed instead the indices. + */ private String[] mDisplayedValues; /** @@ -110,104 +164,466 @@ public class NumberPicker extends LinearLayout { private int mCurrent; /** - * Previous value of this NumberPicker. + * Listener to be notified upon current value change. */ - private int mPrevious; private OnChangedListener mListener; + + /** + * Formatter for for displaying the current value. + */ private Formatter mFormatter; - private long mSpeed = 300; - private boolean mIncrement; - private boolean mDecrement; + /** + * The speed for updating the value form long press. + */ + private long mLongPressUpdateSpeed = 300; /** - * Create a new number picker - * @param context the application environment + * Cache for the string representation of selector indices. + */ + private final SparseArray<String> mSelectorIndexToStringCache = new SparseArray<String>(); + + /** + * The selector indices whose value are show by the selector. + */ + private final int[] mSelectorIndices = new int[] { + Integer.MIN_VALUE, Integer.MIN_VALUE, Integer.MIN_VALUE, Integer.MIN_VALUE, + Integer.MIN_VALUE + }; + + /** + * The {@link Paint} for drawing the selector. + */ + private final Paint mSelectorPaint; + + /** + * The height of a selector element (text + gap). + */ + private int mSelectorElementHeight; + + /** + * The initial offset of the scroll selector. + */ + private int mInitialScrollOffset = Integer.MIN_VALUE; + + /** + * The current offset of the scroll selector. + */ + private int mCurrentScrollOffset; + + /** + * The {@link Scroller} responsible for flinging the selector. + */ + private final Scroller mFlingScroller; + + /** + * The {@link Scroller} responsible for adjusting the selector. + */ + private final Scroller mAdjustScroller; + + /** + * The previous Y coordinate while scrolling the selector. + */ + private int mPreviousScrollerY; + + /** + * Handle to the reusable command for setting the input text selection. + */ + private SetSelectionCommand mSetSelectionCommand; + + /** + * Handle to the reusable command for adjusting the scroller. + */ + private AdjustScrollerCommand mAdjustScrollerCommand; + + /** + * Handle to the reusable command for updating the current value from long + * press. + */ + private UpdateValueFromLongPressCommand mUpdateFromLongPressCommand; + + /** + * {@link Animator} for showing the up/down arrows. + */ + private final AnimatorSet mShowInputControlsAnimator; + + /** + * The Y position of the last down event. + */ + private float mLastDownEventY; + + /** + * The Y position of the last motion event. + */ + private float mLastMotionEventY; + + /** + * Flag if to begin edit on next up event. + */ + private boolean mBeginEditOnUpEvent; + + /** + * Flag if to adjust the selector wheel on next up event. + */ + private boolean mAdjustScrollerOnUpEvent; + + /** + * Flag if to draw the selector wheel. + */ + private boolean mDrawSelectorWheel; + + /** + * Determines speed during touch scrolling. + */ + private VelocityTracker mVelocityTracker; + + /** + * @see ViewConfiguration#getScaledTouchSlop() + */ + private int mTouchSlop; + + /** + * @see ViewConfiguration#getScaledMinimumFlingVelocity() + */ + private int mMinimumFlingVelocity; + + /** + * @see ViewConfiguration#getScaledMaximumFlingVelocity() + */ + private int mMaximumFlingVelocity; + + /** + * Flag whether the selector should wrap around. + */ + private boolean mWrapSelector; + + /** + * The back ground color used to optimize scroller fading. */ - public NumberPicker(Context context) { - this(context, null); + private final int mSolidColor; + + /** + * Reusable {@link Rect} instance. + */ + private final Rect mTempRect = new Rect(); + + /** + * The callback interface used to indicate the number value has changed. + */ + public interface OnChangedListener { + /** + * @param picker The NumberPicker associated with this listener. + * @param oldVal The previous value. + * @param newVal The new value. + */ + void onChanged(NumberPicker picker, int oldVal, int newVal); + } + + /** + * Interface used to format the number into a string for presentation + */ + public interface Formatter { + String toString(int value); } /** * Create a new number picker - * @param context the application environment - * @param attrs a collection of attributes + * + * @param context The application environment. + * @param attrs A collection of attributes. */ public NumberPicker(Context context, AttributeSet attrs) { - super(context, attrs); - setOrientation(VERTICAL); - LayoutInflater inflater = - (LayoutInflater) mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + this(context, attrs, R.attr.numberPickerStyle); + } + + /** + * Create a new number picker + * + * @param context the application environment. + * @param attrs a collection of attributes. + * @param defStyle The default style to apply to this view. + */ + public NumberPicker(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + + // process style attributes + TypedArray attributesArray = context.obtainStyledAttributes(attrs, + R.styleable.NumberPicker, defStyle, 0); + int orientation = attributesArray.getInt(R.styleable.NumberPicker_orientation, VERTICAL); + setOrientation(orientation); + mSolidColor = attributesArray.getColor(R.styleable.NumberPicker_solidColor, 0); + attributesArray.recycle(); + + // By default Linearlayout that we extend is not drawn. This is + // its draw() method is not called but dispatchDraw() is called + // directly (see ViewGroup.drawChild()). However, this class uses + // the fading edge effect implemented by View and we need our + // draw() method to be called. Therefore, we declare we will draw. + setWillNotDraw(false); + setDrawSelectorWheel(false); + + LayoutInflater inflater = (LayoutInflater) getContext().getSystemService( + Context.LAYOUT_INFLATER_SERVICE); inflater.inflate(R.layout.number_picker, this, true); - mHandler = new Handler(); - OnClickListener clickListener = new OnClickListener() { + OnClickListener onClickListener = new OnClickListener() { public void onClick(View v) { - validateInput(mText); - if (!mText.hasFocus()) mText.requestFocus(); - - // now perform the increment/decrement - if (R.id.increment == v.getId()) { + mInputText.clearFocus(); + if (v.getId() == R.id.increment) { changeCurrent(mCurrent + 1); - } else if (R.id.decrement == v.getId()) { + } else { changeCurrent(mCurrent - 1); } } }; - OnFocusChangeListener focusListener = new OnFocusChangeListener() { - public void onFocusChange(View v, boolean hasFocus) { + OnLongClickListener onLongClickListener = new OnLongClickListener() { + public boolean onLongClick(View v) { + mInputText.clearFocus(); + if (v.getId() == R.id.increment) { + postUpdateValueFromLongPress(UPDATE_STEP_INCREMENT); + } else { + postUpdateValueFromLongPress(UPDATE_STEP_DECREMENT); + } + return true; + } + }; - /* When focus is lost check that the text field - * has valid values. - */ + // increment button + mIncrementButton = (ImageButton) findViewById(R.id.increment); + mIncrementButton.setOnClickListener(onClickListener); + mIncrementButton.setOnLongClickListener(onLongClickListener); + + // decrement button + mDecrementButton = (ImageButton) findViewById(R.id.decrement); + mDecrementButton.setOnClickListener(onClickListener); + mDecrementButton.setOnLongClickListener(onLongClickListener); + + // input text + mInputText = (EditText) findViewById(R.id.timepicker_input); + mInputText.setOnFocusChangeListener(new OnFocusChangeListener() { + public void onFocusChange(View v, boolean hasFocus) { if (!hasFocus) { - validateInput(v); + validateInputTextView(v); } } - }; + }); + mInputText.setFilters(new InputFilter[] { + new InputTextFilter() + }); - OnLongClickListener longClickListener = new OnLongClickListener() { - /** - * We start the long click here but rely on the {@link NumberPickerButton} - * to inform us when the long click has ended. - */ - public boolean onLongClick(View v) { - /* The text view may still have focus so clear it's focus which will - * trigger the on focus changed and any typed values to be pulled. - */ - mText.clearFocus(); - - if (R.id.increment == v.getId()) { - mIncrement = true; - mHandler.post(mRunnable); - } else if (R.id.decrement == v.getId()) { - mDecrement = true; - mHandler.post(mRunnable); + mInputText.setRawInputType(InputType.TYPE_CLASS_NUMBER); + + // initialize constants + mTouchSlop = ViewConfiguration.getTapTimeout(); + ViewConfiguration configuration = ViewConfiguration.get(context); + mTouchSlop = configuration.getScaledTouchSlop(); + mMinimumFlingVelocity = configuration.getScaledMinimumFlingVelocity(); + mMaximumFlingVelocity = configuration.getScaledMaximumFlingVelocity() + / SELECTOR_MAX_FLING_VELOCITY_ADJUSTMENT; + mTextSize = (int) mInputText.getTextSize(); + + // create the selector wheel paint + Paint paint = new Paint(); + paint.setAntiAlias(true); + paint.setTextAlign(Align.CENTER); + paint.setTextSize(mTextSize); + paint.setTypeface(mInputText.getTypeface()); + ColorStateList colors = mInputText.getTextColors(); + int color = colors.getColorForState(ENABLED_STATE_SET, Color.WHITE); + paint.setColor(color); + mSelectorPaint = paint; + + // create the animator for showing the input controls + final ValueAnimator fadeScroller = ObjectAnimator.ofInt(this, "selectorPaintAlpha", 255, 0); + final ObjectAnimator showIncrementButton = ObjectAnimator.ofFloat(mIncrementButton, + "alpha", 0, 1); + final ObjectAnimator showDecrementButton = ObjectAnimator.ofFloat(mDecrementButton, + "alpha", 0, 1); + mShowInputControlsAnimator = new AnimatorSet(); + mShowInputControlsAnimator.playTogether(fadeScroller, showIncrementButton, + showDecrementButton); + mShowInputControlsAnimator.setDuration(getResources().getInteger( + R.integer.config_longAnimTime)); + mShowInputControlsAnimator.addListener(new AnimatorListenerAdapter() { + private boolean mCanceled = false; + + @Override + public void onAnimationEnd(Animator animation) { + if (!mCanceled) { + // if canceled => we still want the wheel drawn + setDrawSelectorWheel(false); } - return true; + mCanceled = false; + mSelectorPaint.setAlpha(255); + invalidate(); } - }; - InputFilter inputFilter = new NumberPickerInputFilter(); - mNumberInputFilter = new NumberRangeKeyListener(); - mIncrementButton = (NumberPickerButton) findViewById(R.id.increment); - mIncrementButton.setOnClickListener(clickListener); - mIncrementButton.setOnLongClickListener(longClickListener); - mIncrementButton.setNumberPicker(this); + @Override + public void onAnimationCancel(Animator animation) { + if (mShowInputControlsAnimator.isRunning()) { + mCanceled = true; + } + } + }); - mDecrementButton = (NumberPickerButton) findViewById(R.id.decrement); - mDecrementButton.setOnClickListener(clickListener); - mDecrementButton.setOnLongClickListener(longClickListener); - mDecrementButton.setNumberPicker(this); + // create the fling and adjust scrollers + mFlingScroller = new Scroller(getContext()); + mAdjustScroller = new Scroller(getContext(), new OvershootInterpolator()); - mText = (EditText) findViewById(R.id.timepicker_input); - mText.setOnFocusChangeListener(focusListener); - mText.setFilters(new InputFilter[] {inputFilter}); - mText.setRawInputType(InputType.TYPE_CLASS_NUMBER); + updateInputTextView(); + updateIncrementAndDecrementButtonsVisibilityState(); + } + + @Override + public void onWindowFocusChanged(boolean hasWindowFocus) { + super.onWindowFocusChanged(hasWindowFocus); + if (!hasWindowFocus) { + removeAllCallbacks(); + } + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent event) { + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + mLastMotionEventY = mLastDownEventY = event.getY(); + removeAllCallbacks(); + mBeginEditOnUpEvent = false; + mAdjustScrollerOnUpEvent = true; + if (mDrawSelectorWheel) { + mBeginEditOnUpEvent = mFlingScroller.isFinished() + && mAdjustScroller.isFinished(); + mAdjustScrollerOnUpEvent = true; + mFlingScroller.forceFinished(true); + mAdjustScroller.forceFinished(true); + hideInputControls(); + return true; + } + if (isEventInInputText(event)) { + mAdjustScrollerOnUpEvent = false; + setDrawSelectorWheel(true); + hideInputControls(); + return true; + } + break; + case MotionEvent.ACTION_MOVE: + float currentMoveY = event.getY(); + int deltaDownY = (int) Math.abs(currentMoveY - mLastDownEventY); + if (deltaDownY > mTouchSlop) { + mBeginEditOnUpEvent = false; + setDrawSelectorWheel(true); + hideInputControls(); + return true; + } + break; + } + return false; + } + + @Override + public boolean onTouchEvent(MotionEvent ev) { + if (mVelocityTracker == null) { + mVelocityTracker = VelocityTracker.obtain(); + } + mVelocityTracker.addMovement(ev); + int action = ev.getActionMasked(); + switch (action) { + case MotionEvent.ACTION_MOVE: + float currentMoveY = ev.getY(); + if (mBeginEditOnUpEvent) { + int deltaDownY = (int) Math.abs(currentMoveY - mLastDownEventY); + if (deltaDownY > mTouchSlop) { + mBeginEditOnUpEvent = false; + } + } + int deltaMoveY = (int) (currentMoveY - mLastMotionEventY); + scrollBy(0, deltaMoveY); + invalidate(); + mLastMotionEventY = currentMoveY; + break; + case MotionEvent.ACTION_UP: + if (mBeginEditOnUpEvent) { + setDrawSelectorWheel(false); + showInputControls(); + mInputText.requestFocus(); + InputMethodManager imm = (InputMethodManager) getContext().getSystemService( + Context.INPUT_METHOD_SERVICE); + imm.showSoftInput(mInputText, 0); + return true; + } + VelocityTracker velocityTracker = mVelocityTracker; + velocityTracker.computeCurrentVelocity(1000, mMaximumFlingVelocity); + int initialVelocity = (int) velocityTracker.getYVelocity(); + if (Math.abs(initialVelocity) > mMinimumFlingVelocity) { + fling(initialVelocity); + } else { + if (mAdjustScrollerOnUpEvent) { + if (mFlingScroller.isFinished() && mAdjustScroller.isFinished()) { + postAdjustScrollerCommand(0); + } + } else { + postAdjustScrollerCommand(SHOW_INPUT_CONTROLS_DELAY_MILLIS); + } + } + mVelocityTracker.recycle(); + mVelocityTracker = null; + break; + } + return true; + } + + @Override + public boolean dispatchTouchEvent(MotionEvent event) { + int action = event.getActionMasked(); + if ((action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) + && !isEventInInputText(event)) { + removeAllCallbacks(); + } + return super.dispatchTouchEvent(event); + } + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + int keyCode = event.getKeyCode(); + if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) { + removeAllCallbacks(); + } + return super.dispatchKeyEvent(event); + } + + @Override + public boolean dispatchTrackballEvent(MotionEvent event) { + int action = event.getActionMasked(); + if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) { + removeAllCallbacks(); + } + return super.dispatchTrackballEvent(event); + } - if (!isEnabled()) { - setEnabled(false); + @Override + public void computeScroll() { + if (!mDrawSelectorWheel) { + return; + } + 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(); } } @@ -222,11 +638,57 @@ public class NumberPicker extends LinearLayout { super.setEnabled(enabled); mIncrementButton.setEnabled(enabled); mDecrementButton.setEnabled(enabled); - mText.setEnabled(enabled); + mInputText.setEnabled(enabled); + } + + /** + * Scrolls the selector with the given <code>vertical offset</code>. + */ + @Override + public void scrollBy(int x, int y) { + int[] selectorIndices = getSelectorIndices(); + if (mInitialScrollOffset == Integer.MIN_VALUE) { + int totalTextHeight = selectorIndices.length * mTextSize; + int totalTextGapHeight = (mBottom - mTop) - totalTextHeight; + int textGapCount = selectorIndices.length - 1; + int selectorTextGapHeight = totalTextGapHeight / textGapCount; + // compensate for integer division loss of the components used to + // calculate the text gap + int integerDivisionLoss = (mTextSize + mBottom - mTop) % textGapCount; + mInitialScrollOffset = mCurrentScrollOffset = mTextSize - integerDivisionLoss / 2; + mSelectorElementHeight = mTextSize + selectorTextGapHeight; + } + + if (!mWrapSelector && y > 0 && selectorIndices[SELECTOR_MIDDLE_ITEM_INDEX] <= mStart) { + mCurrentScrollOffset = mInitialScrollOffset; + return; + } + if (!mWrapSelector && y < 0 && selectorIndices[SELECTOR_MIDDLE_ITEM_INDEX] >= mEnd) { + mCurrentScrollOffset = mInitialScrollOffset; + return; + } + mCurrentScrollOffset += y; + while (mCurrentScrollOffset - mInitialScrollOffset > mSelectorElementHeight) { + mCurrentScrollOffset -= mSelectorElementHeight; + decrementSelectorIndices(selectorIndices); + changeCurrent(selectorIndices[SELECTOR_MIDDLE_ITEM_INDEX]); + if (selectorIndices[SELECTOR_MIDDLE_ITEM_INDEX] <= mStart) { + mCurrentScrollOffset = mInitialScrollOffset; + } + } + while (mCurrentScrollOffset - mInitialScrollOffset < -mSelectorElementHeight) { + mCurrentScrollOffset += mSelectorElementHeight; + incrementScrollSelectorIndices(selectorIndices); + changeCurrent(selectorIndices[SELECTOR_MIDDLE_ITEM_INDEX]); + if (selectorIndices[SELECTOR_MIDDLE_ITEM_INDEX] >= mEnd) { + mCurrentScrollOffset = mInitialScrollOffset; + } + } } /** * Set the callback that indicates the number has been adjusted by the user. + * * @param listener the callback, should not be null. */ public void setOnChangeListener(OnChangedListener listener) { @@ -235,292 +697,678 @@ public class NumberPicker extends LinearLayout { /** * Set the formatter that will be used to format the number for presentation - * @param formatter the formatter object. If formatter is null, String.valueOf() - * will be used + * + * @param formatter the formatter object. If formatter is null, + * String.valueOf() will be used */ public void setFormatter(Formatter formatter) { mFormatter = formatter; } /** - * Set the range of numbers allowed for the number picker. The current - * value will be automatically set to the start. + * Set the range of numbers allowed for the number picker. The current value + * will be automatically set to the start. * * @param start the start of the range (inclusive) * @param end the end of the range (inclusive) */ public void setRange(int start, int end) { - setRange(start, end, null/*displayedValues*/); + setRange(start, end, null); } /** - * Set the range of numbers allowed for the number picker. The current - * value will be automatically set to the start. Also provide a mapping - * for values used to display to the user. + * Set the range of numbers allowed for the number picker. The current value + * will be automatically set to the start. Also provide a mapping for values + * used to display to the user. * * @param start the start of the range (inclusive) * @param end the end of the range (inclusive) * @param displayedValues the values displayed to the user. */ public void setRange(int start, int end, String[] displayedValues) { + boolean wrapSelector = (end - start) >= mSelectorIndices.length; + setRange(start, end, displayedValues, wrapSelector); + } + + /** + * Set the range of numbers allowed for the number picker. The current value + * will be automatically set to the start. Also provide a mapping for values + * used to display to the user. + * + * @param start the start of the range (inclusive) + * @param end the end of the range (inclusive) + * @param displayedValues the values displayed to the user. + */ + public void setRange(int start, int end, String[] displayedValues, boolean wrapSelector) { + if (start < 0 || end < 0) { + throw new IllegalArgumentException("start and end must be > 0"); + } + mDisplayedValues = displayedValues; mStart = start; mEnd = end; mCurrent = start; - updateView(); + + setWrapSelector(wrapSelector); + updateInputTextView(); if (displayedValues != null) { // Allow text entry rather than strictly numeric entry. - mText.setRawInputType(InputType.TYPE_CLASS_TEXT | - InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); + mInputText.setRawInputType(InputType.TYPE_CLASS_TEXT + | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); + } else { + mInputText.setRawInputType(InputType.TYPE_CLASS_NUMBER); } + + // make sure cached string representations are dropped + mSelectorIndexToStringCache.clear(); } /** * Set the current value for the number picker. * * @param current the current value the start of the range (inclusive) - * @throws IllegalArgumentException when current is not within the range - * of of the number picker + * @throws IllegalArgumentException when current is not within the range of + * of the number picker */ public void setCurrent(int current) { if (current < mStart || current > mEnd) { - throw new IllegalArgumentException( - "current should be >= start and <= end"); + throw new IllegalArgumentException("current should be >= start and <= end"); } mCurrent = current; - updateView(); + updateInputTextView(); + updateIncrementAndDecrementButtonsVisibilityState(); + } + + /** + * Sets whether the selector shown during flinging/scrolling should wrap + * around the beginning and end values. + * + * @param wrapSelector Whether to wrap. + */ + public void setWrapSelector(boolean wrapSelector) { + if (wrapSelector && (mEnd - mStart) < mSelectorIndices.length) { + throw new IllegalStateException("Range less than selector items count."); + } + if (wrapSelector != mWrapSelector) { + // force the selector indices array to be reinitialized + mSelectorIndices[SELECTOR_MIDDLE_ITEM_INDEX] = Integer.MAX_VALUE; + mWrapSelector = wrapSelector; + } } /** - * Sets the speed at which the numbers will scroll when the +/- - * buttons are longpressed + * Sets the speed at which the numbers will scroll when the +/- buttons are + * longpressed * * @param speed The speed (in milliseconds) at which the numbers will scroll - * default 300ms + * default 300ms */ public void setSpeed(long speed) { - mSpeed = speed; + mLongPressUpdateSpeed = speed; } - private String formatNumber(int value) { - return (mFormatter != null) - ? mFormatter.toString(value) - : String.valueOf(value); + /** + * Returns the current value of the NumberPicker + * + * @return the current value. + */ + public int getCurrent() { + return mCurrent; + } + + @Override + public int getSolidColor() { + return mSolidColor; + } + + @Override + protected float getTopFadingEdgeStrength() { + return TOP_AND_BOTTOM_FADING_EDGE_STRENGTH; + } + + @Override + protected float getBottomFadingEdgeStrength() { + return TOP_AND_BOTTOM_FADING_EDGE_STRENGTH; + } + + @Override + protected void onDetachedFromWindow() { + removeAllCallbacks(); + } + + @Override + protected void dispatchDraw(Canvas canvas) { + // There is a good reason for doing this. See comments in draw(). + } + + @Override + public void draw(Canvas canvas) { + // Dispatch draw to our children only if we are not currently running + // the animation for simultaneously fading out the scroll wheel and + // showing in the buttons. This class takes advantage of the View + // implementation of fading edges effect to draw the selector wheel. + // However, in View.draw(), the fading is applied after all the children + // have been drawn and we do not want this fading to be applied to the + // buttons which are currently showing in. Therefore, we draw our + // children + // after we have completed drawing ourselves. + + // Draw the selector wheel if needed + if (mDrawSelectorWheel) { + super.draw(canvas); + } + + // Draw our children if we are not showing the selector wheel of fading + // it out + if (mShowInputControlsAnimator.isRunning() || !mDrawSelectorWheel) { + long drawTime = getDrawingTime(); + for (int i = 0, count = getChildCount(); i < count; i++) { + View child = getChildAt(i); + if (!child.isShown()) { + continue; + } + drawChild(canvas, getChildAt(i), drawTime); + } + } + } + + @Override + protected void onDraw(Canvas canvas) { + // we only draw the selector wheel + if (!mDrawSelectorWheel) { + return; + } + float x = (mRight - mLeft) / 2; + float y = mCurrentScrollOffset; + + int[] selectorIndices = getSelectorIndices(); + for (int i = 0; i < selectorIndices.length; i++) { + int selectorIndex = selectorIndices[i]; + String scrollSelectorValue = mSelectorIndexToStringCache.get(selectorIndex); + canvas.drawText(scrollSelectorValue, x, y, mSelectorPaint); + y += mSelectorElementHeight; + } } /** - * Sets the current value of this NumberPicker, and sets mPrevious to the previous - * value. If current is greater than mEnd less than mStart, the value of mCurrent - * is wrapped around. + * Returns the upper value of the range of the NumberPicker * - * Subclasses can override this to change the wrapping behavior + * @return the uppper number of the range. + */ + protected int getEndRange() { + return mEnd; + } + + /** + * Returns the lower value of the range of the NumberPicker + * + * @return the lower number of the range. + */ + protected int getBeginRange() { + return mStart; + } + + /** + * Sets the current value of this NumberPicker, and sets mPrevious to the + * previous value. If current is greater than mEnd less than mStart, the + * value of mCurrent is wrapped around. Subclasses can override this to + * change the wrapping behavior * * @param current the new value of the NumberPicker */ - protected void changeCurrent(int current) { + private void changeCurrent(int current) { + if (mCurrent == current) { + return; + } // Wrap around the values if we go past the start or end - if (current > mEnd) { - current = mStart; - } else if (current < mStart) { - current = mEnd; + if (mWrapSelector) { + current = getWrappedSelectorIndex(current); } - mPrevious = mCurrent; - mCurrent = current; - notifyChange(); - updateView(); + int previous = mCurrent; + setCurrent(current); + notifyChange(previous, current); } /** - * Notifies the listener, if registered, of a change of the value of this - * NumberPicker. + * Sets the <code>alpha</code> of the {@link Paint} for drawing the selector + * wheel. */ - private void notifyChange() { - if (mListener != null) { - mListener.onChanged(this, mPrevious, mCurrent); + @SuppressWarnings("unused") + // Called by ShowInputControlsAnimator via reflection + private void setSelectorPaintAlpha(int alpha) { + mSelectorPaint.setAlpha(alpha); + if (mDrawSelectorWheel) { + invalidate(); } } /** - * Updates the view of this NumberPicker. If displayValues were specified - * in {@link #setRange}, the string corresponding to the index specified by - * the current value will be returned. Otherwise, the formatter specified - * in {@link setFormatter} will be used to format the number. + * @return If the <code>event</code> is in the input text. */ - private void updateView() { - /* 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. - */ - if (mDisplayedValues == null) { - mText.setText(formatNumber(mCurrent)); + private boolean isEventInInputText(MotionEvent event) { + mInputText.getHitRect(mTempRect); + return mTempRect.contains((int) event.getX(), (int) event.getY()); + } + + /** + * Sets if to <code>drawSelectionWheel</code>. + */ + private void setDrawSelectorWheel(boolean drawSelectorWheel) { + mDrawSelectorWheel = drawSelectorWheel; + // do not fade if the selector wheel not shown + setVerticalFadingEdgeEnabled(drawSelectorWheel); + } + + /** + * Callback invoked upon completion of a given <code>scroller</code>. + */ + private void onScrollerFinished(Scroller scroller) { + if (scroller == mFlingScroller) { + postAdjustScrollerCommand(0); } else { - mText.setText(mDisplayedValues[mCurrent - mStart]); + showInputControls(); + updateInputTextView(); } - mText.setSelection(mText.getText().length()); } - private void validateCurrentView(CharSequence str) { - int val = getSelectedPos(str.toString()); - if ((val >= mStart) && (val <= mEnd)) { - if (mCurrent != val) { - mPrevious = mCurrent; - mCurrent = val; - notifyChange(); + /** + * Flings the selector with the given <code>velocityY</code>. + */ + private void fling(int velocityY) { + mPreviousScrollerY = 0; + Scroller flingScroller = mFlingScroller; + + if (mWrapSelector) { + if (velocityY > 0) { + flingScroller.fling(0, 0, 0, velocityY, 0, 0, 0, Integer.MAX_VALUE); + } else { + flingScroller.fling(0, Integer.MAX_VALUE, 0, velocityY, 0, 0, 0, Integer.MAX_VALUE); + } + } else { + if (velocityY > 0) { + int maxY = mTextSize * (mCurrent - mStart); + flingScroller.fling(0, 0, 0, velocityY, 0, 0, 0, maxY); + } else { + int startY = mTextSize * (mEnd - mCurrent); + int maxY = startY; + flingScroller.fling(0, startY, 0, velocityY, 0, 0, 0, maxY); } } - updateView(); + + postAdjustScrollerCommand(flingScroller.getDuration()); + invalidate(); } - private void validateInput(View v) { - String str = String.valueOf(((TextView) v).getText()); - if ("".equals(str)) { + /** + * Hides the input controls which is the up/down arrows and the text field. + */ + private void hideInputControls() { + mShowInputControlsAnimator.cancel(); + mIncrementButton.setVisibility(INVISIBLE); + mDecrementButton.setVisibility(INVISIBLE); + mInputText.setVisibility(INVISIBLE); + } - // Restore to the old value as we don't allow empty values - updateView(); - } else { + /** + * Show the input controls by making them visible and animating the alpha + * property up/down arrows. + */ + private void showInputControls() { + updateIncrementAndDecrementButtonsVisibilityState(); + mInputText.setVisibility(VISIBLE); + mShowInputControlsAnimator.start(); + } - // Check the new value and ensure it's in range - validateCurrentView(str); + /** + * Updates the visibility state of the increment and decrement buttons. + */ + private void updateIncrementAndDecrementButtonsVisibilityState() { + if (mWrapSelector || mCurrent < mEnd) { + mIncrementButton.setVisibility(VISIBLE); + } else { + mIncrementButton.setVisibility(INVISIBLE); + } + if (mWrapSelector || mCurrent > mStart) { + mDecrementButton.setVisibility(VISIBLE); + } else { + mDecrementButton.setVisibility(INVISIBLE); } } /** - * @hide + * @return The selector indices array with proper values with the current as + * the middle one. */ - public void cancelIncrement() { - mIncrement = false; + private int[] getSelectorIndices() { + int current = getCurrent(); + if (mSelectorIndices[SELECTOR_MIDDLE_ITEM_INDEX] != current) { + for (int i = 0; i < mSelectorIndices.length; i++) { + int selectorIndex = current + (i - SELECTOR_MIDDLE_ITEM_INDEX); + if (mWrapSelector) { + selectorIndex = getWrappedSelectorIndex(selectorIndex); + } + mSelectorIndices[i] = selectorIndex; + ensureCachedScrollSelectorValue(mSelectorIndices[i]); + } + } + return mSelectorIndices; } /** - * @hide + * @return The wrapped index <code>selectorIndex</code> value. + * <p> + * Note: The absolute value of the argument is never larger than + * mEnd - mStart. + * </p> */ - public void cancelDecrement() { - mDecrement = false; + private int getWrappedSelectorIndex(int selectorIndex) { + if (selectorIndex > mEnd) { + return (Math.abs(selectorIndex) - mEnd); + } else if (selectorIndex < mStart) { + return (mEnd - Math.abs(selectorIndex)); + } + return selectorIndex; } - private static final char[] DIGIT_CHARACTERS = new char[] { - '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' - }; + /** + * Increments the <code>selectorIndices</code> whose string representations + * will be displayed in the selector. + */ + private void incrementScrollSelectorIndices(int[] selectorIndices) { + for (int i = 0; i < selectorIndices.length - 1; i++) { + selectorIndices[i] = selectorIndices[i + 1]; + } + int nextScrollSelectorIndex = selectorIndices[selectorIndices.length - 2] + 1; + if (mWrapSelector && nextScrollSelectorIndex > mEnd) { + nextScrollSelectorIndex = mStart; + } + selectorIndices[selectorIndices.length - 1] = nextScrollSelectorIndex; + ensureCachedScrollSelectorValue(nextScrollSelectorIndex); + } - private NumberPickerButton mIncrementButton; - private NumberPickerButton mDecrementButton; + /** + * 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 (mWrapSelector && nextScrollSelectorIndex < mStart) { + nextScrollSelectorIndex = mEnd; + } + selectorIndices[0] = nextScrollSelectorIndex; + ensureCachedScrollSelectorValue(nextScrollSelectorIndex); + } - private class NumberPickerInputFilter implements InputFilter { - public CharSequence filter(CharSequence source, int start, int end, - Spanned dest, int dstart, int dend) { - if (mDisplayedValues == null) { - return mNumberInputFilter.filter(source, start, end, dest, dstart, dend); - } - CharSequence filtered = String.valueOf(source.subSequence(start, end)); - String result = String.valueOf(dest.subSequence(0, dstart)) - + filtered - + dest.subSequence(dend, dest.length()); - String str = String.valueOf(result).toLowerCase(); - for (String val : mDisplayedValues) { - val = val.toLowerCase(); - if (val.startsWith(str)) { - return filtered; - } + /** + * Ensures we have a cached string representation of the given <code> + * selectorIndex</code> + * to avoid multiple instantiations of the same string. + */ + private void ensureCachedScrollSelectorValue(int selectorIndex) { + SparseArray<String> cache = mSelectorIndexToStringCache; + String scrollSelectorValue = cache.get(selectorIndex); + if (scrollSelectorValue != null) { + return; + } + if (selectorIndex < mStart || selectorIndex > mEnd) { + scrollSelectorValue = ""; + } else { + if (mDisplayedValues != null) { + scrollSelectorValue = mDisplayedValues[selectorIndex]; + } else { + scrollSelectorValue = formatNumber(selectorIndex); } - return ""; } + cache.put(selectorIndex, scrollSelectorValue); } - private class NumberRangeKeyListener extends NumberKeyListener { + private String formatNumber(int value) { + return (mFormatter != null) ? mFormatter.toString(value) : String.valueOf(value); + } - // XXX This doesn't allow for range limits when controlled by a - // soft input method! - public int getInputType() { - return InputType.TYPE_CLASS_NUMBER; + private void validateInputTextView(View v) { + String str = String.valueOf(((TextView) v).getText()); + if (TextUtils.isEmpty(str)) { + // Restore to the old value as we don't allow empty values + updateInputTextView(); + } else { + // Check the new value and ensure it's in range + int current = getSelectedPos(str.toString()); + changeCurrent(current); } + } - @Override - protected char[] getAcceptedChars() { - return DIGIT_CHARACTERS; + /** + * Updates the view of this NumberPicker. If displayValues were specified in + * {@link #setRange}, the string corresponding to the index specified by the + * current value will be returned. Otherwise, the formatter specified in + * {@link #setFormatter} will be used to format the number. + */ + private void 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. + */ + if (mDisplayedValues == null) { + mInputText.setText(formatNumber(mCurrent)); + } else { + mInputText.setText(mDisplayedValues[mCurrent - mStart]); } + mInputText.setSelection(mInputText.getText().length()); + } - @Override - public CharSequence filter(CharSequence source, int start, int end, - Spanned dest, int dstart, int dend) { - - CharSequence filtered = super.filter(source, start, end, dest, dstart, dend); - if (filtered == null) { - filtered = source.subSequence(start, end); - } - - String result = String.valueOf(dest.subSequence(0, dstart)) - + filtered - + dest.subSequence(dend, dest.length()); + /** + * Notifies the listener, if registered, of a change of the value of this + * NumberPicker. + */ + private void notifyChange(int previous, int current) { + if (mListener != null) { + mListener.onChanged(this, previous, mCurrent); + } + } - if ("".equals(result)) { - return result; - } - int val = getSelectedPos(result); + /** + * Posts a command for updating the current value every <code>updateMillis + * </code>. + */ + private void postUpdateValueFromLongPress(int updateMillis) { + mInputText.clearFocus(); + removeAllCallbacks(); + if (mUpdateFromLongPressCommand == null) { + mUpdateFromLongPressCommand = new UpdateValueFromLongPressCommand(); + } + mUpdateFromLongPressCommand.setUpdateStep(updateMillis); + post(mUpdateFromLongPressCommand); + } - /* Ensure the user can't type in a value greater - * than the max allowed. We have to allow less than min - * as the user might want to delete some numbers - * and then type a new number. - */ - if (val > mEnd) { - return ""; - } else { - return filtered; - } + /** + * Removes all pending callback from the message queue. + */ + private void removeAllCallbacks() { + if (mUpdateFromLongPressCommand != null) { + removeCallbacks(mUpdateFromLongPressCommand); + } + if (mAdjustScrollerCommand != null) { + removeCallbacks(mAdjustScrollerCommand); + } + if (mSetSelectionCommand != null) { + removeCallbacks(mSetSelectionCommand); } } - private int getSelectedPos(String str) { + /** + * @return The selected index given its displayed <code>value</code>. + */ + private int getSelectedPos(String value) { if (mDisplayedValues == null) { try { - return Integer.parseInt(str); + return Integer.parseInt(value); } catch (NumberFormatException e) { - /* Ignore as if it's not a number we don't care */ + // Ignore as if it's not a number we don't care } } else { for (int i = 0; i < mDisplayedValues.length; i++) { - /* Don't force the user to type in jan when ja will do */ - str = str.toLowerCase(); - if (mDisplayedValues[i].toLowerCase().startsWith(str)) { + // Don't force the user to type in jan when ja will do + value = value.toLowerCase(); + if (mDisplayedValues[i].toLowerCase().startsWith(value)) { return mStart + i; } } - /* The user might have typed in a number into the month field i.e. + /* + * The user might have typed in a number into the month field i.e. * 10 instead of OCT so support that too. */ try { - return Integer.parseInt(str); + return Integer.parseInt(value); } catch (NumberFormatException e) { - /* Ignore as if it's not a number we don't care */ + // Ignore as if it's not a number we don't care } } return mStart; } /** - * Returns the current value of the NumberPicker - * @return the current value. + * Posts an {@link SetSelectionCommand} from the given <code>selectionStart + * </code> to + * <code>selectionEnd</code>. */ - public int getCurrent() { - return mCurrent; + private void postSetSelectionCommand(int selectionStart, int selectionEnd) { + if (mSetSelectionCommand == null) { + mSetSelectionCommand = new SetSelectionCommand(); + } else { + removeCallbacks(mSetSelectionCommand); + } + mSetSelectionCommand.mSelectionStart = selectionStart; + mSetSelectionCommand.mSelectionEnd = selectionEnd; + post(mSetSelectionCommand); } /** - * Returns the upper value of the range of the NumberPicker - * @return the uppper number of the range. + * Posts an {@link AdjustScrollerCommand} within the given <code> + * delayMillis</code> + * . */ - protected int getEndRange() { - return mEnd; + private void postAdjustScrollerCommand(int delayMillis) { + if (mAdjustScrollerCommand == null) { + mAdjustScrollerCommand = new AdjustScrollerCommand(); + } else { + removeCallbacks(mAdjustScrollerCommand); + } + postDelayed(mAdjustScrollerCommand, delayMillis); } /** - * Returns the lower value of the range of the NumberPicker - * @return the lower number of the range. + * Filter for accepting only valid indices or prefixes of the string + * representation of valid indices. */ - protected int getBeginRange() { - return mStart; + class InputTextFilter extends NumberKeyListener { + + // XXX This doesn't allow for range limits when controlled by a + // soft input method! + public int getInputType() { + return InputType.TYPE_CLASS_TEXT; + } + + @Override + protected char[] getAcceptedChars() { + return DIGIT_CHARACTERS; + } + + @Override + public CharSequence filter(CharSequence source, int start, int end, Spanned dest, + int dstart, int dend) { + if (mDisplayedValues == null) { + CharSequence filtered = super.filter(source, start, end, dest, dstart, dend); + if (filtered == null) { + filtered = source.subSequence(start, end); + } + + String result = String.valueOf(dest.subSequence(0, dstart)) + filtered + + dest.subSequence(dend, dest.length()); + + if ("".equals(result)) { + return result; + } + int val = getSelectedPos(result); + + /* + * Ensure the user can't type in a value greater than the max + * allowed. We have to allow less than min as the user might + * want to delete some numbers and then type a new number. + */ + if (val > mEnd) { + return ""; + } else { + return filtered; + } + } else { + CharSequence filtered = String.valueOf(source.subSequence(start, end)); + if (TextUtils.isEmpty(filtered)) { + return ""; + } + String result = String.valueOf(dest.subSequence(0, dstart)) + filtered + + dest.subSequence(dend, dest.length()); + String str = String.valueOf(result).toLowerCase(); + for (String val : mDisplayedValues) { + String valLowerCase = val.toLowerCase(); + if (valLowerCase.startsWith(str)) { + postSetSelectionCommand(result.length(), val.length()); + return val.subSequence(dstart, val.length()); + } + } + return ""; + } + } + } + + /** + * Command for setting the input text selection. + */ + class SetSelectionCommand implements Runnable { + private int mSelectionStart; + + private int mSelectionEnd; + + public void run() { + mInputText.setSelection(mSelectionStart, mSelectionEnd); + } + } + + /** + * Command for adjusting the scroller to show in its center the closest of + * the displayed items. + */ + class AdjustScrollerCommand implements Runnable { + public void run() { + mPreviousScrollerY = 0; + int deltaY = mInitialScrollOffset - mCurrentScrollOffset; + float delayCoef = (float) Math.abs(deltaY) / (float) mTextSize; + int duration = (int) (delayCoef * SELECTOR_ADJUSTMENT_DURATION_MILLIS); + mAdjustScroller.startScroll(0, 0, 0, deltaY, duration); + invalidate(); + } + } + + /** + * Command for updating the current value from a long press. + */ + class UpdateValueFromLongPressCommand implements Runnable { + private int mUpdateStep = 0; + + private void setUpdateStep(int updateStep) { + mUpdateStep = updateStep; + } + + public void run() { + changeCurrent(mCurrent + mUpdateStep); + postDelayed(this, mLongPressUpdateSpeed); + } } } |