1 /*
2  * Copyright (C) 2015 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 com.android.internal.widget;
18 
19 import android.compat.annotation.UnsupportedAppUsage;
20 import android.content.Context;
21 import android.graphics.Rect;
22 import android.util.AttributeSet;
23 import android.util.StateSet;
24 import android.view.KeyEvent;
25 import android.widget.TextView;
26 
27 /**
28  * Extension of TextView that can handle displaying and inputting a range of
29  * numbers.
30  * <p>
31  * Clients of this view should never call {@link #setText(CharSequence)} or
32  * {@link #setHint(CharSequence)} directly. Instead, they should call
33  * {@link #setValue(int)} to modify the currently displayed value.
34  */
35 public class NumericTextView extends TextView {
36     private static final int RADIX = 10;
37     private static final double LOG_RADIX = Math.log(RADIX);
38 
39     private int mMinValue = 0;
40     private int mMaxValue = 99;
41 
42     /** Number of digits in the maximum value. */
43     private int mMaxCount = 2;
44 
45     private boolean mShowLeadingZeroes = true;
46 
47     private int mValue;
48 
49     /** Number of digits entered during editing mode. */
50     private int mCount;
51 
52     /** Used to restore the value after an aborted edit. */
53     private int mPreviousValue;
54 
55     private OnValueChangedListener mListener;
56 
57     @UnsupportedAppUsage
NumericTextView(Context context, AttributeSet attrs)58     public NumericTextView(Context context, AttributeSet attrs) {
59         super(context, attrs);
60 
61         // Generate the hint text color based on disabled state.
62         final int textColorDisabled = getTextColors().getColorForState(StateSet.get(0), 0);
63         setHintTextColor(textColorDisabled);
64 
65         setFocusable(true);
66     }
67 
68     @Override
onFocusChanged(boolean focused, int direction, Rect previouslyFocusedRect)69     protected void onFocusChanged(boolean focused, int direction, Rect previouslyFocusedRect) {
70         super.onFocusChanged(focused, direction, previouslyFocusedRect);
71 
72         if (focused) {
73             mPreviousValue = mValue;
74             mValue = 0;
75             mCount = 0;
76 
77             // Transfer current text to hint.
78             setHint(getText());
79             setText("");
80         } else {
81             if (mCount == 0) {
82                 // No digits were entered, revert to previous value.
83                 mValue = mPreviousValue;
84 
85                 setText(getHint());
86                 setHint("");
87             }
88 
89             // Ensure the committed value is within range.
90             if (mValue < mMinValue) {
91                 mValue = mMinValue;
92             }
93 
94             setValue(mValue);
95 
96             if (mListener != null) {
97                 mListener.onValueChanged(this, mValue, true, true);
98             }
99         }
100     }
101 
102     /**
103      * Sets the currently displayed value.
104      * <p>
105      * The specified {@code value} must be within the range specified by
106      * {@link #setRange(int, int)} (e.g. between {@link #getRangeMinimum()}
107      * and {@link #getRangeMaximum()}).
108      *
109      * @param value the value to display
110      */
setValue(int value)111     public final void setValue(int value) {
112         if (mValue != value) {
113             mValue = value;
114 
115             updateDisplayedValue();
116         }
117     }
118 
119     /**
120      * Returns the currently displayed value.
121      * <p>
122      * If the value is currently being edited, returns the live value which may
123      * not be within the range specified by {@link #setRange(int, int)}.
124      *
125      * @return the currently displayed value
126      */
getValue()127     public final int getValue() {
128         return mValue;
129     }
130 
131     /**
132      * Sets the valid range (inclusive).
133      *
134      * @param minValue the minimum valid value (inclusive)
135      * @param maxValue the maximum valid value (inclusive)
136      */
setRange(int minValue, int maxValue)137     public final void setRange(int minValue, int maxValue) {
138         if (mMinValue != minValue) {
139             mMinValue = minValue;
140         }
141 
142         if (mMaxValue != maxValue) {
143             mMaxValue = maxValue;
144             mMaxCount = 1 + (int) (Math.log(maxValue) / LOG_RADIX);
145 
146             updateMinimumWidth();
147             updateDisplayedValue();
148         }
149     }
150 
151     /**
152      * @return the minimum value value (inclusive)
153      */
getRangeMinimum()154     public final int getRangeMinimum() {
155         return mMinValue;
156     }
157 
158     /**
159      * @return the maximum value value (inclusive)
160      */
getRangeMaximum()161     public final int getRangeMaximum() {
162         return mMaxValue;
163     }
164 
165     /**
166      * Sets whether this view shows leading zeroes.
167      * <p>
168      * When leading zeroes are shown, the displayed value will be padded
169      * with zeroes to the width of the maximum value as specified by
170      * {@link #setRange(int, int)} (see also {@link #getRangeMaximum()}.
171      * <p>
172      * For example, with leading zeroes shown, a maximum of 99 and value of
173      * 9 would display "09". A maximum of 100 and a value of 9 would display
174      * "009". With leading zeroes hidden, both cases would show "9".
175      *
176      * @param showLeadingZeroes {@code true} to show leading zeroes,
177      *                          {@code false} to hide them
178      */
setShowLeadingZeroes(boolean showLeadingZeroes)179     public final void setShowLeadingZeroes(boolean showLeadingZeroes) {
180         if (mShowLeadingZeroes != showLeadingZeroes) {
181             mShowLeadingZeroes = showLeadingZeroes;
182 
183             updateDisplayedValue();
184         }
185     }
186 
getShowLeadingZeroes()187     public final boolean getShowLeadingZeroes() {
188         return mShowLeadingZeroes;
189     }
190 
191     /**
192      * Computes the display value and updates the text of the view.
193      * <p>
194      * This method should be called whenever the current value or display
195      * properties (leading zeroes, max digits) change.
196      */
updateDisplayedValue()197     private void updateDisplayedValue() {
198         final String format;
199         if (mShowLeadingZeroes) {
200             format = "%0" + mMaxCount + "d";
201         } else {
202             format = "%d";
203         }
204 
205         // Always use String.format() rather than Integer.toString()
206         // to obtain correctly localized values.
207         setText(String.format(format, mValue));
208     }
209 
210     /**
211      * Computes the minimum width in pixels required to display all possible
212      * values and updates the minimum width of the view.
213      * <p>
214      * This method should be called whenever the maximum value changes.
215      */
updateMinimumWidth()216     private void updateMinimumWidth() {
217         final CharSequence previousText = getText();
218         int maxWidth = 0;
219 
220         for (int i = 0; i < mMaxValue; i++) {
221             setText(String.format("%0" + mMaxCount + "d", i));
222             measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
223 
224             final int width = getMeasuredWidth();
225             if (width > maxWidth) {
226                 maxWidth = width;
227             }
228         }
229 
230         setText(previousText);
231         setMinWidth(maxWidth);
232         setMinimumWidth(maxWidth);
233     }
234 
setOnDigitEnteredListener(OnValueChangedListener listener)235     public final void setOnDigitEnteredListener(OnValueChangedListener listener) {
236         mListener = listener;
237     }
238 
getOnDigitEnteredListener()239     public final OnValueChangedListener getOnDigitEnteredListener() {
240         return mListener;
241     }
242 
243     @Override
onKeyDown(int keyCode, KeyEvent event)244     public boolean onKeyDown(int keyCode, KeyEvent event) {
245         return isKeyCodeNumeric(keyCode)
246                 || (keyCode == KeyEvent.KEYCODE_DEL)
247                 || super.onKeyDown(keyCode, event);
248     }
249 
250     @Override
onKeyMultiple(int keyCode, int repeatCount, KeyEvent event)251     public boolean onKeyMultiple(int keyCode, int repeatCount, KeyEvent event) {
252         return isKeyCodeNumeric(keyCode)
253                 || (keyCode == KeyEvent.KEYCODE_DEL)
254                 || super.onKeyMultiple(keyCode, repeatCount, event);
255     }
256 
257     @Override
onKeyUp(int keyCode, KeyEvent event)258     public boolean onKeyUp(int keyCode, KeyEvent event) {
259         return handleKeyUp(keyCode)
260                 || super.onKeyUp(keyCode, event);
261     }
262 
handleKeyUp(int keyCode)263     private boolean handleKeyUp(int keyCode) {
264         if (keyCode == KeyEvent.KEYCODE_DEL) {
265             // Backspace removes the least-significant digit, if available.
266             if (mCount > 0) {
267                 mValue /= RADIX;
268                 mCount--;
269             }
270         } else if (isKeyCodeNumeric(keyCode)) {
271             if (mCount < mMaxCount) {
272                 final int keyValue = numericKeyCodeToInt(keyCode);
273                 final int newValue = mValue * RADIX + keyValue;
274                 if (newValue <= mMaxValue) {
275                     mValue = newValue;
276                     mCount++;
277                 }
278             }
279         } else {
280             return false;
281         }
282 
283         final String formattedValue;
284         if (mCount > 0) {
285             // If the user types 01, we should always show the leading 0 even if
286             // getShowLeadingZeroes() is false. Preserve typed leading zeroes by
287             // using the number of digits entered as the format width.
288             formattedValue = String.format("%0" + mCount + "d", mValue);
289         } else {
290             formattedValue = "";
291         }
292 
293         setText(formattedValue);
294 
295         if (mListener != null) {
296             final boolean isValid = mValue >= mMinValue;
297             final boolean isFinished = mCount >= mMaxCount || mValue * RADIX > mMaxValue;
298             mListener.onValueChanged(this, mValue, isValid, isFinished);
299         }
300 
301         return true;
302     }
303 
isKeyCodeNumeric(int keyCode)304     private static boolean isKeyCodeNumeric(int keyCode) {
305         return keyCode == KeyEvent.KEYCODE_0 || keyCode == KeyEvent.KEYCODE_1
306                 || keyCode == KeyEvent.KEYCODE_2 || keyCode == KeyEvent.KEYCODE_3
307                 || keyCode == KeyEvent.KEYCODE_4 || keyCode == KeyEvent.KEYCODE_5
308                 || keyCode == KeyEvent.KEYCODE_6 || keyCode == KeyEvent.KEYCODE_7
309                 || keyCode == KeyEvent.KEYCODE_8 || keyCode == KeyEvent.KEYCODE_9;
310     }
311 
numericKeyCodeToInt(int keyCode)312     private static int numericKeyCodeToInt(int keyCode) {
313         return keyCode - KeyEvent.KEYCODE_0;
314     }
315 
316     public interface OnValueChangedListener {
317         /**
318          * Called when the value displayed by {@code view} changes.
319          *
320          * @param view the view whose value changed
321          * @param value the new value
322          * @param isValid {@code true} if the value is valid (e.g. within the
323          *                range specified by {@link #setRange(int, int)}),
324          *                {@code false} otherwise
325          * @param isFinished {@code true} if the no more digits may be entered,
326          *                   {@code false} if more digits may be entered
327          */
onValueChanged(NumericTextView view, int value, boolean isValid, boolean isFinished)328         void onValueChanged(NumericTextView view, int value, boolean isValid, boolean isFinished);
329     }
330 }
331