1 /* 2 * Copyright (C) 2015 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 17 package android.support.design.widget; 18 19 import android.content.res.TypedArray; 20 import android.graphics.Bitmap; 21 import android.graphics.Canvas; 22 import android.graphics.Color; 23 import android.graphics.Paint; 24 import android.graphics.Rect; 25 import android.graphics.RectF; 26 import android.graphics.Typeface; 27 import android.os.Build; 28 import android.support.design.R; 29 import android.support.v4.text.TextDirectionHeuristicsCompat; 30 import android.support.v4.view.GravityCompat; 31 import android.support.v4.view.ViewCompat; 32 import android.text.TextPaint; 33 import android.text.TextUtils; 34 import android.view.Gravity; 35 import android.view.View; 36 import android.view.animation.Interpolator; 37 38 final class CollapsingTextHelper { 39 40 // Pre-JB-MR2 doesn't support HW accelerated canvas scaled text so we will workaround it 41 // by using our own texture 42 private static final boolean USE_SCALING_TEXTURE = Build.VERSION.SDK_INT < 18; 43 44 private static final boolean DEBUG_DRAW = false; 45 private static final Paint DEBUG_DRAW_PAINT; 46 static { 47 DEBUG_DRAW_PAINT = DEBUG_DRAW ? new Paint() : null; 48 if (DEBUG_DRAW_PAINT != null) { 49 DEBUG_DRAW_PAINT.setAntiAlias(true); 50 DEBUG_DRAW_PAINT.setColor(Color.MAGENTA); 51 } 52 } 53 54 private final View mView; 55 56 private boolean mDrawTitle; 57 private float mExpandedFraction; 58 59 private final Rect mExpandedBounds; 60 private final Rect mCollapsedBounds; 61 private final RectF mCurrentBounds; 62 private int mExpandedTextGravity = Gravity.CENTER_VERTICAL; 63 private int mCollapsedTextGravity = Gravity.CENTER_VERTICAL; 64 private float mExpandedTextSize = 15; 65 private float mCollapsedTextSize = 15; 66 private int mExpandedTextColor; 67 private int mCollapsedTextColor; 68 69 private float mExpandedDrawY; 70 private float mCollapsedDrawY; 71 private float mExpandedDrawX; 72 private float mCollapsedDrawX; 73 private float mCurrentDrawX; 74 private float mCurrentDrawY; 75 76 private CharSequence mText; 77 private CharSequence mTextToDraw; 78 private boolean mIsRtl; 79 80 private boolean mUseTexture; 81 private Bitmap mExpandedTitleTexture; 82 private Paint mTexturePaint; 83 private float mTextureAscent; 84 private float mTextureDescent; 85 86 private float mScale; 87 private float mCurrentTextSize; 88 89 private boolean mBoundsChanged; 90 91 private final TextPaint mTextPaint; 92 93 private Interpolator mPositionInterpolator; 94 private Interpolator mTextSizeInterpolator; 95 96 public CollapsingTextHelper(View view) { 97 mView = view; 98 99 mTextPaint = new TextPaint(); 100 mTextPaint.setAntiAlias(true); 101 102 mCollapsedBounds = new Rect(); 103 mExpandedBounds = new Rect(); 104 mCurrentBounds = new RectF(); 105 } 106 107 void setTextSizeInterpolator(Interpolator interpolator) { 108 mTextSizeInterpolator = interpolator; 109 recalculate(); 110 } 111 112 void setPositionInterpolator(Interpolator interpolator) { 113 mPositionInterpolator = interpolator; 114 recalculate(); 115 } 116 117 void setExpandedTextSize(float textSize) { 118 if (mExpandedTextSize != textSize) { 119 mExpandedTextSize = textSize; 120 recalculate(); 121 } 122 } 123 124 void setCollapsedTextSize(float textSize) { 125 if (mCollapsedTextSize != textSize) { 126 mCollapsedTextSize = textSize; 127 recalculate(); 128 } 129 } 130 131 void setCollapsedTextColor(int textColor) { 132 if (mCollapsedTextColor != textColor) { 133 mCollapsedTextColor = textColor; 134 recalculate(); 135 } 136 } 137 138 void setExpandedTextColor(int textColor) { 139 if (mExpandedTextColor != textColor) { 140 mExpandedTextColor = textColor; 141 recalculate(); 142 } 143 } 144 145 void setExpandedBounds(int left, int top, int right, int bottom) { 146 if (!rectEquals(mExpandedBounds, left, top, right, bottom)) { 147 mExpandedBounds.set(left, top, right, bottom); 148 mBoundsChanged = true; 149 onBoundsChanged(); 150 } 151 } 152 153 void setCollapsedBounds(int left, int top, int right, int bottom) { 154 if (!rectEquals(mCollapsedBounds, left, top, right, bottom)) { 155 mCollapsedBounds.set(left, top, right, bottom); 156 mBoundsChanged = true; 157 onBoundsChanged(); 158 } 159 } 160 161 void onBoundsChanged() { 162 mDrawTitle = mCollapsedBounds.width() > 0 && mCollapsedBounds.height() > 0 163 && mExpandedBounds.width() > 0 && mExpandedBounds.height() > 0; 164 } 165 setExpandedTextGravity(int gravity)166 void setExpandedTextGravity(int gravity) { 167 if (mExpandedTextGravity != gravity) { 168 mExpandedTextGravity = gravity; 169 recalculate(); 170 } 171 } 172 getExpandedTextGravity()173 int getExpandedTextGravity() { 174 return mExpandedTextGravity; 175 } 176 setCollapsedTextGravity(int gravity)177 void setCollapsedTextGravity(int gravity) { 178 if (mCollapsedTextGravity != gravity) { 179 mCollapsedTextGravity = gravity; 180 recalculate(); 181 } 182 } 183 getCollapsedTextGravity()184 int getCollapsedTextGravity() { 185 return mCollapsedTextGravity; 186 } 187 setCollapsedTextAppearance(int resId)188 void setCollapsedTextAppearance(int resId) { 189 TypedArray a = mView.getContext().obtainStyledAttributes(resId, R.styleable.TextAppearance); 190 if (a.hasValue(R.styleable.TextAppearance_android_textColor)) { 191 mCollapsedTextColor = a.getColor( 192 R.styleable.TextAppearance_android_textColor, mCollapsedTextColor); 193 } 194 if (a.hasValue(R.styleable.TextAppearance_android_textSize)) { 195 mCollapsedTextSize = a.getDimensionPixelSize( 196 R.styleable.TextAppearance_android_textSize, (int) mCollapsedTextSize); 197 } 198 a.recycle(); 199 200 recalculate(); 201 } 202 setExpandedTextAppearance(int resId)203 void setExpandedTextAppearance(int resId) { 204 TypedArray a = mView.getContext().obtainStyledAttributes(resId, R.styleable.TextAppearance); 205 if (a.hasValue(R.styleable.TextAppearance_android_textColor)) { 206 mExpandedTextColor = a.getColor( 207 R.styleable.TextAppearance_android_textColor, mExpandedTextColor); 208 } 209 if (a.hasValue(R.styleable.TextAppearance_android_textSize)) { 210 mExpandedTextSize = a.getDimensionPixelSize( 211 R.styleable.TextAppearance_android_textSize, (int) mExpandedTextSize); 212 } 213 a.recycle(); 214 215 recalculate(); 216 } 217 setTypeface(Typeface typeface)218 void setTypeface(Typeface typeface) { 219 if (typeface == null) { 220 typeface = Typeface.DEFAULT; 221 } 222 if (mTextPaint.getTypeface() != typeface) { 223 mTextPaint.setTypeface(typeface); 224 recalculate(); 225 } 226 } 227 getTypeface()228 Typeface getTypeface() { 229 return mTextPaint.getTypeface(); 230 } 231 232 /** 233 * Set the value indicating the current scroll value. This decides how much of the 234 * background will be displayed, as well as the title metrics/positioning. 235 * 236 * A value of {@code 0.0} indicates that the layout is fully expanded. 237 * A value of {@code 1.0} indicates that the layout is fully collapsed. 238 */ setExpansionFraction(float fraction)239 void setExpansionFraction(float fraction) { 240 fraction = MathUtils.constrain(fraction, 0f, 1f); 241 242 if (fraction != mExpandedFraction) { 243 mExpandedFraction = fraction; 244 calculateCurrentOffsets(); 245 } 246 } 247 getExpansionFraction()248 float getExpansionFraction() { 249 return mExpandedFraction; 250 } 251 getCollapsedTextSize()252 float getCollapsedTextSize() { 253 return mCollapsedTextSize; 254 } 255 getExpandedTextSize()256 float getExpandedTextSize() { 257 return mExpandedTextSize; 258 } 259 calculateCurrentOffsets()260 private void calculateCurrentOffsets() { 261 final float fraction = mExpandedFraction; 262 263 interpolateBounds(fraction); 264 mCurrentDrawX = lerp(mExpandedDrawX, mCollapsedDrawX, fraction, 265 mPositionInterpolator); 266 mCurrentDrawY = lerp(mExpandedDrawY, mCollapsedDrawY, fraction, 267 mPositionInterpolator); 268 269 setInterpolatedTextSize(lerp(mExpandedTextSize, mCollapsedTextSize, 270 fraction, mTextSizeInterpolator)); 271 272 if (mCollapsedTextColor != mExpandedTextColor) { 273 // If the collapsed and expanded text colors are different, blend them based on the 274 // fraction 275 mTextPaint.setColor(blendColors(mExpandedTextColor, mCollapsedTextColor, fraction)); 276 } else { 277 mTextPaint.setColor(mCollapsedTextColor); 278 } 279 280 ViewCompat.postInvalidateOnAnimation(mView); 281 } 282 calculateBaseOffsets()283 private void calculateBaseOffsets() { 284 // We then calculate the collapsed text size, using the same logic 285 mTextPaint.setTextSize(mCollapsedTextSize); 286 float width = mTextToDraw != null ? 287 mTextPaint.measureText(mTextToDraw, 0, mTextToDraw.length()) : 0; 288 final int collapsedAbsGravity = GravityCompat.getAbsoluteGravity(mCollapsedTextGravity, 289 mIsRtl ? ViewCompat.LAYOUT_DIRECTION_RTL : ViewCompat.LAYOUT_DIRECTION_LTR); 290 switch (collapsedAbsGravity & Gravity.VERTICAL_GRAVITY_MASK) { 291 case Gravity.BOTTOM: 292 mCollapsedDrawY = mCollapsedBounds.bottom; 293 break; 294 case Gravity.TOP: 295 mCollapsedDrawY = mCollapsedBounds.top - mTextPaint.ascent(); 296 break; 297 case Gravity.CENTER_VERTICAL: 298 default: 299 float textHeight = mTextPaint.descent() - mTextPaint.ascent(); 300 float textOffset = (textHeight / 2) - mTextPaint.descent(); 301 mCollapsedDrawY = mCollapsedBounds.centerY() + textOffset; 302 break; 303 } 304 switch (collapsedAbsGravity & Gravity.HORIZONTAL_GRAVITY_MASK) { 305 case Gravity.CENTER_HORIZONTAL: 306 mCollapsedDrawX = mCollapsedBounds.centerX() - (width / 2); 307 break; 308 case Gravity.RIGHT: 309 mCollapsedDrawX = mCollapsedBounds.right - width; 310 break; 311 case Gravity.LEFT: 312 default: 313 mCollapsedDrawX = mCollapsedBounds.left; 314 break; 315 } 316 317 mTextPaint.setTextSize(mExpandedTextSize); 318 width = mTextToDraw != null 319 ? mTextPaint.measureText(mTextToDraw, 0, mTextToDraw.length()) : 0; 320 final int expandedAbsGravity = GravityCompat.getAbsoluteGravity(mExpandedTextGravity, 321 mIsRtl ? ViewCompat.LAYOUT_DIRECTION_RTL : ViewCompat.LAYOUT_DIRECTION_LTR); 322 switch (expandedAbsGravity & Gravity.VERTICAL_GRAVITY_MASK) { 323 case Gravity.BOTTOM: 324 mExpandedDrawY = mExpandedBounds.bottom; 325 break; 326 case Gravity.TOP: 327 mExpandedDrawY = mExpandedBounds.top - mTextPaint.ascent(); 328 break; 329 case Gravity.CENTER_VERTICAL: 330 default: 331 float textHeight = mTextPaint.descent() - mTextPaint.ascent(); 332 float textOffset = (textHeight / 2) - mTextPaint.descent(); 333 mExpandedDrawY = mExpandedBounds.centerY() + textOffset; 334 break; 335 } 336 switch (expandedAbsGravity & Gravity.HORIZONTAL_GRAVITY_MASK) { 337 case Gravity.CENTER_HORIZONTAL: 338 mExpandedDrawX = mExpandedBounds.centerX() - (width / 2); 339 break; 340 case Gravity.RIGHT: 341 mExpandedDrawX = mExpandedBounds.right - width; 342 break; 343 case Gravity.LEFT: 344 default: 345 mExpandedDrawX = mExpandedBounds.left; 346 break; 347 } 348 349 // The bounds have changed so we need to clear the texture 350 clearTexture(); 351 } 352 interpolateBounds(float fraction)353 private void interpolateBounds(float fraction) { 354 mCurrentBounds.left = lerp(mExpandedBounds.left, mCollapsedBounds.left, 355 fraction, mPositionInterpolator); 356 mCurrentBounds.top = lerp(mExpandedDrawY, mCollapsedDrawY, 357 fraction, mPositionInterpolator); 358 mCurrentBounds.right = lerp(mExpandedBounds.right, mCollapsedBounds.right, 359 fraction, mPositionInterpolator); 360 mCurrentBounds.bottom = lerp(mExpandedBounds.bottom, mCollapsedBounds.bottom, 361 fraction, mPositionInterpolator); 362 } 363 draw(Canvas canvas)364 public void draw(Canvas canvas) { 365 final int saveCount = canvas.save(); 366 367 if (mTextToDraw != null && mDrawTitle) { 368 float x = mCurrentDrawX; 369 float y = mCurrentDrawY; 370 371 final boolean drawTexture = mUseTexture && mExpandedTitleTexture != null; 372 373 final float ascent; 374 final float descent; 375 376 // Update the TextPaint to the current text size 377 mTextPaint.setTextSize(mCurrentTextSize); 378 379 if (drawTexture) { 380 ascent = mTextureAscent * mScale; 381 descent = mTextureDescent * mScale; 382 } else { 383 ascent = mTextPaint.ascent() * mScale; 384 descent = mTextPaint.descent() * mScale; 385 } 386 387 if (DEBUG_DRAW) { 388 // Just a debug tool, which drawn a Magneta rect in the text bounds 389 canvas.drawRect(mCurrentBounds.left, y + ascent, mCurrentBounds.right, y + descent, 390 DEBUG_DRAW_PAINT); 391 } 392 393 if (drawTexture) { 394 y += ascent; 395 } 396 397 if (mScale != 1f) { 398 canvas.scale(mScale, mScale, x, y); 399 } 400 401 if (drawTexture) { 402 // If we should use a texture, draw it instead of text 403 canvas.drawBitmap(mExpandedTitleTexture, x, y, mTexturePaint); 404 } else { 405 canvas.drawText(mTextToDraw, 0, mTextToDraw.length(), x, y, mTextPaint); 406 } 407 } 408 409 canvas.restoreToCount(saveCount); 410 } 411 calculateIsRtl(CharSequence text)412 private boolean calculateIsRtl(CharSequence text) { 413 final boolean defaultIsRtl = ViewCompat.getLayoutDirection(mView) 414 == ViewCompat.LAYOUT_DIRECTION_RTL; 415 return (defaultIsRtl 416 ? TextDirectionHeuristicsCompat.FIRSTSTRONG_RTL 417 : TextDirectionHeuristicsCompat.FIRSTSTRONG_LTR).isRtl(text, 0, text.length()); 418 } 419 setInterpolatedTextSize(final float textSize)420 private void setInterpolatedTextSize(final float textSize) { 421 if (mText == null) return; 422 423 final float availableWidth; 424 final float newTextSize; 425 boolean updateDrawText = false; 426 427 if (isClose(textSize, mCollapsedTextSize)) { 428 availableWidth = mCollapsedBounds.width(); 429 newTextSize = mCollapsedTextSize; 430 mScale = 1f; 431 } else { 432 availableWidth = mExpandedBounds.width(); 433 newTextSize = mExpandedTextSize; 434 435 if (isClose(textSize, mExpandedTextSize)) { 436 // If we're close to the expanded text size, snap to it and use a scale of 1 437 mScale = 1f; 438 } else { 439 // Else, we'll scale down from the expanded text size 440 mScale = textSize / mExpandedTextSize; 441 } 442 } 443 444 if (availableWidth > 0) { 445 updateDrawText = (mCurrentTextSize != newTextSize) || mBoundsChanged; 446 mCurrentTextSize = newTextSize; 447 mBoundsChanged = false; 448 } 449 450 if (mTextToDraw == null || updateDrawText) { 451 mTextPaint.setTextSize(mCurrentTextSize); 452 453 // If we don't currently have text to draw, or the text size has changed, ellipsize... 454 final CharSequence title = TextUtils.ellipsize(mText, mTextPaint, 455 availableWidth, TextUtils.TruncateAt.END); 456 if (mTextToDraw == null || !mTextToDraw.equals(title)) { 457 mTextToDraw = title; 458 } 459 mIsRtl = calculateIsRtl(mTextToDraw); 460 } 461 462 // Use our texture if the scale isn't 1.0 463 mUseTexture = USE_SCALING_TEXTURE && mScale != 1f; 464 465 if (mUseTexture) { 466 // Make sure we have an expanded texture if needed 467 ensureExpandedTexture(); 468 } 469 470 ViewCompat.postInvalidateOnAnimation(mView); 471 } 472 ensureExpandedTexture()473 private void ensureExpandedTexture() { 474 if (mExpandedTitleTexture != null || mExpandedBounds.isEmpty() 475 || TextUtils.isEmpty(mTextToDraw)) { 476 return; 477 } 478 479 mTextPaint.setTextSize(mExpandedTextSize); 480 mTextPaint.setColor(mExpandedTextColor); 481 mTextureAscent = mTextPaint.ascent(); 482 mTextureDescent = mTextPaint.descent(); 483 484 final int w = Math.round(mTextPaint.measureText(mTextToDraw, 0, mTextToDraw.length())); 485 final int h = Math.round(mTextureDescent - mTextureAscent); 486 487 if (w <= 0 && h <= 0) { 488 return; // If the width or height are 0, return 489 } 490 491 mExpandedTitleTexture = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888); 492 493 Canvas c = new Canvas(mExpandedTitleTexture); 494 c.drawText(mTextToDraw, 0, mTextToDraw.length(), 0, h - mTextPaint.descent(), mTextPaint); 495 496 if (mTexturePaint == null) { 497 // Make sure we have a paint 498 mTexturePaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG); 499 } 500 } 501 recalculate()502 public void recalculate() { 503 if (mView.getHeight() > 0 && mView.getWidth() > 0) { 504 // If we've already been laid out, calculate everything now otherwise we'll wait 505 // until a layout 506 calculateBaseOffsets(); 507 calculateCurrentOffsets(); 508 } 509 } 510 511 /** 512 * Set the title to display 513 * 514 * @param text 515 */ setText(CharSequence text)516 void setText(CharSequence text) { 517 if (text == null || !text.equals(mText)) { 518 mText = text; 519 mTextToDraw = null; 520 clearTexture(); 521 recalculate(); 522 } 523 } 524 getText()525 CharSequence getText() { 526 return mText; 527 } 528 clearTexture()529 private void clearTexture() { 530 if (mExpandedTitleTexture != null) { 531 mExpandedTitleTexture.recycle(); 532 mExpandedTitleTexture = null; 533 } 534 } 535 536 /** 537 * Returns true if {@code value} is 'close' to it's closest decimal value. Close is currently 538 * defined as it's difference being < 0.001. 539 */ isClose(float value, float targetValue)540 private static boolean isClose(float value, float targetValue) { 541 return Math.abs(value - targetValue) < 0.001f; 542 } 543 getExpandedTextColor()544 int getExpandedTextColor() { 545 return mExpandedTextColor; 546 } 547 getCollapsedTextColor()548 int getCollapsedTextColor() { 549 return mCollapsedTextColor; 550 } 551 552 /** 553 * Blend {@code color1} and {@code color2} using the given ratio. 554 * 555 * @param ratio of which to blend. 0.0 will return {@code color1}, 0.5 will give an even blend, 556 * 1.0 will return {@code color2}. 557 */ blendColors(int color1, int color2, float ratio)558 private static int blendColors(int color1, int color2, float ratio) { 559 final float inverseRatio = 1f - ratio; 560 float a = (Color.alpha(color1) * inverseRatio) + (Color.alpha(color2) * ratio); 561 float r = (Color.red(color1) * inverseRatio) + (Color.red(color2) * ratio); 562 float g = (Color.green(color1) * inverseRatio) + (Color.green(color2) * ratio); 563 float b = (Color.blue(color1) * inverseRatio) + (Color.blue(color2) * ratio); 564 return Color.argb((int) a, (int) r, (int) g, (int) b); 565 } 566 lerp(float startValue, float endValue, float fraction, Interpolator interpolator)567 private static float lerp(float startValue, float endValue, float fraction, 568 Interpolator interpolator) { 569 if (interpolator != null) { 570 fraction = interpolator.getInterpolation(fraction); 571 } 572 return AnimationUtils.lerp(startValue, endValue, fraction); 573 } 574 rectEquals(Rect r, int left, int top, int right, int bottom)575 private static boolean rectEquals(Rect r, int left, int top, int right, int bottom) { 576 return !(r.left != left || r.top != top || r.right != right || r.bottom != bottom); 577 } 578 } 579