/* * Copyright (C) 2015 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.android.material.internal; import static android.text.Layout.Alignment.ALIGN_CENTER; import static android.text.Layout.Alignment.ALIGN_NORMAL; import static android.text.Layout.Alignment.ALIGN_OPPOSITE; import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP; import static androidx.core.util.Preconditions.checkNotNull; import static java.lang.Math.max; import static java.lang.Math.min; import android.animation.TimeInterpolator; import android.content.res.ColorStateList; import android.content.res.Configuration; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.Rect; import android.graphics.RectF; import android.graphics.Typeface; import android.os.Build.VERSION; import android.os.Build.VERSION_CODES; import android.text.Layout.Alignment; import android.text.StaticLayout; import android.text.TextPaint; import android.text.TextUtils; import android.text.TextUtils.TruncateAt; import android.util.Log; import android.view.Gravity; import android.view.View; import androidx.annotation.ColorInt; import androidx.annotation.FloatRange; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.annotation.RestrictTo; import androidx.core.math.MathUtils; import androidx.core.text.TextDirectionHeuristicsCompat; import androidx.core.view.GravityCompat; import androidx.core.view.ViewCompat; import com.google.android.material.animation.AnimationUtils; import com.google.android.material.color.MaterialColors; import com.google.android.material.internal.StaticLayoutBuilderCompat.StaticLayoutBuilderCompatException; import com.google.android.material.resources.CancelableFontCallback; import com.google.android.material.resources.CancelableFontCallback.ApplyFont; import com.google.android.material.resources.TextAppearance; import com.google.android.material.resources.TypefaceUtils; /** * Helper class for rendering and animating collapsed text. * * @hide */ @RestrictTo(LIBRARY_GROUP) public final class CollapsingTextHelper { private static final String TAG = "CollapsingTextHelper"; private static final String ELLIPSIS_NORMAL = "\u2026"; // HORIZONTAL ELLIPSIS (...) private static final float FADE_MODE_THRESHOLD_FRACTION_RELATIVE = 0.5f; private static final boolean DEBUG_DRAW = false; @NonNull private static final Paint DEBUG_DRAW_PAINT; static { DEBUG_DRAW_PAINT = DEBUG_DRAW ? new Paint() : null; if (DEBUG_DRAW_PAINT != null) { DEBUG_DRAW_PAINT.setAntiAlias(true); DEBUG_DRAW_PAINT.setColor(Color.MAGENTA); } } private final View view; private float expandedFraction; private boolean fadeModeEnabled; private float fadeModeStartFraction; private float fadeModeThresholdFraction; private int currentOffsetY; @NonNull private final Rect expandedBounds; @NonNull private final Rect collapsedBounds; @NonNull private final RectF currentBounds; private int expandedTextGravity = Gravity.CENTER_VERTICAL; private int collapsedTextGravity = Gravity.CENTER_VERTICAL; private float expandedTextSize = 15; private float collapsedTextSize = 15; private ColorStateList expandedTextColor; private ColorStateList collapsedTextColor; private int expandedLineCount; private float expandedDrawY; private float collapsedDrawY; private float expandedDrawX; private float collapsedDrawX; private float currentDrawX; private float currentDrawY; private Typeface collapsedTypeface; private Typeface collapsedTypefaceBold; private Typeface collapsedTypefaceDefault; private Typeface expandedTypeface; private Typeface expandedTypefaceBold; private Typeface expandedTypefaceDefault; private Typeface currentTypeface; private CancelableFontCallback expandedFontCallback; private CancelableFontCallback collapsedFontCallback; private TruncateAt titleTextEllipsize = TruncateAt.END; @Nullable private CharSequence text; @Nullable private CharSequence textToDraw; private boolean isRtl; private boolean isRtlTextDirectionHeuristicsEnabled = true; private float scale; private float currentTextSize; private float currentShadowRadius; private float currentShadowDx; private float currentShadowDy; private int currentShadowColor; private int[] state; private boolean boundsChanged; @NonNull private final TextPaint textPaint; @NonNull private final TextPaint tmpPaint; private TimeInterpolator positionInterpolator; private TimeInterpolator textSizeInterpolator; private float collapsedShadowRadius; private float collapsedShadowDx; private float collapsedShadowDy; private ColorStateList collapsedShadowColor; private float expandedShadowRadius; private float expandedShadowDx; private float expandedShadowDy; private ColorStateList expandedShadowColor; private float collapsedLetterSpacing; private float expandedLetterSpacing; private float currentLetterSpacing; private StaticLayout textLayout; private float collapsedTextWidth; private float collapsedTextBlend; private float expandedTextBlend; private CharSequence textToDrawCollapsed; private int maxLines = 1; private float lineSpacingAdd = StaticLayoutBuilderCompat.DEFAULT_LINE_SPACING_ADD; private float lineSpacingMultiplier = StaticLayoutBuilderCompat.DEFAULT_LINE_SPACING_MULTIPLIER; private int hyphenationFrequency = StaticLayoutBuilderCompat.DEFAULT_HYPHENATION_FREQUENCY; @Nullable private StaticLayoutBuilderConfigurer staticLayoutBuilderConfigurer; public CollapsingTextHelper(View view) { this.view = view; textPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG | Paint.SUBPIXEL_TEXT_FLAG); tmpPaint = new TextPaint(textPaint); collapsedBounds = new Rect(); expandedBounds = new Rect(); currentBounds = new RectF(); fadeModeThresholdFraction = calculateFadeModeThresholdFraction(); maybeUpdateFontWeightAdjustment(view.getContext().getResources().getConfiguration()); } public void setTextSizeInterpolator(TimeInterpolator interpolator) { textSizeInterpolator = interpolator; recalculate(); } public void setPositionInterpolator(TimeInterpolator interpolator) { positionInterpolator = interpolator; recalculate(); } @Nullable public TimeInterpolator getPositionInterpolator() { return positionInterpolator; } public void setExpandedTextSize(float textSize) { if (expandedTextSize != textSize) { expandedTextSize = textSize; recalculate(); } } public void setCollapsedTextSize(float textSize) { if (collapsedTextSize != textSize) { collapsedTextSize = textSize; recalculate(); } } public void setCollapsedTextColor(ColorStateList textColor) { if (collapsedTextColor != textColor) { collapsedTextColor = textColor; recalculate(); } } public void setExpandedTextColor(ColorStateList textColor) { if (expandedTextColor != textColor) { expandedTextColor = textColor; recalculate(); } } public void setCollapsedAndExpandedTextColor(@Nullable ColorStateList textColor) { if (collapsedTextColor != textColor || expandedTextColor != textColor) { collapsedTextColor = textColor; expandedTextColor = textColor; recalculate(); } } public void setExpandedLetterSpacing(float letterSpacing) { if (expandedLetterSpacing != letterSpacing) { expandedLetterSpacing = letterSpacing; recalculate(); } } public void setExpandedBounds(int left, int top, int right, int bottom) { if (!rectEquals(expandedBounds, left, top, right, bottom)) { expandedBounds.set(left, top, right, bottom); boundsChanged = true; } } public void setExpandedBounds(@NonNull Rect bounds) { setExpandedBounds(bounds.left, bounds.top, bounds.right, bounds.bottom); } public void setCollapsedBounds(int left, int top, int right, int bottom) { if (!rectEquals(collapsedBounds, left, top, right, bottom)) { collapsedBounds.set(left, top, right, bottom); boundsChanged = true; } } public void setCollapsedBounds(@NonNull Rect bounds) { setCollapsedBounds(bounds.left, bounds.top, bounds.right, bounds.bottom); } public void getCollapsedTextActualBounds(@NonNull RectF bounds, int labelWidth, int textGravity) { isRtl = calculateIsRtl(text); bounds.left = max(getCollapsedTextLeftBound(labelWidth, textGravity), collapsedBounds.left); bounds.top = collapsedBounds.top; bounds.right = min(getCollapsedTextRightBound(bounds, labelWidth, textGravity), collapsedBounds.right); bounds.bottom = collapsedBounds.top + getCollapsedTextHeight(); } private float getCollapsedTextLeftBound(int width, int gravity) { if (gravity == Gravity.CENTER || (gravity & Gravity.HORIZONTAL_GRAVITY_MASK) == Gravity.CENTER_HORIZONTAL) { return width / 2f - collapsedTextWidth / 2; } else if ((gravity & Gravity.END) == Gravity.END || (gravity & Gravity.RIGHT) == Gravity.RIGHT) { return isRtl ? collapsedBounds.left : (collapsedBounds.right - collapsedTextWidth); } else { return isRtl ? (collapsedBounds.right - collapsedTextWidth) : collapsedBounds.left; } } private float getCollapsedTextRightBound(@NonNull RectF bounds, int width, int gravity) { if (gravity == Gravity.CENTER || (gravity & Gravity.HORIZONTAL_GRAVITY_MASK) == Gravity.CENTER_HORIZONTAL) { return width / 2f + collapsedTextWidth / 2; } else if ((gravity & Gravity.END) == Gravity.END || (gravity & Gravity.RIGHT) == Gravity.RIGHT) { return isRtl ? (bounds.left + collapsedTextWidth) : collapsedBounds.right; } else { return isRtl ? collapsedBounds.right : (bounds.left + collapsedTextWidth); } } public float getExpandedTextHeight() { getTextPaintExpanded(tmpPaint); // Return expanded height measured from the baseline. return -tmpPaint.ascent(); } public float getExpandedTextFullHeight() { getTextPaintExpanded(tmpPaint); // Return expanded height measured from the baseline. return -tmpPaint.ascent() + tmpPaint.descent(); } public float getCollapsedTextHeight() { getTextPaintCollapsed(tmpPaint); // Return collapsed height measured from the baseline. return -tmpPaint.ascent(); } public void setCurrentOffsetY(int currentOffsetY) { this.currentOffsetY = currentOffsetY; } public void setFadeModeStartFraction(float fadeModeStartFraction) { this.fadeModeStartFraction = fadeModeStartFraction; fadeModeThresholdFraction = calculateFadeModeThresholdFraction(); } private float calculateFadeModeThresholdFraction() { return fadeModeStartFraction + (1 - fadeModeStartFraction) * FADE_MODE_THRESHOLD_FRACTION_RELATIVE; } public void setFadeModeEnabled(boolean fadeModeEnabled) { this.fadeModeEnabled = fadeModeEnabled; } private void getTextPaintExpanded(@NonNull TextPaint textPaint) { textPaint.setTextSize(expandedTextSize); textPaint.setTypeface(expandedTypeface); if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) { textPaint.setLetterSpacing(expandedLetterSpacing); } } private void getTextPaintCollapsed(@NonNull TextPaint textPaint) { textPaint.setTextSize(collapsedTextSize); textPaint.setTypeface(collapsedTypeface); if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) { textPaint.setLetterSpacing(collapsedLetterSpacing); } } public void setExpandedTextGravity(int gravity) { if (expandedTextGravity != gravity) { expandedTextGravity = gravity; recalculate(); } } public int getExpandedTextGravity() { return expandedTextGravity; } public void setCollapsedTextGravity(int gravity) { if (collapsedTextGravity != gravity) { collapsedTextGravity = gravity; recalculate(); } } public int getCollapsedTextGravity() { return collapsedTextGravity; } public void setCollapsedTextAppearance(int resId) { TextAppearance textAppearance = new TextAppearance(view.getContext(), resId); if (textAppearance.getTextColor() != null) { collapsedTextColor = textAppearance.getTextColor(); } if (textAppearance.getTextSize() != 0) { collapsedTextSize = textAppearance.getTextSize(); } if (textAppearance.shadowColor != null) { collapsedShadowColor = textAppearance.shadowColor; } collapsedShadowDx = textAppearance.shadowDx; collapsedShadowDy = textAppearance.shadowDy; collapsedShadowRadius = textAppearance.shadowRadius; collapsedLetterSpacing = textAppearance.letterSpacing; // Cancel pending async fetch, if any, and replace with a new one. if (collapsedFontCallback != null) { collapsedFontCallback.cancel(); } collapsedFontCallback = new CancelableFontCallback( new ApplyFont() { @Override public void apply(Typeface font) { setCollapsedTypeface(font); } }, textAppearance.getFallbackFont()); textAppearance.getFontAsync(view.getContext(), collapsedFontCallback); recalculate(); } public void setExpandedTextAppearance(int resId) { TextAppearance textAppearance = new TextAppearance(view.getContext(), resId); if (textAppearance.getTextColor() != null) { expandedTextColor = textAppearance.getTextColor(); } if (textAppearance.getTextSize() != 0) { expandedTextSize = textAppearance.getTextSize(); } if (textAppearance.shadowColor != null) { expandedShadowColor = textAppearance.shadowColor; } expandedShadowDx = textAppearance.shadowDx; expandedShadowDy = textAppearance.shadowDy; expandedShadowRadius = textAppearance.shadowRadius; expandedLetterSpacing = textAppearance.letterSpacing; // Cancel pending async fetch, if any, and replace with a new one. if (expandedFontCallback != null) { expandedFontCallback.cancel(); } expandedFontCallback = new CancelableFontCallback( new ApplyFont() { @Override public void apply(Typeface font) { setExpandedTypeface(font); } }, textAppearance.getFallbackFont()); textAppearance.getFontAsync(view.getContext(), expandedFontCallback); recalculate(); } public void setTitleTextEllipsize(@NonNull TruncateAt ellipsize) { titleTextEllipsize = ellipsize; recalculate(); } @NonNull public TruncateAt getTitleTextEllipsize() { return titleTextEllipsize; } public void setCollapsedTypeface(Typeface typeface) { if (setCollapsedTypefaceInternal(typeface)) { recalculate(); } } public void setExpandedTypeface(Typeface typeface) { if (setExpandedTypefaceInternal(typeface)) { recalculate(); } } public void setTypefaces(Typeface typeface) { boolean collapsedFontChanged = setCollapsedTypefaceInternal(typeface); boolean expandedFontChanged = setExpandedTypefaceInternal(typeface); if (collapsedFontChanged || expandedFontChanged) { recalculate(); } } @SuppressWarnings("ReferenceEquality") // Matches the Typeface comparison in TextView private boolean setCollapsedTypefaceInternal(Typeface typeface) { // Explicit Typeface setting cancels pending async fetch, if any, to avoid old font overriding // already updated one when async op comes back after a while. if (collapsedFontCallback != null) { collapsedFontCallback.cancel(); } if (collapsedTypefaceDefault != typeface) { collapsedTypefaceDefault = typeface; collapsedTypefaceBold = TypefaceUtils.maybeCopyWithFontWeightAdjustment( view.getContext().getResources().getConfiguration(), typeface); collapsedTypeface = collapsedTypefaceBold == null ? collapsedTypefaceDefault : collapsedTypefaceBold; return true; } return false; } @SuppressWarnings("ReferenceEquality") // Matches the Typeface comparison in TextView private boolean setExpandedTypefaceInternal(Typeface typeface) { // Explicit Typeface setting cancels pending async fetch, if any, to avoid old font overriding // already updated one when async op comes back after a while. if (expandedFontCallback != null) { expandedFontCallback.cancel(); } if (expandedTypefaceDefault != typeface) { expandedTypefaceDefault = typeface; expandedTypefaceBold = TypefaceUtils.maybeCopyWithFontWeightAdjustment( view.getContext().getResources().getConfiguration(), typeface); expandedTypeface = expandedTypefaceBold == null ? expandedTypefaceDefault : expandedTypefaceBold; return true; } return false; } public Typeface getCollapsedTypeface() { return collapsedTypeface != null ? collapsedTypeface : Typeface.DEFAULT; } public Typeface getExpandedTypeface() { return expandedTypeface != null ? expandedTypeface : Typeface.DEFAULT; } public void maybeUpdateFontWeightAdjustment(@NonNull Configuration configuration) { if (VERSION.SDK_INT >= VERSION_CODES.S) { if (collapsedTypefaceDefault != null) { collapsedTypefaceBold = TypefaceUtils.maybeCopyWithFontWeightAdjustment( configuration, collapsedTypefaceDefault); } if (expandedTypefaceDefault != null) { expandedTypefaceBold = TypefaceUtils.maybeCopyWithFontWeightAdjustment(configuration, expandedTypefaceDefault); } collapsedTypeface = collapsedTypefaceBold != null ? collapsedTypefaceBold : collapsedTypefaceDefault; expandedTypeface = expandedTypefaceBold != null ? expandedTypefaceBold : expandedTypefaceDefault; recalculate(/* forceRecalculate= */ true); } } /** * Set the value indicating the current scroll value. This decides how much of the background will * be displayed, as well as the title metrics/positioning. * *

A value of {@code 0.0} indicates that the layout is fully expanded. A value of {@code 1.0} * indicates that the layout is fully collapsed. */ public void setExpansionFraction(float fraction) { fraction = MathUtils.clamp(fraction, 0f, 1f); if (fraction != expandedFraction) { expandedFraction = fraction; calculateCurrentOffsets(); } } public final boolean setState(final int[] state) { this.state = state; if (isStateful()) { recalculate(); return true; } return false; } public final boolean isStateful() { return (collapsedTextColor != null && collapsedTextColor.isStateful()) || (expandedTextColor != null && expandedTextColor.isStateful()); } public float getFadeModeThresholdFraction() { return fadeModeThresholdFraction; } public float getExpansionFraction() { return expandedFraction; } public float getCollapsedTextSize() { return collapsedTextSize; } public float getExpandedTextSize() { return expandedTextSize; } public void setRtlTextDirectionHeuristicsEnabled(boolean rtlTextDirectionHeuristicsEnabled) { isRtlTextDirectionHeuristicsEnabled = rtlTextDirectionHeuristicsEnabled; } public boolean isRtlTextDirectionHeuristicsEnabled() { return isRtlTextDirectionHeuristicsEnabled; } private void calculateCurrentOffsets() { calculateOffsets(expandedFraction); } private void calculateOffsets(final float fraction) { interpolateBounds(fraction); float textBlendFraction; if (fadeModeEnabled) { if (fraction < fadeModeThresholdFraction) { textBlendFraction = 0F; currentDrawX = expandedDrawX; currentDrawY = expandedDrawY; setInterpolatedTextSize(/* fraction= */ 0); } else { textBlendFraction = 1F; currentDrawX = collapsedDrawX; currentDrawY = collapsedDrawY - max(0, currentOffsetY); setInterpolatedTextSize(/* fraction= */ 1); } } else { textBlendFraction = fraction; currentDrawX = lerp(expandedDrawX, collapsedDrawX, fraction, positionInterpolator); currentDrawY = lerp(expandedDrawY, collapsedDrawY, fraction, positionInterpolator); setInterpolatedTextSize(fraction); } setCollapsedTextBlend( 1 - lerp(0, 1, 1 - fraction, AnimationUtils.FAST_OUT_SLOW_IN_INTERPOLATOR)); setExpandedTextBlend(lerp(1, 0, fraction, AnimationUtils.FAST_OUT_SLOW_IN_INTERPOLATOR)); if (collapsedTextColor != expandedTextColor) { // If the collapsed and expanded text colors are different, blend them based on the // fraction textPaint.setColor( blendARGB( getCurrentExpandedTextColor(), getCurrentCollapsedTextColor(), textBlendFraction)); } else { textPaint.setColor(getCurrentCollapsedTextColor()); } if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) { if (collapsedLetterSpacing != expandedLetterSpacing) { textPaint.setLetterSpacing( lerp( expandedLetterSpacing, collapsedLetterSpacing, fraction, AnimationUtils.FAST_OUT_SLOW_IN_INTERPOLATOR)); } else { textPaint.setLetterSpacing(collapsedLetterSpacing); } } // Calculates paint parameters for shadow layer. currentShadowRadius = lerp(expandedShadowRadius, collapsedShadowRadius, fraction, null); currentShadowDx = lerp(expandedShadowDx, collapsedShadowDx, fraction, null); currentShadowDy = lerp(expandedShadowDy, collapsedShadowDy, fraction, null); currentShadowColor = blendARGB( getCurrentColor(expandedShadowColor), getCurrentColor(collapsedShadowColor), fraction); textPaint.setShadowLayer( currentShadowRadius, currentShadowDx, currentShadowDy, currentShadowColor); if (fadeModeEnabled) { int originalAlpha = textPaint.getAlpha(); // Calculates new alpha as a ratio of original alpha based on position. int textAlpha = (int) (calculateFadeModeTextAlpha(fraction) * originalAlpha); textPaint.setAlpha(textAlpha); // Workaround for API 31(+). Applying the shadow color for the painted text. if (VERSION.SDK_INT >= VERSION_CODES.S) { textPaint.setShadowLayer( currentShadowRadius, currentShadowDx, currentShadowDy, MaterialColors.compositeARGBWithAlpha(currentShadowColor, textPaint.getAlpha())); } } ViewCompat.postInvalidateOnAnimation(view); } private float calculateFadeModeTextAlpha(@FloatRange(from = 0.0, to = 1.0) float fraction) { if (fraction <= fadeModeThresholdFraction) { return AnimationUtils.lerp( /* startValue= */ 1, /* endValue= */ 0, /* startFraction= */ fadeModeStartFraction, /* endFraction= */ fadeModeThresholdFraction, fraction); } else { return AnimationUtils.lerp( /* startValue= */ 0, /* endValue= */ 1, /* startFraction= */ fadeModeThresholdFraction, /* endFraction= */ 1, fraction); } } @ColorInt private int getCurrentExpandedTextColor() { return getCurrentColor(expandedTextColor); } @ColorInt public int getCurrentCollapsedTextColor() { return getCurrentColor(collapsedTextColor); } @ColorInt private int getCurrentColor(@Nullable ColorStateList colorStateList) { if (colorStateList == null) { return 0; } if (state != null) { return colorStateList.getColorForState(state, 0); } return colorStateList.getDefaultColor(); } private void calculateBaseOffsets(boolean forceRecalculate) { // We then calculate the collapsed text size, using the same logic calculateUsingTextSize(/* fraction= */ 1, forceRecalculate); if (textToDraw != null && textLayout != null) { textToDrawCollapsed = TextUtils.ellipsize(textToDraw, textPaint, textLayout.getWidth(), titleTextEllipsize); } if (textToDrawCollapsed != null) { collapsedTextWidth = measureTextWidth(textPaint, textToDrawCollapsed); } else { collapsedTextWidth = 0; } final int collapsedAbsGravity = GravityCompat.getAbsoluteGravity( collapsedTextGravity, isRtl ? ViewCompat.LAYOUT_DIRECTION_RTL : ViewCompat.LAYOUT_DIRECTION_LTR); switch (collapsedAbsGravity & Gravity.VERTICAL_GRAVITY_MASK) { case Gravity.BOTTOM: collapsedDrawY = collapsedBounds.bottom + textPaint.ascent(); break; case Gravity.TOP: collapsedDrawY = collapsedBounds.top; break; case Gravity.CENTER_VERTICAL: default: float textOffset = (textPaint.descent() - textPaint.ascent()) / 2; collapsedDrawY = collapsedBounds.centerY() - textOffset; break; } switch (collapsedAbsGravity & GravityCompat.RELATIVE_HORIZONTAL_GRAVITY_MASK) { case Gravity.CENTER_HORIZONTAL: collapsedDrawX = collapsedBounds.centerX() - (collapsedTextWidth / 2); break; case Gravity.RIGHT: collapsedDrawX = collapsedBounds.right - collapsedTextWidth; break; case Gravity.LEFT: default: collapsedDrawX = collapsedBounds.left; break; } calculateUsingTextSize(/* fraction= */ 0, forceRecalculate); float expandedTextHeight = textLayout != null ? textLayout.getHeight() : 0; float expandedTextWidth = 0; if (textLayout != null && maxLines > 1) { expandedTextWidth = textLayout.getWidth(); } else if (textToDraw != null) { expandedTextWidth = measureTextWidth(textPaint, textToDraw); } expandedLineCount = textLayout != null ? textLayout.getLineCount() : 0; final int expandedAbsGravity = GravityCompat.getAbsoluteGravity( expandedTextGravity, isRtl ? ViewCompat.LAYOUT_DIRECTION_RTL : ViewCompat.LAYOUT_DIRECTION_LTR); switch (expandedAbsGravity & Gravity.VERTICAL_GRAVITY_MASK) { case Gravity.BOTTOM: expandedDrawY = expandedBounds.bottom - expandedTextHeight + textPaint.descent(); break; case Gravity.TOP: expandedDrawY = expandedBounds.top; break; case Gravity.CENTER_VERTICAL: default: float textOffset = expandedTextHeight / 2; expandedDrawY = expandedBounds.centerY() - textOffset; break; } switch (expandedAbsGravity & GravityCompat.RELATIVE_HORIZONTAL_GRAVITY_MASK) { case Gravity.CENTER_HORIZONTAL: expandedDrawX = expandedBounds.centerX() - (expandedTextWidth / 2); break; case Gravity.RIGHT: expandedDrawX = expandedBounds.right - expandedTextWidth; break; case Gravity.LEFT: default: expandedDrawX = expandedBounds.left; break; } // Now reset the text size back to the original setInterpolatedTextSize(expandedFraction); } private float measureTextWidth(TextPaint textPaint, CharSequence textToDraw) { return textPaint.measureText(textToDraw, 0, textToDraw.length()); } private void interpolateBounds(float fraction) { if (fadeModeEnabled) { currentBounds.set(fraction < fadeModeThresholdFraction ? expandedBounds : collapsedBounds); } else { currentBounds.left = lerp(expandedBounds.left, collapsedBounds.left, fraction, positionInterpolator); currentBounds.top = lerp(expandedDrawY, collapsedDrawY, fraction, positionInterpolator); currentBounds.right = lerp(expandedBounds.right, collapsedBounds.right, fraction, positionInterpolator); currentBounds.bottom = lerp(expandedBounds.bottom, collapsedBounds.bottom, fraction, positionInterpolator); } } private void setCollapsedTextBlend(float blend) { collapsedTextBlend = blend; ViewCompat.postInvalidateOnAnimation(view); } private void setExpandedTextBlend(float blend) { expandedTextBlend = blend; ViewCompat.postInvalidateOnAnimation(view); } public void draw(@NonNull Canvas canvas) { final int saveCount = canvas.save(); // Compute where to draw textLayout for this frame if (textToDraw != null && currentBounds.width() > 0 && currentBounds.height() > 0) { textPaint.setTextSize(currentTextSize); float x = currentDrawX; float y = currentDrawY; if (DEBUG_DRAW) { // Just a debug tool, which drawn a magenta rect in the text bounds canvas.drawRect( x, y, x + textLayout.getWidth() * scale, y + textLayout.getHeight() * scale, DEBUG_DRAW_PAINT); } if (scale != 1f && !fadeModeEnabled) { canvas.scale(scale, scale, x, y); } if (shouldDrawMultiline() && (!fadeModeEnabled || expandedFraction > fadeModeThresholdFraction)) { drawMultilineTransition(canvas, currentDrawX - textLayout.getLineStart(0), y); } else { canvas.translate(x, y); textLayout.draw(canvas); } canvas.restoreToCount(saveCount); } } private boolean shouldDrawMultiline() { return maxLines > 1 && (!isRtl || fadeModeEnabled); } private void drawMultilineTransition(@NonNull Canvas canvas, float currentExpandedX, float y) { int originalAlpha = textPaint.getAlpha(); // position text appropriately canvas.translate(currentExpandedX, y); if (!fadeModeEnabled) { // Expanded text (when not in fade mode, because in fade mode at this point the expanded text // has been fully faded out, so there's no need to try to draw it again) textPaint.setAlpha((int) (expandedTextBlend * originalAlpha)); // Workaround for API 31(+). Paint applies an inverse alpha of Paint object on the shadow // layer when collapsing mode is scale and shadow color is opaque. The workaround is to set // the shadow not opaque. Then Paint will respect to the color's alpha. Applying the shadow // color for expanded text. if (VERSION.SDK_INT >= VERSION_CODES.S) { textPaint.setShadowLayer( currentShadowRadius, currentShadowDx, currentShadowDy, MaterialColors.compositeARGBWithAlpha(currentShadowColor, textPaint.getAlpha())); } textLayout.draw(canvas); } // Collapsed text if (!fadeModeEnabled) { // Only change the collapsed text alpha when not in fade mode, because when in fade mode it // will be precalculated based on the current fraction in calculateOffsets() textPaint.setAlpha((int) (collapsedTextBlend * originalAlpha)); } // Workaround for API 31(+). Applying the shadow color for collapsed text. if (VERSION.SDK_INT >= VERSION_CODES.S) { textPaint.setShadowLayer( currentShadowRadius, currentShadowDx, currentShadowDy, MaterialColors.compositeARGBWithAlpha(currentShadowColor, textPaint.getAlpha())); } int lineBaseline = textLayout.getLineBaseline(0); canvas.drawText( textToDrawCollapsed, /* start = */ 0, textToDrawCollapsed.length(), /* x = */ 0, lineBaseline, textPaint); // Reverse workaround for API 31(+). Applying opaque shadow color after the expanded text and // the collapsed text are drawn. if (VERSION.SDK_INT >= VERSION_CODES.S) { textPaint.setShadowLayer( currentShadowRadius, currentShadowDx, currentShadowDy, currentShadowColor); } if (!fadeModeEnabled) { // Remove ellipsis for Cross-section animation String tmp = textToDrawCollapsed.toString().trim(); if (tmp.endsWith(ELLIPSIS_NORMAL)) { tmp = tmp.substring(0, tmp.length() - 1); } // Cross-section between both texts (should stay at original alpha) textPaint.setAlpha(originalAlpha); canvas.drawText( tmp, /* start = */ 0, min(textLayout.getLineEnd(0), tmp.length()), /* x = */ 0, lineBaseline, textPaint); } } private boolean calculateIsRtl(@NonNull CharSequence text) { final boolean defaultIsRtl = isDefaultIsRtl(); return isRtlTextDirectionHeuristicsEnabled ? isTextDirectionHeuristicsIsRtl(text, defaultIsRtl) : defaultIsRtl; } private boolean isDefaultIsRtl() { return ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_RTL; } private boolean isTextDirectionHeuristicsIsRtl(@NonNull CharSequence text, boolean defaultIsRtl) { return (defaultIsRtl ? TextDirectionHeuristicsCompat.FIRSTSTRONG_RTL : TextDirectionHeuristicsCompat.FIRSTSTRONG_LTR) .isRtl(text, 0, text.length()); } private void setInterpolatedTextSize(float fraction) { calculateUsingTextSize(fraction); ViewCompat.postInvalidateOnAnimation(view); } private void calculateUsingTextSize(final float fraction) { calculateUsingTextSize(fraction, /* forceRecalculate= */ false); } @SuppressWarnings("ReferenceEquality") // Matches the Typeface comparison in TextView private void calculateUsingTextSize(final float fraction, boolean forceRecalculate) { if (text == null) { return; } float collapsedWidth = collapsedBounds.width(); float expandedWidth = expandedBounds.width(); float availableWidth; float newTextSize; float newLetterSpacing; Typeface newTypeface; if (isClose(fraction, /* targetValue= */ 1)) { newTextSize = collapsedTextSize; newLetterSpacing = collapsedLetterSpacing; scale = 1f; newTypeface = collapsedTypeface; availableWidth = collapsedWidth; } else { newTextSize = expandedTextSize; newLetterSpacing = expandedLetterSpacing; newTypeface = expandedTypeface; if (isClose(fraction, /* targetValue= */ 0)) { // If we're close to the expanded text size, snap to it and use a scale of 1 scale = 1f; } else { // Else, we'll scale down from the expanded text size scale = lerp(expandedTextSize, collapsedTextSize, fraction, textSizeInterpolator) / expandedTextSize; } float textSizeRatio = collapsedTextSize / expandedTextSize; // This is the size of the expanded bounds when it is scaled to match the // collapsed text size float scaledDownWidth = expandedWidth * textSizeRatio; if (forceRecalculate || fadeModeEnabled) { // If we're forcing a recalculate during a measure pass, use the expanded width since the // collapsed width might not be ready yet // Or if the fade mode is enabled, we can also just use the expanded width because when // fading out/in there is not a continuous scale transition between expanded/collapsed text availableWidth = expandedWidth; } else { // If the scaled down size is larger than the actual collapsed width, we need to // cap the available width so that when the expanded text scales down, it matches // the collapsed width // Otherwise we'll just use the expanded width availableWidth = scaledDownWidth > collapsedWidth ? min(collapsedWidth / textSizeRatio, expandedWidth) : expandedWidth; } } boolean updateDrawText; if (availableWidth > 0) { boolean textSizeChanged = currentTextSize != newTextSize; boolean letterSpacingChanged = currentLetterSpacing != newLetterSpacing; boolean typefaceChanged = currentTypeface != newTypeface; boolean availableWidthChanged = textLayout != null && availableWidth != textLayout.getWidth(); updateDrawText = textSizeChanged || letterSpacingChanged || availableWidthChanged || typefaceChanged || boundsChanged; currentTextSize = newTextSize; currentLetterSpacing = newLetterSpacing; currentTypeface = newTypeface; boundsChanged = false; // Use linear text scaling if we're scaling the canvas textPaint.setLinearText(scale != 1f); } else { updateDrawText = false; } if (textToDraw == null || updateDrawText) { textPaint.setTextSize(currentTextSize); textPaint.setTypeface(currentTypeface); if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) { textPaint.setLetterSpacing(currentLetterSpacing); } isRtl = calculateIsRtl(text); textLayout = createStaticLayout(shouldDrawMultiline() ? maxLines : 1, availableWidth, isRtl); textToDraw = textLayout.getText(); } } private StaticLayout createStaticLayout(int maxLines, float availableWidth, boolean isRtl) { StaticLayout textLayout = null; try { // In multiline mode, the text alignment should be controlled by the static layout. Alignment textAlignment = maxLines == 1 ? ALIGN_NORMAL : getMultilineTextLayoutAlignment(); textLayout = StaticLayoutBuilderCompat.obtain(text, textPaint, (int) availableWidth) .setEllipsize(titleTextEllipsize) .setIsRtl(isRtl) .setAlignment(textAlignment) .setIncludePad(false) .setMaxLines(maxLines) .setLineSpacing(lineSpacingAdd, lineSpacingMultiplier) .setHyphenationFrequency(hyphenationFrequency) .setStaticLayoutBuilderConfigurer(staticLayoutBuilderConfigurer) .build(); } catch (StaticLayoutBuilderCompatException e) { Log.e(TAG, e.getCause().getMessage(), e); } return checkNotNull(textLayout); } private Alignment getMultilineTextLayoutAlignment() { int absoluteGravity = GravityCompat.getAbsoluteGravity( expandedTextGravity, isRtl ? ViewCompat.LAYOUT_DIRECTION_RTL : ViewCompat.LAYOUT_DIRECTION_LTR); switch (absoluteGravity & Gravity.HORIZONTAL_GRAVITY_MASK) { case Gravity.CENTER_HORIZONTAL: return ALIGN_CENTER; case Gravity.RIGHT: return isRtl ? ALIGN_NORMAL : ALIGN_OPPOSITE; default: return isRtl ? ALIGN_OPPOSITE : ALIGN_NORMAL; } } public void recalculate() { recalculate(/* forceRecalculate= */ false); } public void recalculate(boolean forceRecalculate) { if ((view.getHeight() > 0 && view.getWidth() > 0) || forceRecalculate) { // If we've already been laid out, calculate everything now otherwise we'll wait // until a layout calculateBaseOffsets(forceRecalculate); calculateCurrentOffsets(); } } /** * Set the title to display * * @param text */ public void setText(@Nullable CharSequence text) { if (text == null || !TextUtils.equals(this.text, text)) { this.text = text; textToDraw = null; recalculate(); } } @Nullable public CharSequence getText() { return text; } public void setMaxLines(int maxLines) { if (maxLines != this.maxLines) { this.maxLines = maxLines; recalculate(); } } public int getMaxLines() { return maxLines; } /** * Returns the current text line count. * * @return The current text line count. */ public int getLineCount() { return textLayout != null ? textLayout.getLineCount() : 0; } /** * Returns the expanded text line count. * * @return The expanded text line count. */ public int getExpandedLineCount() { return expandedLineCount; } @RequiresApi(VERSION_CODES.M) public void setLineSpacingAdd(float spacingAdd) { this.lineSpacingAdd = spacingAdd; } @RequiresApi(VERSION_CODES.M) public float getLineSpacingAdd() { return textLayout.getSpacingAdd(); } @RequiresApi(VERSION_CODES.M) public void setLineSpacingMultiplier(@FloatRange(from = 0.0) float spacingMultiplier) { this.lineSpacingMultiplier = spacingMultiplier; } @RequiresApi(VERSION_CODES.M) public float getLineSpacingMultiplier() { return textLayout.getSpacingMultiplier(); } @RequiresApi(VERSION_CODES.M) public void setHyphenationFrequency(int hyphenationFrequency) { this.hyphenationFrequency = hyphenationFrequency; } @RequiresApi(VERSION_CODES.M) public int getHyphenationFrequency() { return hyphenationFrequency; } @RequiresApi(VERSION_CODES.M) public void setStaticLayoutBuilderConfigurer( @Nullable StaticLayoutBuilderConfigurer staticLayoutBuilderConfigurer) { if (this.staticLayoutBuilderConfigurer != staticLayoutBuilderConfigurer) { this.staticLayoutBuilderConfigurer = staticLayoutBuilderConfigurer; recalculate(/* forceRecalculate= */ true); } } /** * Returns true if {@code value} is 'close' to it's closest decimal value. Close is currently * defined as it's difference being < 0.00001. */ private static boolean isClose(float value, float targetValue) { return Math.abs(value - targetValue) < 0.00001f; } public ColorStateList getExpandedTextColor() { return expandedTextColor; } public ColorStateList getCollapsedTextColor() { return collapsedTextColor; } /** * Blend between two ARGB colors using the given ratio. * *

A blend ratio of 0.0 will result in {@code color1}, 0.5 will give an even blend, 1.0 will * result in {@code color2}. * *

This is different from the AndroidX implementation by rounding the blended channel values * with {@link Math#round(float)}. * * @param color1 the first ARGB color * @param color2 the second ARGB color * @param ratio the blend ratio of {@code color1} to {@code color2} */ @ColorInt private static int blendARGB( @ColorInt int color1, @ColorInt int color2, @FloatRange(from = 0.0, to = 1.0) float ratio) { final float inverseRatio = 1 - ratio; float a = Color.alpha(color1) * inverseRatio + Color.alpha(color2) * ratio; float r = Color.red(color1) * inverseRatio + Color.red(color2) * ratio; float g = Color.green(color1) * inverseRatio + Color.green(color2) * ratio; float b = Color.blue(color1) * inverseRatio + Color.blue(color2) * ratio; return Color.argb(Math.round(a), Math.round(r), Math.round(g), Math.round(b)); } private static float lerp( float startValue, float endValue, float fraction, @Nullable TimeInterpolator interpolator) { if (interpolator != null) { fraction = interpolator.getInterpolation(fraction); } return AnimationUtils.lerp(startValue, endValue, fraction); } private static boolean rectEquals(@NonNull Rect r, int left, int top, int right, int bottom) { return !(r.left != left || r.top != top || r.right != right || r.bottom != bottom); } }