com.stasbar.knowyourself.timer.CountingTimerView.java Source code

Java tutorial

Introduction

Here is the source code for com.stasbar.knowyourself.timer.CountingTimerView.java

Source

/*
 * Copyright (C) 2012 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.stasbar.knowyourself.timer;

import android.content.Context;
import android.content.res.Resources;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Typeface;
import android.support.annotation.PluralsRes;
import android.support.v4.content.ContextCompat;
import android.text.TextUtils;
import android.text.format.DateUtils;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.accessibility.AccessibilityManager;

import com.stasbar.knowyourself.LogUtils;
import com.stasbar.knowyourself.R;
import com.stasbar.knowyourself.Utils;
import com.stasbar.knowyourself.uidata.UiDataModel;

import java.util.Locale;

/**
 * Class to measure and draw the time in the CircleTimerView.
 * This class manages and sums the work of the four members mBigHours, mBigMinutes,
 * mBigSeconds and mMedHundredths. Those members are each tasked with measuring, sizing and
 * drawing digits (and optional label) of the time set in {@link #setTime(long, boolean)}
 */
public class CountingTimerView extends View {

    private static final float TEXT_SIZE_TO_WIDTH_RATIO = 0.85f;
    // This is the ratio of the font height needed to vertically offset the font for alignment
    // from the center.
    private static final float FONT_VERTICAL_OFFSET = 0.14f;
    // Ratio of the space trailing the Hours and Minutes
    private static final float HOURS_MINUTES_SPACING = 0.4f;
    // Ratio of the space leading the Hundredths
    private static final float HUNDREDTHS_SPACING = 0.5f;

    /** Reusable StringBuilder to assemble talk back announcements when the time is updated. */
    private static final StringBuilder sTalkBackBuilder = new StringBuilder(50);

    // Radial offset of the enclosing circle
    private final float mRadiusOffset;

    private String mHours, mMinutes, mSeconds, mHundredths;

    private boolean mShowTimeStr = true;
    private final Paint mPaintBigThin = new Paint();
    private final Paint mPaintMed = new Paint();
    private final float mBigFontSize, mSmallFontSize;
    // Hours and minutes are signed for when a timer goes past the set time and thus negative
    private final SignedTime mBigHours, mBigMinutes;
    // Seconds are always shown with minutes, so are never signed
    private final UnsignedTime mBigSeconds;
    private final Hundredths mMedHundredths;
    private float mTextHeight = 0;
    private float mTotalTextWidth;
    private boolean mRemeasureText = true;

    private int mDefaultColor;
    private final int mPressedColor;
    private final int mWhiteColor;
    private final int mAccentColor;
    private final AccessibilityManager mAccessibilityManager;

    // Fields for the text serving as a virtual button.
    private boolean mVirtualButtonEnabled = false;
    private boolean mVirtualButtonPressedOn = false;

    // Whether or not a bounding circle exists into which the text must be made to fit.
    // If no such circle exists, the entire width of this component is available for text display.
    private boolean mShowBoundingCircle;

    Runnable mBlinkThread = new Runnable() {
        private boolean mVisible = true;

        @Override
        public void run() {
            mVisible = !mVisible;
            CountingTimerView.this.showTime(mVisible);
            postDelayed(mBlinkThread, 500);
        }
    };

    /**
     * Class to measure and draw the digit pairs of hours, minutes, seconds or hundredths. Digits
     * may have an optional label. for hours, minutes and seconds, this label trails the digits
     * and for seconds, precedes the digits.
     */
    static class UnsignedTime {
        protected Paint mPaint;
        protected float mEm;
        protected float mWidth = 0;
        private final String mWidest;
        protected final float mSpacingRatio;
        private float mLabelWidth = 0;

        public UnsignedTime(Paint paint, float spacingRatio, String allDigits) {
            mPaint = paint;
            mSpacingRatio = spacingRatio;

            if (TextUtils.isEmpty(allDigits)) {
                LogUtils.wtf("Locale digits missing - using English");
                allDigits = "0123456789";
            }

            float widths[] = new float[allDigits.length()];
            int ll = mPaint.getTextWidths(allDigits, widths);
            int largest = 0;
            for (int ii = 1; ii < ll; ii++) {
                if (widths[ii] > widths[largest]) {
                    largest = ii;
                }
            }

            mEm = widths[largest];
            mWidest = allDigits.substring(largest, largest + 1);
        }

