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