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