        public UnsignedTime(UnsignedTime unsignedTime, float spacingRatio) {
            this.mPaint = unsignedTime.mPaint;
            this.mEm = unsignedTime.mEm;
            this.mWidth = unsignedTime.mWidth;
            this.mWidest = unsignedTime.mWidest;
            this.mSpacingRatio = spacingRatio;
        }

        protected void updateWidth(final String time) {
            mEm = mPaint.measureText(mWidest);
            mLabelWidth = mSpacingRatio * mEm;
            mWidth = time.length() * mEm;
        }

        protected void resetWidth() {
            mWidth = mLabelWidth = 0;
        }

        public float calcTotalWidth(final String time) {
            if (time != null) {
                updateWidth(time);
                return mWidth + mLabelWidth;
            } else {
                resetWidth();
                return 0;
            }
        }

        public float getLabelWidth() {
            return mLabelWidth;
        }

        /**
         * Draws each character with a fixed spacing from time starting at ii.
         * @param canvas the canvas on which the time segment will be drawn
         * @param time time segment
         * @param ii what character to start the draw
         * @param x offset
         * @param y offset
         * @return X location for the next segment
         */
        protected float drawTime(Canvas canvas, final String time, int ii, float x, float y) {
            float textEm = mEm / 2f;
            while (ii < time.length()) {
                x += textEm;
                canvas.drawText(time.substring(ii, ii + 1), x, y, mPaint);
                x += textEm;
                ii++;
            }
            return x;
        }

        /**
         * Draw this time segment and append the intra-segment spacing to the x
         * @param canvas the canvas on which the time segment will be drawn
         * @param time time segment
         * @param x offset
         * @param y offset
         * @return X location for the next segment
         */
        public float draw(Canvas canvas, final String time, float x, float y) {
            return drawTime(canvas, time, 0, x, y) + getLabelWidth();
        }
    }

    /**
     * Special derivation to handle the hundredths painting with the label in front.
     */
    static class Hundredths extends UnsignedTime {
        public Hundredths(Paint paint, float spacingRatio, final String allDigits) {
            super(paint, spacingRatio, allDigits);
        }

        /**
         * Draw this time segment after prepending the intra-segment spacing to the x location.
         * {@link UnsignedTime#draw(android.graphics.Canvas, String, float, float)}
         */
        @Override
        public float draw(Canvas canvas, final String time, float x, float y) {
            return drawTime(canvas, time, 0, x + getLabelWidth(), y);
        }
    }

    /**
     * Special derivation to handle a negative number
     */
    static class SignedTime extends UnsignedTime {
        private float mMinusWidth = 0;

        public SignedTime(UnsignedTime unsignedTime, float spacingRatio) {
            super(unsignedTime, spacingRatio);
        }

        @Override
        protected void updateWidth(final String time) {
            super.updateWidth(time);
            if (time.contains("-")) {
                mMinusWidth = mPaint.measureText("-");
                mWidth += (mMinusWidth - mEm);
            } else {
                mMinusWidth = 0;
            }
        }

        @Override
        protected void resetWidth() {
            super.resetWidth();
            mMinusWidth = 0;
        }

        /**
         * Draws each character with a fixed spacing from time, handling the special negative
         * number case.
         * {@link UnsignedTime#draw(android.graphics.Canvas, String, float, float)}
         */
        @Override
        public float draw(Canvas canvas, final String time, float x, float y) {
            int ii = 0;
            if (mMinusWidth != 0f) {
                float minusWidth = mMinusWidth / 2;
                x += minusWidth;
                //TODO:hyphen is too thick when painted
                canvas.drawText(time.substring(0, 1), x, y, mPaint);
                x += minusWidth;
                ii++;
            }
            return drawTime(canvas, time, ii, x, y) + getLabelWidth();
        }
    }

    @SuppressWarnings("unused")
    public CountingTimerView(Context context) {
        this(context, null);
    }

