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