/* * Copyright (C) 2008 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 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.content.res.ColorStateList; import android.content.res.TypedArray; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.Paint.Align; import android.graphics.Rect; 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.LayoutInflater.Filter; import android.view.MotionEvent; import android.view.VelocityTracker; import android.view.View; import android.view.ViewConfiguration; import android.view.animation.OvershootInterpolator; import android.view.inputmethod.InputMethodManager; /** * A widget that enables the user to select a number form a predefined range. * The widget presents an input filed and up and down buttons for selecting the * current value. Pressing/long pressing the up and down buttons increments and * decrements the current value respectively. Touching the input filed shows a * scroll wheel, tapping on which while shown and not moving allows direct edit * of the current value. Sliding motions up or down hide the buttons and the * input filed, show the scroll wheel, and rotate the latter. Flinging is * also supported. The widget enables mapping from positions to strings such * that instead the position index the corresponding string is displayed. *
* For an example of using this widget, see {@link android.widget.TimePicker}. *
*/ @Widget public class NumberPicker extends LinearLayout { /** * The index of the middle selector item. */ private static final int SELECTOR_MIDDLE_ITEM_INDEX = 2; /** * The coefficient by which to adjust (divide) the max fling velocity. */ private static final int SELECTOR_MAX_FLING_VELOCITY_ADJUSTMENT = 8; /** * The the duration for adjusting the selector wheel. */ private static final int SELECTOR_ADJUSTMENT_DURATION_MILLIS = 800; /** * 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, java.util.Locale.US); 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 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; /** * Lower value of the range of numbers allowed for the NumberPicker */ private int mStart; /** * Upper value of the range of numbers allowed for the NumberPicker */ private int mEnd; /** * Current value of this NumberPicker */ private int mCurrent; /** * Listener to be notified upon current value change. */ private OnChangeListener mOnChangeListener; /** * Listener to be notified upon scroll state change. */ private OnScrollListener mOnScrollListener; /** * Formatter for for displaying the current value. */ private Formatter mFormatter; /** * The speed for updating the value form long press. */ private long mLongPressUpdateInterval = 300; /** * Cache for the string representation of selector indices. */ private final SparseArrayvertical offset
.
*/
@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;
}
}
}
@Override
public int getSolidColor() {
return mSolidColor;
}
/**
* Sets the listener to be notified on change of the current value.
*
* @param onChangeListener The listener.
*/
public void setOnChangeListener(OnChangeListener onChangeListener) {
mOnChangeListener = onChangeListener;
}
/**
* Set listener to be notified for scroll state changes.
*
* @param onScrollListener the callback, should not be null.
*/
public void setOnScrollListener(OnScrollListener onScrollListener) {
mOnScrollListener = onScrollListener;
}
/**
* Set the formatter to be used for formatting the current value.
* * Note: If you have provided alternative values for the selected positons * this formatter is never invoked. *
* * @param formatter the formatter object. If formatter is null, * String.valueOf() will be used. * * @see #setRange(int, int, String[]) */ public void setFormatter(Formatter formatter) { mFormatter = formatter; resetSelectorIndices(); } /** * 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); } /** * 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 instead of the numbers in the range. * * @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. *
* Note: The wrapSelectorWheel
argument is ignored if the range
* (difference between start
and end
) us less than
* five since this is the number of values shown by the selector wheel.
*
alpha
of the {@link Paint} for drawing the selector
* wheel.
*/
@SuppressWarnings("unused")
// Called by ShowInputControlsAnimator via reflection
private void setSelectorPaintAlpha(int alpha) {
mSelectorPaint.setAlpha(alpha);
if (mDrawSelectorWheel) {
invalidate();
}
}
/**
* @return If the event
is in the input text.
*/
private boolean isEventInInputText(MotionEvent event) {
mInputText.getHitRect(mTempRect);
return mTempRect.contains((int) event.getX(), (int) event.getY());
}
/**
* Sets if to drawSelectionWheel
.
*/
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 scroller
.
*/
private void onScrollerFinished(Scroller scroller) {
if (scroller == mFlingScroller) {
postAdjustScrollerCommand(0);
tryNotifyScrollListener(OnScrollListener.SCROLL_STATE_IDLE);
} else {
showInputControls();
updateInputTextView();
}
}
/**
* Notifies the scroll listener for the given scrollState
* if the scroll state differs from the current scroll state.
*/
private void tryNotifyScrollListener(int scrollState) {
if (mOnScrollListener != null && mScrollState != scrollState) {
mScrollState = scrollState;
mOnScrollListener.onScrollStateChange(this, scrollState);
}
}
/**
* Flings the selector with the given velocityY
.
*/
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);
}
}
postAdjustScrollerCommand(flingScroller.getDuration());
invalidate();
}
/**
* 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);
}
/**
* 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();
}
/**
* 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);
}
}
/**
* @return The selector indices array with proper values with the current as
* the middle one.
*/
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;
}
/**
* @return The wrapped index selectorIndex
value.
*/
private int getWrappedSelectorIndex(int selectorIndex) {
if (selectorIndex > mEnd) {
return mStart + (selectorIndex - mEnd) % (mEnd - mStart);
} else if (selectorIndex < mStart) {
return mEnd - (mStart - selectorIndex) % (mEnd - mStart);
}
return selectorIndex;
}
/**
* Increments the selectorIndices
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);
}
/**
* 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 (mWrapSelector && nextScrollSelectorIndex < mStart) {
nextScrollSelectorIndex = mEnd;
}
selectorIndices[0] = nextScrollSelectorIndex;
ensureCachedScrollSelectorValue(nextScrollSelectorIndex);
}
/**
* Ensures we have a cached string representation of the given
* selectorIndex
* to avoid multiple instantiations of the same string.
*/
private void ensureCachedScrollSelectorValue(int selectorIndex) {
SparseArrayupdateMillis
*
.
*/
private void postUpdateValueFromLongPress(int updateMillis) {
mInputText.clearFocus();
removeAllCallbacks();
if (mUpdateFromLongPressCommand == null) {
mUpdateFromLongPressCommand = new UpdateValueFromLongPressCommand();
}
mUpdateFromLongPressCommand.setUpdateStep(updateMillis);
post(mUpdateFromLongPressCommand);
}
/**
* 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);
}
}
/**
* @return The selected index given its displayed value
.
*/
private int getSelectedPos(String value) {
if (mDisplayedValues == null) {
try {
return Integer.parseInt(value);
} catch (NumberFormatException e) {
// 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
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.
* 10 instead of OCT so support that too.
*/
try {
return Integer.parseInt(value);
} catch (NumberFormatException e) {
// Ignore as if it's not a number we don't care
}
}
return mStart;
}
/**
* Posts an {@link SetSelectionCommand} from the given selectionStart
*
to
* selectionEnd
.
*/
private void postSetSelectionCommand(int selectionStart, int selectionEnd) {
if (mSetSelectionCommand == null) {
mSetSelectionCommand = new SetSelectionCommand();
} else {
removeCallbacks(mSetSelectionCommand);
}
mSetSelectionCommand.mSelectionStart = selectionStart;
mSetSelectionCommand.mSelectionEnd = selectionEnd;
post(mSetSelectionCommand);
}
/**
* Posts an {@link AdjustScrollerCommand} within the given
* delayMillis
* .
*/
private void postAdjustScrollerCommand(int delayMillis) {
if (mAdjustScrollerCommand == null) {
mAdjustScrollerCommand = new AdjustScrollerCommand();
} else {
removeCallbacks(mAdjustScrollerCommand);
}
postDelayed(mAdjustScrollerCommand, delayMillis);
}
/**
* Filter for accepting only valid indices or prefixes of the string
* representation of valid indices.
*/
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, mLongPressUpdateInterval);
}
}
}