    public CountingTimerView(Context context, AttributeSet attrs) {
        super(context, attrs);
        mAccessibilityManager = (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE);
        Resources r = context.getResources();
        mDefaultColor = mWhiteColor = ContextCompat.getColor(context, R.color.colorPrimary);
        mPressedColor = mAccentColor = ContextCompat.getColor(context, R.color.colorPrimary);
        mBigFontSize = r.getDimension(R.dimen.big_font_size);
        mSmallFontSize = r.getDimension(R.dimen.small_font_size);

        mPaintBigThin.setAntiAlias(true);
        mPaintBigThin.setStyle(Paint.Style.STROKE);
        mPaintBigThin.setTextAlign(Paint.Align.CENTER);
        mPaintBigThin.setTypeface(Typeface.create("sans-serif-thin", Typeface.NORMAL));

        mPaintMed.setAntiAlias(true);
        mPaintMed.setStyle(Paint.Style.STROKE);
        mPaintMed.setTextAlign(Paint.Align.CENTER);
        mPaintMed.setTypeface(Typeface.create("sans-serif-light", Typeface.NORMAL));

        resetTextSize();
        setTextColor(mDefaultColor);

        // allDigits will contain ten digits: "0123456789" in the default locale
        final String allDigits = String.format(Locale.getDefault(), "%010d", 123456789);
        mBigSeconds = new UnsignedTime(mPaintBigThin, 0.f, allDigits);
        mBigHours = new SignedTime(mBigSeconds, HOURS_MINUTES_SPACING);
        mBigMinutes = new SignedTime(mBigSeconds, HOURS_MINUTES_SPACING);
        mMedHundredths = new Hundredths(mPaintMed, HUNDREDTHS_SPACING, allDigits);

        mRadiusOffset = Utils.calculateRadiusOffset(r);
    }

    protected void resetTextSize() {
        mTextHeight = mBigFontSize;
        mPaintBigThin.setTextSize(mBigFontSize);
        mPaintMed.setTextSize(mSmallFontSize);
    }

    protected void setTextColor(int textColor) {
        mPaintBigThin.setColor(textColor);
        mPaintMed.setColor(textColor);
    }

    public void setShowBoundingCircle(boolean showBoundingCircle) {
        mShowBoundingCircle = showBoundingCircle;
        requestLayout();
    }

    /**
     * Update the time to display. Separates that time into the hours, minutes, seconds and
     * hundredths. If update is true, the view is invalidated so that it will draw again.
     *
     * @param time new time to display - in milliseconds
     * @param showHundredths flag to show hundredths resolution
     */
    // TODO:showHundredths S/B attribute or setter - i.e. unchanging over object life
    public void setTime(long time, boolean showHundredths) {
        final int oldLength = getDigitsLength();
        boolean neg = false, showNeg = false;
        if (time < 0) {
            time = -time;
            neg = showNeg = true;
        }

        int hours = (int) (time / DateUtils.HOUR_IN_MILLIS);
        int remainder = (int) (time % DateUtils.HOUR_IN_MILLIS);

        int minutes = (int) (remainder / DateUtils.MINUTE_IN_MILLIS);
        remainder = (int) (remainder % DateUtils.MINUTE_IN_MILLIS);

        int seconds = (int) (remainder / DateUtils.SECOND_IN_MILLIS);
        remainder = (int) (remainder % DateUtils.SECOND_IN_MILLIS);

        int hundredths = remainder / 10;

        if (hours > 999) {
            hours = 0;
        }

        // The time can be between 0 and -1 seconds, but the "truncated" equivalent time of hours
        // and minutes and seconds could be zero, so since we do not show fractions of seconds
        // when counting down, do not show the minus sign.
        // TODO:does it matter that we do not look at showHundredths?
        if (hours == 0 && minutes == 0 && seconds == 0) {
            showNeg = false;
        }

        // If not showing hundredths, round up to the next second.
        if (!showHundredths) {
            if (!neg && hundredths != 0) {
                seconds++;
                if (seconds == 60) {
                    seconds = 0;
                    minutes++;
                    if (minutes == 60) {
                        minutes = 0;
                        hours++;
                    }
                }
            }
        }

        // Hours may be empty.
        final UiDataModel uiDataModel = UiDataModel.getUiDataModel();
        if (hours > 0) {
            final int hoursLength = hours >= 10 ? 2 : 1;
            mHours = uiDataModel.getFormattedNumber(showNeg, hours, hoursLength);
        } else {
            mHours = null;
        }

        // Minutes are never empty and forced to two digits when hours exist.
        final boolean showNegMinutes = showNeg && hours == 0;
        final int minutesLength = minutes >= 10 || hours > 0 ? 2 : 1;
        mMinutes = uiDataModel.getFormattedNumber(showNegMinutes, minutes, minutesLength);

        // Seconds are always two digits
        mSeconds = uiDataModel.getFormattedNumber(seconds, 2);

        // Hundredths are optional but forced to two digits when displayed.
        if (showHundredths) {
            mHundredths = uiDataModel.getFormattedNumber(hundredths, 2);
        } else {
            mHundredths = null;
        }

        int newLength = getDigitsLength();
        if (oldLength != newLength) {
            if (oldLength > newLength) {
                resetTextSize();
            }
            mRemeasureText = true;
        }

        setContentDescription(getTimeStringForAccessibility(hours, minutes, seconds, showNeg, getResources()));
        postInvalidateOnAnimation();
    }

