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