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
17package android.support.design.widget;
18
19import android.content.res.TypedArray;
20import android.graphics.Bitmap;
21import android.graphics.Canvas;
22import android.graphics.Color;
23import android.graphics.Paint;
24import android.graphics.Rect;
25import android.graphics.RectF;
26import android.graphics.Typeface;
27import android.os.Build;
28import android.support.design.R;
29import android.support.v4.text.TextDirectionHeuristicsCompat;
30import android.support.v4.view.GravityCompat;
31import android.support.v4.view.ViewCompat;
32import android.text.TextPaint;
33import android.text.TextUtils;
34import android.view.Gravity;
35import android.view.View;
36import android.view.animation.Interpolator;
37
38final 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
166    void setExpandedTextGravity(int gravity) {
167        if (mExpandedTextGravity != gravity) {
168            mExpandedTextGravity = gravity;
169            recalculate();
170        }
171    }
172
173    int getExpandedTextGravity() {
174        return mExpandedTextGravity;
175    }
176
177    void setCollapsedTextGravity(int gravity) {
178        if (mCollapsedTextGravity != gravity) {
179            mCollapsedTextGravity = gravity;
180            recalculate();
181        }
182    }
183
184    int getCollapsedTextGravity() {
185        return mCollapsedTextGravity;
186    }
187
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
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
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
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     */
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
248    float getExpansionFraction() {
249        return mExpandedFraction;
250    }
251
252    float getCollapsedTextSize() {
253        return mCollapsedTextSize;
254    }
255
256    float getExpandedTextSize() {
257        return mExpandedTextSize;
258    }
259
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
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
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
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
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
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
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
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     */
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
525    CharSequence getText() {
526        return mText;
527    }
528
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     */
540    private static boolean isClose(float value, float targetValue) {
541        return Math.abs(value - targetValue) < 0.001f;
542    }
543
544    int getExpandedTextColor() {
545        return mExpandedTextColor;
546    }
547
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     */
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
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
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