    private int getDigitsLength() {
        return ((mHours == null) ? 0 : mHours.length()) + ((mMinutes == null) ? 0 : mMinutes.length())
                + ((mSeconds == null) ? 0 : mSeconds.length()) + ((mHundredths == null) ? 0 : mHundredths.length());
    }

    private void calcTotalTextWidth() {
        mTotalTextWidth = mBigHours.calcTotalWidth(mHours) + mBigMinutes.calcTotalWidth(mMinutes)
                + mBigSeconds.calcTotalWidth(mSeconds) + mMedHundredths.calcTotalWidth(mHundredths);
    }

    /**
     * Adjust the size of the fonts to fit within the the circle and painted object in
     * CircleTimerView#onDraw(android.graphics.Canvas)
     */
    private void setTotalTextWidth() {
        calcTotalTextWidth();

        int width;
        if (mShowBoundingCircle) {
            // A bounding circle exists, so the available width in which to fit the timer text is
            // the smaller of the width or height, which is also equal to the circle's diameter.
            width = Math.min(getWidth(), getHeight());
        } else {
            // A bounding circle does not exist, so pretend that the entire width of this component
            // is the diameter of a theoretical bounding circle.
            width = getWidth();
        }

        if (width != 0) {
            // Shrink 'width' to account for circle stroke and other painted objects.
            // Note on the "4 *": (1) To reduce divisions, using the diameter instead of the radius.
            // (2) The radius of the enclosing circle is reduced by mRadiusOffset and the
            // text needs to fit within a circle further reduced by mRadiusOffset.
            width -= (int) (4 * mRadiusOffset + 0.5f);

            final float wantDiameter2 = TEXT_SIZE_TO_WIDTH_RATIO * width * width;
            float totalDiameter2 = getHypotenuseSquared();

            // If the hypotenuse of the bounding box is too large, reduce all the paint text sizes
            while (totalDiameter2 > wantDiameter2) {
                // Convergence is slightly difficult due to quantization in the mTotalTextWidth
                // calculation. Reducing the ratio by 1% converges more quickly without excessive
                // loss of quality.
                float sizeRatio = 0.99f * (float) Math.sqrt(wantDiameter2 / totalDiameter2);
                mPaintBigThin.setTextSize(mPaintBigThin.getTextSize() * sizeRatio);
                mPaintMed.setTextSize(mPaintMed.getTextSize() * sizeRatio);
                // Recalculate the new total text height and half-width
                mTextHeight = mPaintBigThin.getTextSize();
                calcTotalTextWidth();
                totalDiameter2 = getHypotenuseSquared();
            }
        }
    }

    /**
     * Calculate the square of the diameter to use in {@link CountingTimerView#setTotalTextWidth()}
     */
    private float getHypotenuseSquared() {
        return mTotalTextWidth * mTotalTextWidth + mTextHeight * mTextHeight;
    }

    public void blinkTimeStr(boolean blink) {
        if (blink) {
            removeCallbacks(mBlinkThread);
            post(mBlinkThread);
        } else {
            removeCallbacks(mBlinkThread);
            showTime(true);
        }
    }

    public void showTime(boolean visible) {
        mShowTimeStr = visible;
        invalidate();
    }

    public void setTimeStrTextColor(boolean active, boolean forceUpdate) {
        mDefaultColor = active ? mAccentColor : mWhiteColor;
        setTextColor(mDefaultColor);
        if (forceUpdate) {
            invalidate();
        }
    }

