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