CountingTimerView.java revision d27c983901df08a8ef3b839fe6d262073e50fc32
1/*
2 * Copyright (C) 2012 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.android.deskclock.timer;
18
19import android.content.Context;
20import android.content.res.Resources;
21import android.graphics.Canvas;
22import android.graphics.Paint;
23import android.graphics.Typeface;
24import android.text.TextUtils;
25import android.util.AttributeSet;
26import android.view.MotionEvent;
27import android.view.View;
28import android.view.accessibility.AccessibilityManager;
29import android.widget.TextView;
30
31import com.android.deskclock.Log;
32import com.android.deskclock.R;
33import com.android.deskclock.Utils;
34
35
36/**
37 * Class to measure and draw the time in the {@link com.android.deskclock.CircleTimerView}.
38 * This class manages and sums the work of the four members mBigHours, mBigMinutes,
39 * mBigSeconds and mMedHundredths. Those members are each tasked with measuring, sizing and
40 * drawing digits (and optional label) of the time set in {@link #setTime(long, boolean, boolean)}
41 */
42public class CountingTimerView extends View {
43    private static final String TWO_DIGITS = "%02d";
44    private static final String ONE_DIGIT = "%01d";
45    private static final String NEG_TWO_DIGITS = "-%02d";
46    private static final String NEG_ONE_DIGIT = "-%01d";
47    private static final float TEXT_SIZE_TO_WIDTH_RATIO = 0.75f;
48    // This is the ratio of the font height needed to vertically offset the font for alignment
49    // from the center.
50    private static final float FONT_VERTICAL_OFFSET = 0.14f;
51    // Ratio of the space trailing the Hours and Minutes
52    private static final float HOURS_MINUTES_SPACING = 0.5f;
53    // Ratio of the space leading the Hundredths
54    private static final float HUNDREDTHS_SPACING = 0.5f;
55
56    private String mHours, mMinutes, mSeconds, mHundredths;
57
58    private boolean mShowTimeStr = true;
59    private final Paint mPaintBigThin = new Paint();
60    private final Paint mPaintMed = new Paint();
61    private final Paint mPaintLabel = new Paint();
62    private final float mBigFontSize, mSmallFontSize;
63    // Hours and minutes are signed for when a timer goes past the set time and thus negative
64    private final SignedTime mBigHours, mBigMinutes;
65    // Seconds are always shown with minutes, so are never signed
66    private final UnsignedTime mBigSeconds;
67    private final Hundredths mMedHundredths;
68    private float mTextHeight = 0;
69    private float mTotalTextWidth;
70    private boolean mRemeasureText = true;
71
72    private int mDefaultColor;
73    private final int mPressedColor;
74    private final int mWhiteColor;
75    private final int mRedColor;
76    private TextView mStopStartTextView;
77    private final AccessibilityManager mAccessibilityManager;
78
79    // Fields for the text serving as a virtual button.
80    private boolean mVirtualButtonEnabled = false;
81    private boolean mVirtualButtonPressedOn = false;
82
83    Runnable mBlinkThread = new Runnable() {
84        private boolean mVisible = true;
85        @Override
86        public void run() {
87            mVisible = !mVisible;
88            CountingTimerView.this.showTime(mVisible);
89            postDelayed(mBlinkThread, 500);
90        }
91
92    };
93
94    /**
95     * Class to measure and draw the digit pairs of hours, minutes, seconds or hundredths. Digits
96     * may have an optional label. for hours, minutes and seconds, this label trails the digits
97     * and for seconds, precedes the digits.
98     */
99    static class UnsignedTime {
100        protected Paint mPaint;
101        protected float mEm;
102        protected float mWidth = 0;
103        private final String mWidest;
104        protected final float mSpacingRatio;
105        private float mLabelWidth = 0;
106
107        public UnsignedTime(Paint paint, float spacingRatio, String allDigits) {
108            mPaint = paint;
109            mSpacingRatio = spacingRatio;
110
111            if (TextUtils.isEmpty(allDigits)) {
112                Log.wtf("Locale digits missing - using English");
113                allDigits = "0123456789";
114            }
115
116            float widths[] = new float[allDigits.length()];
117            int ll = mPaint.getTextWidths(allDigits, widths);
118            int largest = 0;
119            for (int ii = 1; ii < ll; ii++) {
120                if (widths[ii] > widths[largest]) {
121                    largest = ii;
122                }
123            }
124
125            mEm = widths[largest];
126            mWidest = allDigits.substring(largest, largest + 1);
127        }
128
129        public UnsignedTime(UnsignedTime unsignedTime, float spacingRatio) {
130            this.mPaint = unsignedTime.mPaint;
131            this.mEm = unsignedTime.mEm;
132            this.mWidth = unsignedTime.mWidth;
133            this.mWidest = unsignedTime.mWidest;
134            this.mSpacingRatio = spacingRatio;
135        }
136
137        protected void updateWidth(final String time) {
138            mEm = mPaint.measureText(mWidest);
139            mLabelWidth = mSpacingRatio * mEm;
140            mWidth = time.length() * mEm;
141        }
142
143        protected void resetWidth() {
144            mWidth = mLabelWidth = 0;
145        }
146
147        public float calcTotalWidth(final String time) {
148            if (time != null) {
149                updateWidth(time);
150                return mWidth + mLabelWidth;
151            } else {
152                resetWidth();
153                return 0;
154            }
155        }
156
157        public float getLabelWidth() {
158            return mLabelWidth;
159        }
160
161        /**
162         * Draws each character with a fixed spacing from time starting at ii.
163         * @param canvas the canvas on which the time segment will be drawn
164         * @param time time segment
165         * @param ii what character to start the draw
166         * @param x offset
167         * @param y offset
168         * @return X location for the next segment
169         */
170        protected float drawTime(Canvas canvas, final String time, int ii, float x, float y) {
171            float textEm  = mEm / 2f;
172            while (ii < time.length()) {
173                x += textEm;
174                canvas.drawText(time.substring(ii, ii + 1), x, y, mPaint);
175                x += textEm;
176                ii++;
177            }
178            return x;
179        }
180
181        /**
182         * Draw this time segment and append the intra-segment spacing to the x
183         * @param canvas the canvas on which the time segment will be drawn
184         * @param time time segment
185         * @param x offset
186         * @param y offset
187         * @return X location for the next segment
188         */
189        public float draw(Canvas canvas, final String time, float x, float y) {
190            return drawTime(canvas, time, 0, x, y) + getLabelWidth();
191        }
192    }
193
194    /**
195     * Special derivation to handle the hundredths painting with the label in front.
196     */
197    static class Hundredths extends UnsignedTime {
198        public Hundredths(Paint paint, float spacingRatio, final String allDigits) {
199            super(paint, spacingRatio, allDigits);
200        }
201
202        /**
203         * Draw this time segment after prepending the intra-segment spacing to the x location.
204         * {@link UnsignedTime#draw(android.graphics.Canvas, String, float, float)}
205         */
206        @Override
207        public float draw(Canvas canvas, final String time, float x, float y) {
208            return drawTime(canvas, time, 0, x + getLabelWidth(), y);
209        }
210    }
211
212    /**
213     * Special derivation to handle a negative number
214     */
215    static class SignedTime extends UnsignedTime {
216        private float mMinusWidth = 0;
217
218        public SignedTime (UnsignedTime unsignedTime, float spacingRatio) {
219            super(unsignedTime, spacingRatio);
220        }
221
222        @Override
223        protected void updateWidth(final String time) {
224            super.updateWidth(time);
225            if (time.contains("-")) {
226                mMinusWidth = mPaint.measureText("-");
227                mWidth += (mMinusWidth - mEm);
228            } else {
229                mMinusWidth = 0;
230            }
231        }
232
233        @Override
234        protected void resetWidth() {
235            super.resetWidth();
236            mMinusWidth = 0;
237        }
238
239        /**
240         * Draws each character with a fixed spacing from time, handling the special negative
241         * number case.
242         * {@link UnsignedTime#draw(android.graphics.Canvas, String, float, float)}
243         */
244        @Override
245        public float draw(Canvas canvas, final String time, float x, float y) {
246            int ii = 0;
247            if (mMinusWidth != 0f) {
248                float minusWidth = mMinusWidth / 2;
249                x += minusWidth;
250                //TODO:hyphen is too thick when painted
251                canvas.drawText(time.substring(0, 1), x, y, mPaint);
252                x += minusWidth;
253                ii++;
254            }
255            return drawTime(canvas, time, ii, x, y) + getLabelWidth();
256        }
257    }
258
259    @SuppressWarnings("unused")
260    public CountingTimerView(Context context) {
261        this(context, null);
262    }
263
264    public CountingTimerView(Context context, AttributeSet attrs) {
265        super(context, attrs);
266        mAccessibilityManager =
267                (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE);
268        Resources r = context.getResources();
269        mWhiteColor = r.getColor(R.color.clock_white);
270        mDefaultColor = mWhiteColor;
271        mPressedColor = r.getColor(Utils.getPressedColorId());
272        mRedColor = r.getColor(R.color.clock_red);
273        mBigFontSize = r.getDimension(R.dimen.big_font_size);
274        mSmallFontSize = r.getDimension(R.dimen.small_font_size);
275
276        Typeface androidClockMonoThin = Typeface.
277                createFromAsset(context.getAssets(), "fonts/AndroidClockMono-Thin.ttf");
278        mPaintBigThin.setAntiAlias(true);
279        mPaintBigThin.setStyle(Paint.Style.STROKE);
280        mPaintBigThin.setTextAlign(Paint.Align.CENTER);
281        mPaintBigThin.setTypeface(androidClockMonoThin);
282
283        Typeface androidClockMonoLight = Typeface.
284                createFromAsset(context.getAssets(), "fonts/AndroidClockMono-Light.ttf");
285        mPaintMed.setAntiAlias(true);
286        mPaintMed.setStyle(Paint.Style.STROKE);
287        mPaintMed.setTextAlign(Paint.Align.CENTER);
288        mPaintMed.setTypeface(androidClockMonoLight);
289
290        Typeface robotoLabel = Typeface.create("sans-serif-condensed", Typeface.BOLD);
291        mPaintLabel.setAntiAlias(true);
292        mPaintLabel.setStyle(Paint.Style.STROKE);
293        mPaintLabel.setTextAlign(Paint.Align.LEFT);
294        mPaintLabel.setTypeface(robotoLabel);
295        mPaintLabel.setTextSize(r.getDimension(R.dimen.label_font_size));
296
297        resetTextSize();
298        setTextColor(mDefaultColor);
299
300        // allDigits will contain ten digits: "0123456789" in the default locale
301        final String allDigits = String.format("%010d", 123456789);
302        mBigSeconds = new UnsignedTime(mPaintBigThin, 0.f, allDigits);
303        mBigHours = new SignedTime(mBigSeconds, HOURS_MINUTES_SPACING);
304        mBigMinutes = new SignedTime(mBigSeconds, HOURS_MINUTES_SPACING);
305        mMedHundredths = new Hundredths(mPaintMed, HUNDREDTHS_SPACING, allDigits);
306    }
307
308    protected void resetTextSize() {
309        mTextHeight = mBigFontSize;
310        mPaintBigThin.setTextSize(mBigFontSize);
311        mPaintMed.setTextSize(mSmallFontSize);
312    }
313
314    protected void setTextColor(int textColor) {
315        mPaintBigThin.setColor(textColor);
316        mPaintMed.setColor(textColor);
317        mPaintLabel.setColor(textColor);
318    }
319
320    /**
321     * Update the time to display. Separates that time into the hours, minutes, seconds and
322     * hundredths. If update is true, the view is invalidated so that it will draw again.
323     *
324     * @param time new time to display - in milliseconds
325     * @param showHundredths flag to show hundredths resolution
326     * @param update to invalidate the view - otherwise the time is examined to see if it is within
327     *               100 milliseconds of zero seconds and when so, invalidate the view.
328     */
329    // TODO:showHundredths S/B attribute or setter - i.e. unchanging over object life
330    public void setTime(long time, boolean showHundredths, boolean update) {
331        int oldLength = getDigitsLength();
332        boolean neg = false, showNeg = false;
333        String format;
334        if (time < 0) {
335            time = -time;
336            neg = showNeg = true;
337        }
338        long hundreds, seconds, minutes, hours;
339        seconds = time / 1000;
340        hundreds = (time - seconds * 1000) / 10;
341        minutes = seconds / 60;
342        seconds = seconds - minutes * 60;
343        hours = minutes / 60;
344        minutes = minutes - hours * 60;
345        if (hours > 999) {
346            hours = 0;
347        }
348        // The time  can be between 0 and -1 seconds, but the "truncated" equivalent time of hours
349        // and minutes and seconds could be zero, so since we do not show fractions of seconds
350        // when counting down, do not show the minus sign.
351        // TODO:does it matter that we do not look at showHundredths?
352        if (hours == 0 && minutes == 0 && seconds == 0) {
353            showNeg = false;
354        }
355
356        // Normalize and check if it is 'time' to invalidate
357        if (!showHundredths) {
358            if (!neg && hundreds != 0) {
359                seconds++;
360                if (seconds == 60) {
361                    seconds = 0;
362                    minutes++;
363                    if (minutes == 60) {
364                        minutes = 0;
365                        hours++;
366                    }
367                }
368            }
369            if (hundreds < 10 || hundreds > 90) {
370                update = true;
371            }
372        }
373
374        // Hours may be empty
375        if (hours >= 10) {
376            format = showNeg ? NEG_TWO_DIGITS : TWO_DIGITS;
377            mHours = String.format(format, hours);
378        } else if (hours > 0) {
379            format = showNeg ? NEG_ONE_DIGIT : ONE_DIGIT;
380            mHours = String.format(format, hours);
381        } else {
382            mHours = null;
383        }
384
385        // Minutes are never empty and when hours are non-empty, must be two digits
386        if (minutes >= 10 || hours > 0) {
387            format = (showNeg && hours == 0) ? NEG_TWO_DIGITS : TWO_DIGITS;
388            mMinutes = String.format(format, minutes);
389        } else {
390            format = (showNeg && hours == 0) ? NEG_ONE_DIGIT : ONE_DIGIT;
391            mMinutes = String.format(format, minutes);
392        }
393
394        // Seconds are always two digits
395        mSeconds = String.format(TWO_DIGITS, seconds);
396
397        // Hundredths are optional and then two digits
398        if (showHundredths) {
399            mHundredths = String.format(TWO_DIGITS, hundreds);
400        } else {
401            mHundredths = null;
402        }
403
404        int newLength = getDigitsLength();
405        if (oldLength != newLength) {
406            if (oldLength > newLength) {
407                resetTextSize();
408            }
409            mRemeasureText = true;
410        }
411
412        if (update) {
413            setContentDescription(getTimeStringForAccessibility((int) hours, (int) minutes,
414                    (int) seconds, showNeg, getResources()));
415            invalidate();
416        }
417    }
418
419    private int getDigitsLength() {
420        return ((mHours == null) ? 0 : mHours.length())
421                + ((mMinutes == null) ? 0 : mMinutes.length())
422                + ((mSeconds == null) ? 0 : mSeconds.length())
423                + ((mHundredths == null) ? 0 : mHundredths.length());
424    }
425
426    private void calcTotalTextWidth() {
427        mTotalTextWidth = mBigHours.calcTotalWidth(mHours) + mBigMinutes.calcTotalWidth(mMinutes)
428                + mBigSeconds.calcTotalWidth(mSeconds)
429                + mMedHundredths.calcTotalWidth(mHundredths);
430    }
431
432    private void setTotalTextWidth() {
433        calcTotalTextWidth();
434        // To determine the maximum width, we find the minimum of the height and width (since the
435        // circle we are trying to fit the text into has its radius sized to the smaller of the
436        // two.
437        int width = Math.min(getWidth(), getHeight());
438        if (width != 0) {
439            float wantWidth = (int)(TEXT_SIZE_TO_WIDTH_RATIO * width);
440            // If the text is too wide, reduce all the paint text sizes
441            while (mTotalTextWidth > wantWidth) {
442                // Variant-section reduction
443                float sizeRatio = wantWidth / mTotalTextWidth;
444                mPaintBigThin.setTextSize(mPaintBigThin.getTextSize() * sizeRatio);
445                mPaintMed.setTextSize(mPaintMed.getTextSize() * sizeRatio);
446                // Recalculate the new total text height and half-width
447                mTextHeight = mPaintBigThin.getTextSize();
448                calcTotalTextWidth();
449            }
450        }
451    }
452
453    public void blinkTimeStr(boolean blink) {
454        if (blink) {
455            removeCallbacks(mBlinkThread);
456            post(mBlinkThread);
457        } else {
458            removeCallbacks(mBlinkThread);
459            showTime(true);
460        }
461    }
462
463    public void showTime(boolean visible) {
464        mShowTimeStr = visible;
465        invalidate();
466    }
467
468    public void redTimeStr(boolean red, boolean forceUpdate) {
469        mDefaultColor = red ? mRedColor : mWhiteColor;
470        setTextColor(mDefaultColor);
471        if (forceUpdate) {
472            invalidate();
473        }
474    }
475
476    public String getTimeString() {
477        // Though only called from Stopwatch Share, so hundredth are never null,
478        // protect the future and check for null mHundredths
479        if (mHundredths == null) {
480            if (mHours == null) {
481                return String.format("%s:%s", mMinutes, mSeconds);
482            }
483            return String.format("%s:%s:%s", mHours, mMinutes, mSeconds);
484        } else if (mHours == null) {
485            return String.format("%s:%s.%s", mMinutes, mSeconds, mHundredths);
486        }
487        return String.format("%s:%s:%s.%s", mHours, mMinutes, mSeconds, mHundredths);
488    }
489
490    private static String getTimeStringForAccessibility(int hours, int minutes, int seconds,
491            boolean showNeg, Resources r) {
492        StringBuilder s = new StringBuilder();
493        if (showNeg) {
494            // This must be followed by a non-zero number or it will be audible as "hyphen"
495            // instead of "minus".
496            s.append("-");
497        }
498        if (showNeg && hours == 0 && minutes == 0) {
499            // Non-negative time will always have minutes, eg. "0 minutes 7 seconds", but negative
500            // time must start with non-zero digit, eg. -0m7s will be audible as just "-7 seconds"
501            s.append(String.format(
502                    r.getQuantityText(R.plurals.Nseconds_description, seconds).toString(),
503                    seconds));
504        } else if (hours == 0) {
505            s.append(String.format(
506                    r.getQuantityText(R.plurals.Nminutes_description, minutes).toString(),
507                    minutes));
508            s.append(" ");
509            s.append(String.format(
510                    r.getQuantityText(R.plurals.Nseconds_description, seconds).toString(),
511                    seconds));
512        } else {
513            s.append(String.format(
514                    r.getQuantityText(R.plurals.Nhours_description, hours).toString(),
515                    hours));
516            s.append(" ");
517            s.append(String.format(
518                    r.getQuantityText(R.plurals.Nminutes_description, minutes).toString(),
519                    minutes));
520            s.append(" ");
521            s.append(String.format(
522                    r.getQuantityText(R.plurals.Nseconds_description, seconds).toString(),
523                    seconds));
524        }
525        return s.toString();
526    }
527
528    public void setVirtualButtonEnabled(boolean enabled) {
529        mVirtualButtonEnabled = enabled;
530    }
531
532    private void virtualButtonPressed(boolean pressedOn) {
533        mVirtualButtonPressedOn = pressedOn;
534        mStopStartTextView.setTextColor(pressedOn ? mPressedColor : mWhiteColor);
535        invalidate();
536    }
537
538    private boolean withinVirtualButtonBounds(float x, float y) {
539        int width = getWidth();
540        int height = getHeight();
541        float centerX = width / 2;
542        float centerY = height / 2;
543        float radius = Math.min(width, height) / 2;
544
545        // Within the circle button if distance to the center is less than the radius.
546        double distance = Math.sqrt(Math.pow(centerX - x, 2) + Math.pow(centerY - y, 2));
547        return distance < radius;
548    }
549
550    public void registerVirtualButtonAction(final Runnable runnable) {
551        if (!mAccessibilityManager.isEnabled()) {
552            this.setOnTouchListener(new OnTouchListener() {
553                @Override
554                public boolean onTouch(View v, MotionEvent event) {
555                    if (mVirtualButtonEnabled) {
556                        switch (event.getAction()) {
557                            case MotionEvent.ACTION_DOWN:
558                                if (withinVirtualButtonBounds(event.getX(), event.getY())) {
559                                    virtualButtonPressed(true);
560                                    return true;
561                                } else {
562                                    virtualButtonPressed(false);
563                                    return false;
564                                }
565                            case MotionEvent.ACTION_CANCEL:
566                                virtualButtonPressed(false);
567                                return true;
568                            case MotionEvent.ACTION_OUTSIDE:
569                                virtualButtonPressed(false);
570                                return false;
571                            case MotionEvent.ACTION_UP:
572                                virtualButtonPressed(false);
573                                if (withinVirtualButtonBounds(event.getX(), event.getY())) {
574                                    runnable.run();
575                                }
576                                return true;
577                        }
578                    }
579                    return false;
580                }
581            });
582        } else {
583            this.setOnClickListener(new OnClickListener() {
584                @Override
585                public void onClick(View v) {
586                    runnable.run();
587                }
588            });
589        }
590    }
591
592    @Override
593    public void onDraw(Canvas canvas) {
594        // Blink functionality.
595        if (!mShowTimeStr && !mVirtualButtonPressedOn) {
596            return;
597        }
598
599        int width = getWidth();
600        if (mRemeasureText && width != 0) {
601            setTotalTextWidth();
602            width = getWidth();
603            mRemeasureText = false;
604        }
605
606        int xCenter = width / 2;
607        int yCenter = getHeight() / 2;
608
609        float xTextStart = xCenter - mTotalTextWidth / 2;
610        float yTextStart = yCenter + mTextHeight/2 - (mTextHeight * FONT_VERTICAL_OFFSET);
611
612        // Text color differs based on pressed state.
613        int textColor;
614        if (mVirtualButtonPressedOn) {
615            textColor = mPressedColor;
616            mStopStartTextView.setTextColor(mPressedColor);
617        } else {
618            textColor = mDefaultColor;
619        }
620        mPaintBigThin.setColor(textColor);
621        mPaintLabel.setColor(textColor);
622        mPaintMed.setColor(textColor);
623
624        if (mHours != null) {
625            xTextStart = mBigHours.draw(canvas, mHours, xTextStart, yTextStart);
626        }
627        if (mMinutes != null) {
628            xTextStart = mBigMinutes.draw(canvas, mMinutes, xTextStart, yTextStart);
629        }
630        if (mSeconds != null) {
631            xTextStart = mBigSeconds.draw(canvas, mSeconds, xTextStart, yTextStart);
632        }
633        if (mHundredths != null) {
634            mMedHundredths.draw(canvas, mHundredths, xTextStart, yTextStart);
635        }
636    }
637
638    public void registerStopTextView(TextView stopStartTextView) {
639        mStopStartTextView = stopStartTextView;
640    }
641}
642