    private static String getTimeStringForAccessibility(int hours, int minutes, int seconds, boolean showNeg,
            Resources r) {
        sTalkBackBuilder.setLength(0);
        if (showNeg) {
            // This must be followed by a non-zero number or it will be audible as "hyphen"
            // instead of "minus".
            sTalkBackBuilder.append('-');
        }
        if (showNeg && hours == 0 && minutes == 0) {
            // Non-negative time will always have minutes, eg. "0 minutes 7 seconds", but negative
            // time must start with non-zero digit, eg. -0m7s will be audible as just "-7 seconds"
            sTalkBackBuilder.append(getQuantityString(r, R.plurals.Nseconds_description, seconds));
        } else if (hours == 0) {
            sTalkBackBuilder.append(getQuantityString(r, R.plurals.Nminutes_description, minutes));
            sTalkBackBuilder.append(' ');
            sTalkBackBuilder.append(getQuantityString(r, R.plurals.Nseconds_description, seconds));
        } else {
            sTalkBackBuilder.append(getQuantityString(r, R.plurals.Nhours_description, hours));
            sTalkBackBuilder.append(' ');
            sTalkBackBuilder.append(getQuantityString(r, R.plurals.Nminutes_description, minutes));
            sTalkBackBuilder.append(' ');
            sTalkBackBuilder.append(getQuantityString(r, R.plurals.Nseconds_description, seconds));
        }
        return sTalkBackBuilder.toString();
    }

    private static String getQuantityString(Resources r, @PluralsRes int resId, int quantity) {
        return r.getQuantityString(resId, quantity, quantity);
    }

    public void setVirtualButtonEnabled(boolean enabled) {
        mVirtualButtonEnabled = enabled;
    }

    private void virtualButtonPressed(boolean pressedOn) {
        mVirtualButtonPressedOn = pressedOn;
        invalidate();
    }

    private boolean withinVirtualButtonBounds(float x, float y) {
        int width = getWidth();
        int height = getHeight();
        float centerX = width / 2;
        float centerY = height / 2;
        float radius = Math.min(width, height) / 2;

        // Within the circle button if distance to the center is less than the radius.
        double distance = Math.sqrt(Math.pow(centerX - x, 2) + Math.pow(centerY - y, 2));
        return distance < radius;
    }

    public void registerVirtualButtonAction(final Runnable runnable) {
        if (!mAccessibilityManager.isEnabled()) {
            this.setOnTouchListener(new OnTouchListener() {
                @Override
                public boolean onTouch(View v, MotionEvent event) {
                    if (mVirtualButtonEnabled) {
                        switch (event.getAction()) {
                        case MotionEvent.ACTION_DOWN:
                            if (withinVirtualButtonBounds(event.getX(), event.getY())) {
                                virtualButtonPressed(true);
                                return true;
                            } else {
                                virtualButtonPressed(false);
                                return false;
                            }
                        case MotionEvent.ACTION_CANCEL:
                            virtualButtonPressed(false);
                            return true;
                        case MotionEvent.ACTION_OUTSIDE:
                            virtualButtonPressed(false);
                            return false;
                        case MotionEvent.ACTION_UP:
                            virtualButtonPressed(false);
                            if (withinVirtualButtonBounds(event.getX(), event.getY())) {
                                runnable.run();
                            }
                            return true;
                        }
                    }
                    return false;
                }
            });
        } else {
            this.setOnClickListener(new OnClickListener() {
                @Override
                public void onClick(View v) {
                    runnable.run();
                }
            });
        }
    }

    @Override
    public void onDraw(Canvas canvas) {
        // Blink functionality.
        if (!mShowTimeStr && !mVirtualButtonPressedOn) {
            return;
        }

        int width = getWidth();
        if (mRemeasureText && width != 0) {
            setTotalTextWidth();
            width = getWidth();
            mRemeasureText = false;
        }

        int xCenter = width / 2;
        int yCenter = getHeight() / 2;

        float xTextStart = xCenter - mTotalTextWidth / 2;
        float yTextStart = yCenter + mTextHeight / 2 - (mTextHeight * FONT_VERTICAL_OFFSET);

        // Text color differs based on pressed state.
        final int textColor = mVirtualButtonPressedOn ? mPressedColor : mDefaultColor;
        mPaintBigThin.setColor(textColor);
        mPaintMed.setColor(textColor);

        if (mHours != null) {
            xTextStart = mBigHours.draw(canvas, mHours, xTextStart, yTextStart);
        }
        if (mMinutes != null) {
            xTextStart = mBigMinutes.draw(canvas, mMinutes, xTextStart, yTextStart);
        }
        if (mSeconds != null) {
            xTextStart = mBigSeconds.draw(canvas, mSeconds, xTextStart, yTextStart);
        }
        if (mHundredths != null) {
            mMedHundredths.draw(canvas, mHundredths, xTextStart, yTextStart);
        }
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        mRemeasureText = true;
        resetTextSize();
    }
}