/* * Copyright 2015 Google Inc. * * 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 io.plaidapp.ui.widget; import android.annotation.TargetApi; import android.content.Context; import android.content.res.TypedArray; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.Point; import android.os.Build; import android.support.annotation.Nullable; import android.support.v4.view.GravityCompat; import android.support.v4.view.ViewCompat; import android.text.Layout; import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.StaticLayout; import android.text.TextPaint; import android.text.TextUtils; import android.util.AttributeSet; import android.util.TypedValue; import android.view.Gravity; import android.view.View; import android.widget.FrameLayout; import io.plaidapp.R; import io.plaidapp.ui.span.TextColorSpan; import io.plaidapp.ui.transitions.ReflowText; import io.plaidapp.util.CollapsingTextHelper; import io.plaidapp.util.FontUtil; import io.plaidapp.util.ViewUtils; /** * A layout that draws a title and can collapse down to a condensed size. If the title shows over * multiple lines then it will fade out line by line as it collapses. If the title is a single * line then text is initially displayed as large as possible and then scaled down to fit the within * the collapsed size. */ public class CollapsingTitleLayout extends FrameLayout implements ReflowText.Reflowable { // constants private static final int BREAK_STRATEGY = Layout.BREAK_STRATEGY_HIGH_QUALITY; // configurable attributes private int titleInsetStart; private float titleInsetTop; private int titleInsetEnd; private int titleInsetBottom; private float collapsedTextSize; private float maxExpandedTextSize; private float lineHeightHint; private int maxLines; // state private CharSequence title; private SpannableStringBuilder displayText; private TextPaint paint; private float textTop; private float scrollOffset; private int scrollRange; private float collapsedHeight; private CollapsingTextHelper collapsingText; private StaticLayout layout; private Line[] lines; private int calculatedWithWidth; private int lineCount; private float lineSpacingAdd; public CollapsingTitleLayout(Context context) { this(context, null, 0, 0); } public CollapsingTitleLayout(Context context, AttributeSet attrs) { this(context, attrs, 0, 0); } public CollapsingTitleLayout(Context context, AttributeSet attrs, int defStyleAttr) { this(context, attrs, defStyleAttr, 0); } public CollapsingTitleLayout(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); setWillNotDraw(false); paint = new TextPaint(Paint.ANTI_ALIAS_FLAG); TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CollapsingTitleLayout); final boolean isRtl = ViewCompat.getLayoutDirection(this) == ViewCompat.LAYOUT_DIRECTION_RTL; // first check if all insets set the same titleInsetStart = titleInsetEnd = titleInsetBottom = a.getDimensionPixelSize(R.styleable.CollapsingTitleLayout_titleInset, 0); titleInsetTop = titleInsetStart; if (a.hasValue(R.styleable.CollapsingTitleLayout_titleInsetStart)) { final int insetStart = a.getDimensionPixelSize( R.styleable.CollapsingTitleLayout_titleInsetStart, 0); if (isRtl) { titleInsetEnd = insetStart; } else { titleInsetStart = insetStart; } } if (a.hasValue(R.styleable.CollapsingTitleLayout_titleInsetTop)) { titleInsetTop = a.getDimensionPixelSize( R.styleable.CollapsingTitleLayout_titleInsetTop, 0); } if (a.hasValue(R.styleable.CollapsingTitleLayout_titleInsetEnd)) { final int insetEnd = a.getDimensionPixelSize( R.styleable.CollapsingTitleLayout_titleInsetEnd, 0); if (isRtl) { titleInsetStart = insetEnd; } else { titleInsetEnd = insetEnd; } } if (a.hasValue(R.styleable.CollapsingTitleLayout_titleInsetBottom)) { titleInsetBottom = a.getDimensionPixelSize( R.styleable.CollapsingTitleLayout_titleInsetBottom, 0); } final int textAppearance = a.getResourceId( R.styleable.CollapsingTitleLayout_android_textAppearance, android.R.style.TextAppearance); TypedArray atp = getContext().obtainStyledAttributes(textAppearance, R.styleable.CollapsingTextAppearance); paint.setColor(atp.getColor(R.styleable.CollapsingTextAppearance_android_textColor, Color.WHITE)); collapsedTextSize = atp.getDimensionPixelSize( R.styleable.CollapsingTextAppearance_android_textSize, 0); if (atp.hasValue(R.styleable.CollapsingTextAppearance_font)) { paint.setTypeface(FontUtil.get(getContext(), atp.getString(R.styleable.CollapsingTextAppearance_font))); } atp.recycle(); if (a.hasValue(R.styleable.CollapsingTitleLayout_collapsedTextSize)) { collapsedTextSize = a.getDimensionPixelSize( R.styleable.CollapsingTitleLayout_collapsedTextSize, 0); paint.setTextSize(collapsedTextSize); } maxExpandedTextSize = a.getDimensionPixelSize( R.styleable.CollapsingTitleLayout_maxExpandedTextSize, Integer.MAX_VALUE); lineHeightHint = a.getDimensionPixelSize(R.styleable.CollapsingTitleLayout_lineHeightHint, 0); maxLines = a.getInteger(R.styleable.CollapsingTitleLayout_android_maxLines, 5); a.recycle(); } public void setTitle(CharSequence title) { this.title = title; this.displayText = new SpannableStringBuilder(title); } public void setScrollPixelOffset(int offset) { if (scrollOffset != offset) { scrollOffset = offset; if (lineCount == 1) { setScrollOffsetSingleLine(); } else { setScrollOffsetMultiLine(); } ViewCompat.postInvalidateOnAnimation(this); } } @Override public void draw(Canvas canvas) { super.draw(canvas); if (lineCount == 1) { collapsingText.draw(canvas); } else { float x = titleInsetStart; float y = Math.max(textTop - scrollOffset, titleInsetTop); canvas.translate(x, y); canvas.clipRect(0, 0, getWidth() - titleInsetStart - titleInsetEnd, Math.max(getHeight() - scrollOffset, collapsedHeight) - y); layout.draw(canvas); } } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { final int width = MeasureSpec.getSize(widthMeasureSpec); if (width != calculatedWithWidth) { recalculate(width); } final int desiredHeight = getDesiredHeight(); int height; switch (MeasureSpec.getMode(heightMeasureSpec)) { case MeasureSpec.EXACTLY: height = MeasureSpec.getSize(heightMeasureSpec); break; case MeasureSpec.AT_MOST: height = Math.min(desiredHeight, MeasureSpec.getSize(heightMeasureSpec)); break; case MeasureSpec.UNSPECIFIED: default: height = desiredHeight; break; } setMeasuredDimension(width, height); measureChildren(widthMeasureSpec, MeasureSpec.makeMeasureSpec(height, MeasureSpec.AT_MOST)); } private int getDesiredHeight() { if (layout == null) return getMinimumHeight(); return Math.max( (int) (titleInsetTop + layout.getHeight() + titleInsetBottom), getMinimumHeight()); } private void recalculate(int width) { // reset stateful objects that might change over measure passes paint.setTextSize(collapsedTextSize); displayText = new SpannableStringBuilder(title); // Calculate line height; ensure it' a multiple of 4dp to sit on the grid final float fourDip = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 4, getResources().getDisplayMetrics()); Paint.FontMetricsInt fm = paint.getFontMetricsInt(); int fontHeight = Math.abs(fm.ascent - fm.descent) + fm.leading; final int baselineAlignedLineHeight = (int) (fourDip * (float) Math.ceil(lineHeightHint / fourDip)); lineSpacingAdd = Math.max(0, baselineAlignedLineHeight - fontHeight); // now create the layout with our desired insets & line height createLayout(width, lineSpacingAdd); // adjust the title top inset to vertically center text with the toolbar collapsedHeight = (int) Math.max(ViewUtils.getActionBarSize(getContext()), (fourDip + baselineAlignedLineHeight + fourDip)); titleInsetTop = (collapsedHeight - baselineAlignedLineHeight) / 2f; if (lineCount == 1) { // single line mode layout = null; collapsingText = new CollapsingTextHelper(this); collapsingText.setText(title); collapsingText.setCollapsedBounds(titleInsetStart, 0, width - titleInsetEnd, (int) collapsedHeight); collapsingText.setExpandedBounds(titleInsetStart, (int) titleInsetTop, width - titleInsetEnd, getMinimumHeight() - titleInsetBottom); collapsingText.setCollapsedTextColor(paint.getColor()); collapsingText.setExpandedTextColor(paint.getColor()); collapsingText.setCollapsedTextSize(collapsedTextSize); int expandedTitleTextSize = (int) Math.max(collapsedTextSize, ViewUtils.getSingleLineTextSize(displayText.toString(), paint, width - titleInsetStart - titleInsetEnd, collapsedTextSize, maxExpandedTextSize, 0.5f, getResources().getDisplayMetrics())); collapsingText.setExpandedTextSize(expandedTitleTextSize); collapsingText.setExpandedTextGravity(GravityCompat.START | Gravity.BOTTOM); collapsingText.setCollapsedTextGravity(GravityCompat.START | Gravity.CENTER_VERTICAL); collapsingText.setTypeface(paint.getTypeface()); textTop = getHeight() - titleInsetBottom - fontHeight; scrollRange = getMinimumHeight() - (int) collapsedHeight; } else { // multi-line mode // bottom align the text textTop = getDesiredHeight() - titleInsetBottom - layout.getHeight(); // pre-calculate at what scroll offsets lines should disappear scrollRange = (int) (textTop - titleInsetTop); final int fadeDistance = layout.getLineBottom(0) - layout.getLineBaseline(0); lines = new Line[lineCount]; for (int i = 1; i < lineCount; i++) { int lineBottomScrollOffset = scrollRange + ((lineCount - i - 1) * baselineAlignedLineHeight); lines[i] = new Line( layout.getLineStart(i), layout.getLineEnd(i), new TextColorSpan(paint.getColor()), lineBottomScrollOffset, lineBottomScrollOffset + fadeDistance); } } calculatedWithWidth = width; } private void createLayout(int width, float lineSpacingAdd) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { createLayoutM(width, lineSpacingAdd); } else { createLayoutPreM(width, lineSpacingAdd); } } @TargetApi(Build.VERSION_CODES.M) private void createLayoutM(int width, float lineSpacingAdd) { layout = StaticLayout.Builder.obtain(displayText, 0, displayText.length(), paint, width - titleInsetStart - titleInsetEnd) .setLineSpacing(lineSpacingAdd, 1f) .setMaxLines(maxLines) .setEllipsize(TextUtils.TruncateAt.END) .setBreakStrategy(BREAK_STRATEGY) .build(); lineCount = layout.getLineCount(); } private void createLayoutPreM(int width, float lineSpacingAdd) { layout = new StaticLayout(displayText, paint, width - titleInsetStart - titleInsetEnd, Layout.Alignment.ALIGN_NORMAL, 1f, lineSpacingAdd, true); lineCount = layout.getLineCount(); if (lineCount > maxLines) { // if it exceeds our max number of lines then truncate the text & recreate the layout int endIndex = layout.getLineEnd(maxLines - 1) - 2; // minus 2 chars for the ellipse displayText = new SpannableStringBuilder(title.subSequence(0, endIndex) + "…"); layout = new StaticLayout(displayText, paint, width - titleInsetStart - titleInsetEnd, Layout.Alignment.ALIGN_NORMAL, 1f, lineSpacingAdd, true); lineCount = maxLines; } } private void setScrollOffsetSingleLine() { // see how far we have scrolled as a fraction of the scroll range collapsingText.setExpansionFraction(Math.min(scrollOffset, scrollRange) / scrollRange); } private void setScrollOffsetMultiLine() { // loop over each line and check/set an appropriate alpha for the current scroll offset for (int i = 1; i < lineCount; i++) { Line line = lines[i]; float lineAlpha = 1f; if (scrollOffset >= line.zeroAlphaScrollOffset) { lineAlpha = 0f; } else if (scrollOffset <= line.fullAlphaScrollOffset) { lineAlpha = 1f; } else if (scrollOffset > line.fullAlphaScrollOffset && scrollOffset < line.zeroAlphaScrollOffset) { lineAlpha = 1f - (scrollOffset - line.fullAlphaScrollOffset) / (line.zeroAlphaScrollOffset - line.fullAlphaScrollOffset); } if (line.currentAlpha != lineAlpha) { // mutating a span does not re-draw, need to remove and re-set it displayText.removeSpan(line.span); line.span.setAlpha(lineAlpha); displayText.setSpan(line.span, line.startIndex, line.endIndex, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); line.currentAlpha = lineAlpha; } } } @Override public View getView() { return this; } @Override public String getText() { return title.toString(); } @Override public Point getTextPosition() { if (lineCount == 1) { return collapsingText.getTextTopLeft(); } else { return new Point(titleInsetStart, (int) Math.max(textTop - scrollOffset, titleInsetTop)); } } @Override public int getTextWidth() { return calculatedWithWidth - titleInsetStart - titleInsetEnd; } @Override public int getTextHeight() { return (int) (lineCount == 1 ? collapsingText.getExpandedBounds().height() : Math.max(getHeight() - scrollOffset, collapsedHeight)); } @Override public float getLineSpacingAdd() { return lineSpacingAdd; } @Override public float getLineSpacingMult() { return 1f; } @Override public int getBreakStrategy() { return BREAK_STRATEGY; } @Override public float getLetterSpacing() { return paint.getLetterSpacing(); } @Override public float getTextSize() { return lineCount == 1 ? collapsingText.getCurrentTextSize() : paint.getTextSize(); } @Override public int getTextColor() { return paint.getColor(); } @Nullable @Override public String getFontName() { return FontUtil.getName(paint.getTypeface()); } @Override public int getMaxLines() { return maxLines; } private class Line { public int startIndex; public int endIndex; public TextColorSpan span; public int fullAlphaScrollOffset; public int zeroAlphaScrollOffset; public float currentAlpha = 1f; public Line(int startIndex, int endIndex, TextColorSpan span, int fullAlphaScrollOffset, int zeroAlphaScrollOffset) { this.startIndex = startIndex; this.endIndex = endIndex; this.span = span; this.zeroAlphaScrollOffset = zeroAlphaScrollOffset; this.fullAlphaScrollOffset = fullAlphaScrollOffset; } } }