CountingTimerView.java revision 69ca5f1f1e76a2e6530a5ef6d410ab8048023867
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.Color;
23import android.graphics.Paint;
24import android.graphics.Typeface;
25import android.text.TextUtils;
26import android.util.AttributeSet;
27import android.view.MotionEvent;
28import android.view.View;
29import android.view.accessibility.AccessibilityManager;
30
31import com.android.deskclock.LogUtils;
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.85f;
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.4f;
53    // Ratio of the space leading the Hundredths
54    private static final float HUNDREDTHS_SPACING = 0.5f;
55    // Radial offset of the enclosing circle
56    private final float mRadiusOffset;
57
58    private String mHours, mMinutes, mSeconds, mHundredths;
59
60    private boolean mShowTimeStr = true;
61    private final Paint mPaintBigThin = new Paint();
62    private final Paint mPaintMed = new Paint();
63    private final float mBigFontSize, mSmallFontSize;
64    // Hours and minutes are signed for when a timer goes past the set time and thus negative
65    private final SignedTime mBigHours, mBigMinutes;
66    // Seconds are always shown with minutes, so are never signed
67    private final UnsignedTime mBigSeconds;
68    private final Hundredths mMedHundredths;
69    private float mTextHeight = 0;
70    private float mTotalTextWidth;
71    private boolean mRemeasureText = true;
72
73    private int mDefaultColor;
74    private final int mPressedColor;
75    private final int mWhiteColor;
76    private final int mAccentColor;
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                LogUtils.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        mDefaultColor = mWhiteColor = r.getColor(R.color.clock_white);
270        mPressedColor = mAccentColor = Utils.obtainStyledColor(
271                context, R.attr.colorAccent, Color.RED);
272        mBigFontSize = r.getDimension(R.dimen.big_font_size);
273        mSmallFontSize = r.getDimension(R.dimen.small_font_size);
274
275        Typeface androidClockMonoThin = Typeface.
276                createFromAsset(context.getAssets(), "fonts/AndroidClockMono-Thin.ttf");
277        mPaintBigThin.setAntiAlias(true);
278        mPaintBigThin.setStyle(Paint.Style.STROKE);
279        mPaintBigThin.setTextAlign(Paint.Align.CENTER);
280        mPaintBigThin.setTypeface(androidClockMonoThin);
281
282        Typeface androidClockMonoLight = Typeface.
283                createFromAsset(context.getAssets(), "fonts/AndroidClockMono-Light.ttf");
284        mPaintMed.setAntiAlias(true);
285        mPaintMed.setStyle(Paint.Style.STROKE);
286        mPaintMed.setTextAlign(Paint.Align.CENTER);
287        mPaintMed.setTypeface(androidClockMonoLight);
288
289        resetTextSize();
290        setTextColor(mDefaultColor);
291
292        // allDigits will contain ten digits: "0123456789" in the default locale
293        final String allDigits = String.format("%010d", 123456789);
294        mBigSeconds = new UnsignedTime(mPaintBigThin, 0.f, allDigits);
295        mBigHours = new SignedTime(mBigSeconds, HOURS_MINUTES_SPACING);
296        mBigMinutes = new SignedTime(mBigSeconds, HOURS_MINUTES_SPACING);
297        mMedHundredths = new Hundredths(mPaintMed, HUNDREDTHS_SPACING, allDigits);
298
299        mRadiusOffset = Utils.calculateRadiusOffset(r);
300    }
301
302    protected void resetTextSize() {
303        mTextHeight = mBigFontSize;
304        mPaintBigThin.setTextSize(mBigFontSize);
305        mPaintMed.setTextSize(mSmallFontSize);
306    }
307
308    protected void setTextColor(int textColor) {
309        mPaintBigThin.setColor(textColor);
310        mPaintMed.setColor(textColor);
311    }
312
313    /**
314     * Update the time to display. Separates that time into the hours, minutes, seconds and
315     * hundredths. If update is true, the view is invalidated so that it will draw again.
316     *
317     * @param time new time to display - in milliseconds
318     * @param showHundredths flag to show hundredths resolution
319     * @param update to invalidate the view - otherwise the time is examined to see if it is within
320     *               100 milliseconds of zero seconds and when so, invalidate the view.
321     */
322    // TODO:showHundredths S/B attribute or setter - i.e. unchanging over object life
323    public void setTime(long time, boolean showHundredths, boolean update) {
324        int oldLength = getDigitsLength();
325        boolean neg = false, showNeg = false;
326        String format;
327        if (time < 0) {
328            time = -time;
329            neg = showNeg = true;
330        }
331        long hundreds, seconds, minutes, hours;
332        seconds = time / 1000;
333        hundreds = (time - seconds * 1000) / 10;
334        minutes = seconds / 60;
335        seconds = seconds - minutes * 60;
336        hours = minutes / 60;
337        minutes = minutes - hours * 60;
338        if (hours > 999) {
339            hours = 0;
340        }
341        // The time  can be between 0 and -1 seconds, but the "truncated" equivalent time of hours
342        // and minutes and seconds could be zero, so since we do not show fractions of seconds
343        // when counting down, do not show the minus sign.
344        // TODO:does it matter that we do not look at showHundredths?
345        if (hours == 0 && minutes == 0 && seconds == 0) {
346            showNeg = false;
347        }
348
349        // Normalize and check if it is 'time' to invalidate
350        if (!showHundredths) {
351            if (!neg && hundreds != 0) {
352                seconds++;
353                if (seconds == 60) {
354                    seconds = 0;
355                    minutes++;
356                    if (minutes == 60) {
357                        minutes = 0;
358                        hours++;
359                    }
360                }
361            }
362            if (hundreds < 10 || hundreds > 90) {
363                update = true;
364            }
365        }
366
367        // Hours may be empty
368        if (hours >= 10) {
369            format = showNeg ? NEG_TWO_DIGITS : TWO_DIGITS;
370            mHours = String.format(format, hours);
371        } else if (hours > 0) {
372            format = showNeg ? NEG_ONE_DIGIT : ONE_DIGIT;
373            mHours = String.format(format, hours);
374        } else {
375            mHours = null;
376        }
377
378        // Minutes are never empty and when hours are non-empty, must be two digits
379        if (minutes >= 10 || hours > 0) {
380            format = (showNeg && hours == 0) ? NEG_TWO_DIGITS : TWO_DIGITS;
381            mMinutes = String.format(format, minutes);
382        } else {
383            format = (showNeg && hours == 0) ? NEG_ONE_DIGIT : ONE_DIGIT;
384            mMinutes = String.format(format, minutes);
385        }
386
387        // Seconds are always two digits
388        mSeconds = String.format(TWO_DIGITS, seconds);
389
390        // Hundredths are optional and then two digits
391        if (showHundredths) {
392            mHundredths = String.format(TWO_DIGITS, hundreds);
393        } else {
394            mHundredths = null;
395        }
396
397        int newLength = getDigitsLength();
398        if (oldLength != newLength) {
399            if (oldLength > newLength) {
400                resetTextSize();
401            }
402            mRemeasureText = true;
403        }
404
405        if (update) {
406            setContentDescription(getTimeStringForAccessibility((int) hours, (int) minutes,
407                    (int) seconds, showNeg, getResources()));
408            postInvalidateOnAnimation();
409        }
410    }
411
412    private int getDigitsLength() {
413        return ((mHours == null) ? 0 : mHours.length())
414                + ((mMinutes == null) ? 0 : mMinutes.length())
415                + ((mSeconds == null) ? 0 : mSeconds.length())
416                + ((mHundredths == null) ? 0 : mHundredths.length());
417    }
418
419    private void calcTotalTextWidth() {
420        mTotalTextWidth = mBigHours.calcTotalWidth(mHours) + mBigMinutes.calcTotalWidth(mMinutes)
421                + mBigSeconds.calcTotalWidth(mSeconds)
422                + mMedHundredths.calcTotalWidth(mHundredths);
423    }
424
425    /**
426     * Adjust the size of the fonts to fit within the the circle and painted object in
427     * {@link com.android.deskclock.CircleTimerView#onDraw(android.graphics.Canvas)}
428     */
429    private void setTotalTextWidth() {
430        calcTotalTextWidth();
431        // To determine the maximum width, we find the minimum of the height and width (since the
432        // circle we are trying to fit the text into has its radius sized to the smaller of the
433        // two.
434        int width = Math.min(getWidth(), getHeight());
435        if (width != 0) {
436            // Shrink 'width' to account for circle stroke and other painted objects.
437            // Note on the "4 *": (1) To reduce divisions, using the diameter instead of the radius.
438            // (2) The radius of the enclosing circle is reduced by mRadiusOffset and the
439            // text needs to fit within a circle further reduced by mRadiusOffset.
440            width -= (int) (4 * mRadiusOffset + 0.5f);
441
442            final float wantDiameter2 = TEXT_SIZE_TO_WIDTH_RATIO * width * width;
443            float totalDiameter2 = getHypotenuseSquared();
444
445            // If the hypotenuse of the bounding box is too large, reduce all the paint text sizes
446            while (totalDiameter2 > wantDiameter2) {
447                // Convergence is slightly difficult due to quantization in the mTotalTextWidth
448                // calculation. Reducing the ratio by 1% converges more quickly without excessive
449                // loss of quality.
450                float sizeRatio = 0.99f * (float) Math.sqrt(wantDiameter2/totalDiameter2);
451                mPaintBigThin.setTextSize(mPaintBigThin.getTextSize() * sizeRatio);
452                mPaintMed.setTextSize(mPaintMed.getTextSize() * sizeRatio);
453                // Recalculate the new total text height and half-width
454                mTextHeight = mPaintBigThin.getTextSize();
455                calcTotalTextWidth();
456                totalDiameter2 = getHypotenuseSquared();
457            }
458        }
459    }
460
461    /**
462     * Calculate the square of the diameter to use in {@link CountingTimerView#setTotalTextWidth()}
463     */
464    private float getHypotenuseSquared() {
465        return mTotalTextWidth * mTotalTextWidth + mTextHeight * mTextHeight;
466    }
467
468    public void blinkTimeStr(boolean blink) {
469        if (blink) {
470            removeCallbacks(mBlinkThread);
471            post(mBlinkThread);
472        } else {
473            removeCallbacks(mBlinkThread);
474            showTime(true);
475        }
476    }
477
478    public void showTime(boolean visible) {
479        mShowTimeStr = visible;
480        invalidate();
481    }
482
483    public void setTimeStrTextColor(boolean active, boolean forceUpdate) {
484        mDefaultColor = active ? mAccentColor : mWhiteColor;
485        setTextColor(mDefaultColor);
486        if (forceUpdate) {
487            invalidate();
488        }
489    }
490
491    public String getTimeString() {
492        // Though only called from Stopwatch Share, so hundredth are never null,
493        // protect the future and check for null mHundredths
494        if (mHundredths == null) {
495            if (mHours == null) {
496                return String.format("%s:%s", mMinutes, mSeconds);
497            }
498            return String.format("%s:%s:%s", mHours, mMinutes, mSeconds);
499        } else if (mHours == null) {
500            return String.format("%s:%s.%s", mMinutes, mSeconds, mHundredths);
501        }
502        return String.format("%s:%s:%s.%s", mHours, mMinutes, mSeconds, mHundredths);
503    }
504
505    private static String getTimeStringForAccessibility(int hours, int minutes, int seconds,
506            boolean showNeg, Resources r) {
507        StringBuilder s = new StringBuilder();
508        if (showNeg) {
509            // This must be followed by a non-zero number or it will be audible as "hyphen"
510            // instead of "minus".
511            s.append("-");
512        }
513        if (showNeg && hours == 0 && minutes == 0) {
514            // Non-negative time will always have minutes, eg. "0 minutes 7 seconds", but negative
515            // time must start with non-zero digit, eg. -0m7s will be audible as just "-7 seconds"
516            s.append(String.format(
517                    r.getQuantityText(R.plurals.Nseconds_description, seconds).toString(),
518                    seconds));
519        } else if (hours == 0) {
520            s.append(String.format(
521                    r.getQuantityText(R.plurals.Nminutes_description, minutes).toString(),
522                    minutes));
523            s.append(" ");
524            s.append(String.format(
525                    r.getQuantityText(R.plurals.Nseconds_description, seconds).toString(),
526                    seconds));
527        } else {
528            s.append(String.format(
529                    r.getQuantityText(R.plurals.Nhours_description, hours).toString(),
530                    hours));
531            s.append(" ");
532            s.append(String.format(
533                    r.getQuantityText(R.plurals.Nminutes_description, minutes).toString(),
534                    minutes));
535            s.append(" ");
536            s.append(String.format(
537                    r.getQuantityText(R.plurals.Nseconds_description, seconds).toString(),
538                    seconds));
539        }
540        return s.toString();
541    }
542
543    public void setVirtualButtonEnabled(boolean enabled) {
544        mVirtualButtonEnabled = enabled;
545    }
546
547    private void virtualButtonPressed(boolean pressedOn) {
548        mVirtualButtonPressedOn = pressedOn;
549        invalidate();
550    }
551
552    private boolean withinVirtualButtonBounds(float x, float y) {
553        int width = getWidth();
554        int height = getHeight();
555        float centerX = width / 2;
556        float centerY = height / 2;
557        float radius = Math.min(width, height) / 2;
558
559        // Within the circle button if distance to the center is less than the radius.
560        double distance = Math.sqrt(Math.pow(centerX - x, 2) + Math.pow(centerY - y, 2));
561        return distance < radius;
562    }
563
564    public void registerVirtualButtonAction(final Runnable runnable) {
565        if (!mAccessibilityManager.isEnabled()) {
566            this.setOnTouchListener(new OnTouchListener() {
567                @Override
568                public boolean onTouch(View v, MotionEvent event) {
569                    if (mVirtualButtonEnabled) {
570                        switch (event.getAction()) {
571                            case MotionEvent.ACTION_DOWN:
572                                if (withinVirtualButtonBounds(event.getX(), event.getY())) {
573                                    virtualButtonPressed(true);
574                                    return true;
575                                } else {
576                                    virtualButtonPressed(false);
577                                    return false;
578                                }
579                            case MotionEvent.ACTION_CANCEL:
580                                virtualButtonPressed(false);
581                                return true;
582                            case MotionEvent.ACTION_OUTSIDE:
583                                virtualButtonPressed(false);
584                                return false;
585                            case MotionEvent.ACTION_UP:
586                                virtualButtonPressed(false);
587                                if (withinVirtualButtonBounds(event.getX(), event.getY())) {
588                                    runnable.run();
589                                }
590                                return true;
591                        }
592                    }
593                    return false;
594                }
595            });
596        } else {
597            this.setOnClickListener(new OnClickListener() {
598                @Override
599                public void onClick(View v) {
600                    runnable.run();
601                }
602            });
603        }
604    }
605
606    @Override
607    public void onDraw(Canvas canvas) {
608        // Blink functionality.
609        if (!mShowTimeStr && !mVirtualButtonPressedOn) {
610            return;
611        }
612
613        int width = getWidth();
614        if (mRemeasureText && width != 0) {
615            setTotalTextWidth();
616            width = getWidth();
617            mRemeasureText = false;
618        }
619
620        int xCenter = width / 2;
621        int yCenter = getHeight() / 2;
622
623        float xTextStart = xCenter - mTotalTextWidth / 2;
624        float yTextStart = yCenter + mTextHeight/2 - (mTextHeight * FONT_VERTICAL_OFFSET);
625
626        // Text color differs based on pressed state.
627        final int textColor = mVirtualButtonPressedOn ? mPressedColor : mDefaultColor;
628        mPaintBigThin.setColor(textColor);
629        mPaintMed.setColor(textColor);
630
631        if (mHours != null) {
632            xTextStart = mBigHours.draw(canvas, mHours, xTextStart, yTextStart);
633        }
634        if (mMinutes != null) {
635            xTextStart = mBigMinutes.draw(canvas, mMinutes, xTextStart, yTextStart);
636        }
637        if (mSeconds != null) {
638            xTextStart = mBigSeconds.draw(canvas, mSeconds, xTextStart, yTextStart);
639        }
640        if (mHundredths != null) {
641            mMedHundredths.draw(canvas, mHundredths, xTextStart, yTextStart);
642        }
643    }
644
645    @Override
646    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
647        super.onSizeChanged(w, h, oldw, oldh);
648        mRemeasureText = true;
649        resetTextSize();
650    }
651}
652