| 1 | /* |
| 2 | * Copyright (C) 2008 The Android Open Source Project |
| 3 | * |
| 4 | * Licensed under the Apache License, Version 2.0 (the "License"); |
| 5 | * you may not use this file except in compliance with the License. |
| 6 | * You may obtain a copy of the License at |
| 7 | * |
| 8 | * http://www.apache.org/licenses/LICENSE-2.0 |
| 9 | * |
| 10 | * Unless required by applicable law or agreed to in writing, software |
| 11 | * distributed under the License is distributed on an "AS IS" BASIS, |
| 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 13 | * See the License for the specific language governing permissions and |
| 14 | * limitations under the License. |
| 15 | */ |
| 16 | |
| 17 | package net.mandaria.tippytipper.widgets; |
| 18 | |
| 19 | import net.mandaria.tippytipper.R; |
| 20 | import android.content.Context; |
| 21 | import android.os.Handler; |
| 22 | import android.text.InputFilter; |
| 23 | import android.text.InputType; |
| 24 | import android.text.Spanned; |
| 25 | import android.text.method.NumberKeyListener; |
| 26 | import android.util.AttributeSet; |
| 27 | import android.view.LayoutInflater; |
| 28 | import android.view.View; |
| 29 | import android.view.View.OnClickListener; |
| 30 | import android.view.View.OnFocusChangeListener; |
| 31 | import android.view.View.OnLongClickListener; |
| 32 | import android.widget.TextView; |
| 33 | import android.widget.LinearLayout; |
| 34 | import android.widget.EditText; |
| 35 | |
| 36 | /** |
| 37 | * This class has been pulled from the Android platform source code, its an internal widget that hasn't been |
| 38 | * made public so its included in the project in this fashion for use with the preferences screen; I have made |
| 39 | * a few slight modifications to the code here, I simply put a MAX and MIN default in the code but these values |
| 40 | * can still be set publically by calling code. |
| 41 | * |
| 42 | * @author Google |
| 43 | */ |
| 44 | public class NumberPicker extends LinearLayout implements OnClickListener, |
| 45 | OnFocusChangeListener, OnLongClickListener { |
| 46 | |
| 47 | private static final String TAG = "NumberPicker"; |
| 48 | private static final int DEFAULT_MAX = 999; |
| 49 | private static final int DEFAULT_MIN = 0; |
| 50 | |
| 51 | public interface OnChangedListener { |
| 52 | void onChanged(NumberPicker picker, int oldVal, int newVal); |
| 53 | } |
| 54 | |
| 55 | public interface Formatter { |
| 56 | String toString(int value); |
| 57 | } |
| 58 | |
| 59 | /* |
| 60 | * Use a custom NumberPicker formatting callback to use two-digit |
| 61 | * minutes strings like "01". Keeping a static formatter etc. is the |
| 62 | * most efficient way to do this; it avoids creating temporary objects |
| 63 | * on every call to format(). |
| 64 | */ |
| 65 | public static final NumberPicker.Formatter TWO_DIGIT_FORMATTER = |
| 66 | new NumberPicker.Formatter() { |
| 67 | final StringBuilder mBuilder = new StringBuilder(); |
| 68 | final java.util.Formatter mFmt = new java.util.Formatter(mBuilder); |
| 69 | final Object[] mArgs = new Object[1]; |
| 70 | public String toString(int value) { |
| 71 | mArgs[0] = value; |
| 72 | mBuilder.delete(0, mBuilder.length()); |
| 73 | mFmt.format("%02d", mArgs); |
| 74 | return mFmt.toString(); |
| 75 | } |
| 76 | }; |
| 77 | |
| 78 | public static final NumberPicker.Formatter THREE_DIGIT_FORMATTER = |
| 79 | new NumberPicker.Formatter() { |
| 80 | final StringBuilder mBuilder = new StringBuilder(); |
| 81 | final java.util.Formatter mFmt = new java.util.Formatter(mBuilder); |
| 82 | final Object[] mArgs = new Object[1]; |
| 83 | public String toString(int value) { |
| 84 | mArgs[0] = value; |
| 85 | mBuilder.delete(0, mBuilder.length()); |
| 86 | mFmt.format("%03d", mArgs); |
| 87 | return mFmt.toString(); |
| 88 | } |
| 89 | }; |
| 90 | |
| 91 | private final Handler mHandler; |
| 92 | private final Runnable mRunnable = new Runnable() { |
| 93 | public void run() { |
| 94 | if (mIncrement) { |
| 95 | changeCurrent(mCurrent + 1); |
| 96 | mHandler.postDelayed(this, mSpeed); |
| 97 | } else if (mDecrement) { |
| 98 | changeCurrent(mCurrent - 1); |
| 99 | mHandler.postDelayed(this, mSpeed); |
| 100 | } |
| 101 | } |
| 102 | }; |
| 103 | |
| 104 | private final EditText mText; |
| 105 | private final InputFilter mNumberInputFilter; |
| 106 | |
| 107 | private String[] mDisplayedValues; |
| 108 | protected int mStart; |
| 109 | protected int mEnd; |
| 110 | protected int mCurrent; |
| 111 | protected int mPrevious; |
| 112 | private OnChangedListener mListener; |
| 113 | private Formatter mFormatter; |
| 114 | private long mSpeed = 300; |
| 115 | |
| 116 | private boolean mIncrement; |
| 117 | private boolean mDecrement; |
| 118 | |
| 119 | public NumberPicker(Context context) { |
| 120 | this(context, null); |
| 121 | } |
| 122 | |
| 123 | public NumberPicker(Context context, AttributeSet attrs) { |
| 124 | this(context, attrs, 0); |
| 125 | } |
| 126 | |
| 127 | @SuppressWarnings({"UnusedDeclaration"}) |
| 128 | public NumberPicker(Context context, AttributeSet attrs, int defStyle) { |
| 129 | super(context, attrs); |
| 130 | setOrientation(VERTICAL); |
| 131 | LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); |
| 132 | inflater.inflate(R.layout.number_picker, this, true); |
| 133 | mHandler = new Handler(); |
| 134 | InputFilter inputFilter = new NumberPickerInputFilter(); |
| 135 | mNumberInputFilter = new NumberRangeKeyListener(); |
| 136 | mIncrementButton = (NumberPickerButton) findViewById(R.id.increment); |
| 137 | mIncrementButton.setOnClickListener(this); |
| 138 | mIncrementButton.setOnLongClickListener(this); |
| 139 | mIncrementButton.setNumberPicker(this); |
| 140 | mDecrementButton = (NumberPickerButton) findViewById(R.id.decrement); |
| 141 | mDecrementButton.setOnClickListener(this); |
| 142 | mDecrementButton.setOnLongClickListener(this); |
| 143 | mDecrementButton.setNumberPicker(this); |
| 144 | |
| 145 | mText = (EditText) findViewById(R.id.timepicker_input); |
| 146 | mText.setOnFocusChangeListener(this); |
| 147 | mText.setFilters(new InputFilter[] {inputFilter}); |
| 148 | mText.setRawInputType(InputType.TYPE_CLASS_NUMBER); |
| 149 | |
| 150 | if (!isEnabled()) { |
| 151 | setEnabled(false); |
| 152 | } |
| 153 | |
| 154 | mStart = DEFAULT_MIN; |
| 155 | mEnd = DEFAULT_MAX; |
| 156 | } |
| 157 | |
| 158 | @Override |
| 159 | public void setEnabled(boolean enabled) { |
| 160 | super.setEnabled(enabled); |
| 161 | mIncrementButton.setEnabled(enabled); |
| 162 | mDecrementButton.setEnabled(enabled); |
| 163 | mText.setEnabled(enabled); |
| 164 | } |
| 165 | |
| 166 | public void setOnChangeListener(OnChangedListener listener) { |
| 167 | mListener = listener; |
| 168 | } |
| 169 | |
| 170 | public void setFormatter(Formatter formatter) { |
| 171 | mFormatter = formatter; |
| 172 | } |
| 173 | |
| 174 | /** |
| 175 | * Set the range of numbers allowed for the number picker. The current |
| 176 | * value will be automatically set to the start. |
| 177 | * |
| 178 | * @param start the start of the range (inclusive) |
| 179 | * @param end the end of the range (inclusive) |
| 180 | */ |
| 181 | public void setRange(int start, int end) { |
| 182 | mStart = start; |
| 183 | mEnd = end; |
| 184 | mCurrent = start; |
| 185 | updateView(); |
| 186 | } |
| 187 | |
| 188 | /** |
| 189 | * Set the range of numbers allowed for the number picker. The current |
| 190 | * value will be automatically set to the start. Also provide a mapping |
| 191 | * for values used to display to the user. |
| 192 | * |
| 193 | * @param start the start of the range (inclusive) |
| 194 | * @param end the end of the range (inclusive) |
| 195 | * @param displayedValues the values displayed to the user. |
| 196 | */ |
| 197 | public void setRange(int start, int end, String[] displayedValues) { |
| 198 | mDisplayedValues = displayedValues; |
| 199 | mStart = start; |
| 200 | mEnd = end; |
| 201 | mCurrent = start; |
| 202 | updateView(); |
| 203 | } |
| 204 | |
| 205 | public void setCurrent(int current) { |
| 206 | mCurrent = current; |
| 207 | updateView(); |
| 208 | } |
| 209 | |
| 210 | /** |
| 211 | * The speed (in milliseconds) at which the numbers will scroll |
| 212 | * when the the +/- buttons are longpressed. Default is 300ms. |
| 213 | */ |
| 214 | public void setSpeed(long speed) { |
| 215 | mSpeed = speed; |
| 216 | } |
| 217 | |
| 218 | public void onClick(View v) { |
| 219 | validateInput(mText); |
| 220 | if (!mText.hasFocus()) mText.requestFocus(); |
| 221 | |
| 222 | if(v != null) |
| 223 | { |
| 224 | // now perform the increment/decrement |
| 225 | if (R.id.increment == v.getId()) { |
| 226 | changeCurrent(mCurrent + 1); |
| 227 | } else if (R.id.decrement == v.getId()) { |
| 228 | changeCurrent(mCurrent - 1); |
| 229 | } |
| 230 | } |
| 231 | } |
| 232 | |
| 233 | private String formatNumber(int value) { |
| 234 | return (mFormatter != null) |
| 235 | ? mFormatter.toString(value) |
| 236 | : String.valueOf(value); |
| 237 | } |
| 238 | |
| 239 | protected void changeCurrent(int current) { |
| 240 | |
| 241 | // Wrap around the values if we go past the start or end |
| 242 | if (current > mEnd) { |
| 243 | current = mStart; |
| 244 | } else if (current < mStart) { |
| 245 | current = mEnd; |
| 246 | } |
| 247 | mPrevious = mCurrent; |
| 248 | mCurrent = current; |
| 249 | |
| 250 | notifyChange(); |
| 251 | updateView(); |
| 252 | } |
| 253 | |
| 254 | protected void notifyChange() { |
| 255 | if (mListener != null) { |
| 256 | mListener.onChanged(this, mPrevious, mCurrent); |
| 257 | } |
| 258 | } |
| 259 | |
| 260 | protected void updateView() { |
| 261 | |
| 262 | /* If we don't have displayed values then use the |
| 263 | * current number else find the correct value in the |
| 264 | * displayed values for the current number. |
| 265 | */ |
| 266 | if (mDisplayedValues == null) { |
| 267 | mText.setText(formatNumber(mCurrent)); |
| 268 | } else { |
| 269 | mText.setText(mDisplayedValues[mCurrent - mStart]); |
| 270 | } |
| 271 | mText.setSelection(mText.getText().length()); |
| 272 | } |
| 273 | |
| 274 | private void validateCurrentView(CharSequence str) { |
| 275 | int val = getSelectedPos(str.toString()); |
| 276 | if ((val >= mStart) && (val <= mEnd)) { |
| 277 | if (mCurrent != val) { |
| 278 | mPrevious = mCurrent; |
| 279 | mCurrent = val; |
| 280 | notifyChange(); |
| 281 | } |
| 282 | } |
| 283 | updateView(); |
| 284 | } |
| 285 | |
| 286 | public void onFocusChange(View v, boolean hasFocus) { |
| 287 | |
| 288 | /* When focus is lost check that the text field |
| 289 | * has valid values. |
| 290 | */ |
| 291 | if (!hasFocus) { |
| 292 | validateInput(v); |
| 293 | } |
| 294 | } |
| 295 | |
| 296 | private void validateInput(View v) { |
| 297 | String str = String.valueOf(((TextView) v).getText()); |
| 298 | if(str.length() == 2 && mFormatter == THREE_DIGIT_FORMATTER) |
| 299 | str = str + "0"; |
| 300 | else if(str.length() == 1 && mFormatter == THREE_DIGIT_FORMATTER) |
| 301 | str = str + "00"; |
| 302 | |
| 303 | if ("".equals(str)) { |
| 304 | |
| 305 | // Restore to the old value as we don't allow empty values |
| 306 | updateView(); |
| 307 | } else { |
| 308 | |
| 309 | // Check the new value and ensure it's in range |
| 310 | validateCurrentView(str); |
| 311 | } |
| 312 | } |
| 313 | |
| 314 | /** |
| 315 | * We start the long click here but rely on the {@link NumberPickerButton} |
| 316 | * to inform us when the long click has ended. |
| 317 | */ |
| 318 | public boolean onLongClick(View v) { |
| 319 | |
| 320 | /* The text view may still have focus so clear it's focus which will |
| 321 | * trigger the on focus changed and any typed values to be pulled. |
| 322 | */ |
| 323 | mText.clearFocus(); |
| 324 | |
| 325 | if (R.id.increment == v.getId()) { |
| 326 | mIncrement = true; |
| 327 | mHandler.post(mRunnable); |
| 328 | } else if (R.id.decrement == v.getId()) { |
| 329 | mDecrement = true; |
| 330 | mHandler.post(mRunnable); |
| 331 | } |
| 332 | return true; |
| 333 | } |
| 334 | |
| 335 | public void cancelIncrement() { |
| 336 | mIncrement = false; |
| 337 | } |
| 338 | |
| 339 | public void cancelDecrement() { |
| 340 | mDecrement = false; |
| 341 | } |
| 342 | |
| 343 | private static final char[] DIGIT_CHARACTERS = new char[] { |
| 344 | '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' |
| 345 | }; |
| 346 | |
| 347 | private NumberPickerButton mIncrementButton; |
| 348 | private NumberPickerButton mDecrementButton; |
| 349 | |
| 350 | private class NumberPickerInputFilter implements InputFilter { |
| 351 | public CharSequence filter(CharSequence source, int start, int end, |
| 352 | Spanned dest, int dstart, int dend) { |
| 353 | if (mDisplayedValues == null) { |
| 354 | return mNumberInputFilter.filter(source, start, end, dest, dstart, dend); |
| 355 | } |
| 356 | CharSequence filtered = String.valueOf(source.subSequence(start, end)); |
| 357 | String result = String.valueOf(dest.subSequence(0, dstart)) |
| 358 | + filtered |
| 359 | + dest.subSequence(dend, dest.length()); |
| 360 | String str = String.valueOf(result).toLowerCase(); |
| 361 | for (String val : mDisplayedValues) { |
| 362 | val = val.toLowerCase(); |
| 363 | if (val.startsWith(str)) { |
| 364 | return filtered; |
| 365 | } |
| 366 | } |
| 367 | return ""; |
| 368 | } |
| 369 | } |
| 370 | |
| 371 | private class NumberRangeKeyListener extends NumberKeyListener { |
| 372 | |
| 373 | // XXX This doesn't allow for range limits when controlled by a |
| 374 | // soft input method! |
| 375 | public int getInputType() { |
| 376 | return InputType.TYPE_CLASS_NUMBER; |
| 377 | } |
| 378 | |
| 379 | @Override |
| 380 | protected char[] getAcceptedChars() { |
| 381 | return DIGIT_CHARACTERS; |
| 382 | } |
| 383 | |
| 384 | @Override |
| 385 | public CharSequence filter(CharSequence source, int start, int end, |
| 386 | Spanned dest, int dstart, int dend) { |
| 387 | |
| 388 | CharSequence filtered = super.filter(source, start, end, dest, dstart, dend); |
| 389 | if (filtered == null) { |
| 390 | filtered = source.subSequence(start, end); |
| 391 | } |
| 392 | |
| 393 | String result = String.valueOf(dest.subSequence(0, dstart)) |
| 394 | + filtered |
| 395 | + dest.subSequence(dend, dest.length()); |
| 396 | |
| 397 | if ("".equals(result)) { |
| 398 | return result; |
| 399 | } |
| 400 | int val = getSelectedPos(result); |
| 401 | |
| 402 | /* Ensure the user can't type in a value greater |
| 403 | * than the max allowed. We have to allow less than min |
| 404 | * as the user might want to delete some numbers |
| 405 | * and then type a new number. |
| 406 | */ |
| 407 | if (val > mEnd) { |
| 408 | return ""; |
| 409 | } else { |
| 410 | return filtered; |
| 411 | } |
| 412 | } |
| 413 | } |
| 414 | |
| 415 | private int getSelectedPos(String str) { |
| 416 | if (mDisplayedValues == null) { |
| 417 | return Integer.parseInt(str); |
| 418 | } else { |
| 419 | for (int i = 0; i < mDisplayedValues.length; i++) { |
| 420 | |
| 421 | /* Don't force the user to type in jan when ja will do */ |
| 422 | str = str.toLowerCase(); |
| 423 | if (mDisplayedValues[i].toLowerCase().startsWith(str)) { |
| 424 | return mStart + i; |
| 425 | } |
| 426 | } |
| 427 | |
| 428 | /* The user might have typed in a number into the month field i.e. |
| 429 | * 10 instead of OCT so support that too. |
| 430 | */ |
| 431 | try { |
| 432 | return Integer.parseInt(str); |
| 433 | } catch (NumberFormatException e) { |
| 434 | |
| 435 | /* Ignore as if it's not a number we don't care */ |
| 436 | } |
| 437 | } |
| 438 | return mStart; |
| 439 | } |
| 440 | |
| 441 | /** |
| 442 | * @return the current value. |
| 443 | */ |
| 444 | public int getCurrent() { |
| 445 | return mCurrent; |
| 446 | } |
| 447 | |
| 448 | public String getCurrentFormatted() { |
| 449 | return formatNumber(mCurrent); |
| 450 | } |
| 451 | } |