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