/* * Copyright (C) 2016 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.android.incallui.autoresizetext; import android.content.Context; import android.content.res.TypedArray; import android.graphics.RectF; import android.support.annotation.Nullable; import android.text.Layout.Alignment; import android.text.StaticLayout; import android.text.TextPaint; import android.util.AttributeSet; import android.util.DisplayMetrics; import android.util.SparseIntArray; import android.util.TypedValue; import android.widget.TextView; /** * A TextView that automatically scales its text to completely fill its allotted width. * *

Note: In some edge cases, the binary search algorithm to find the best fit may slightly * overshoot / undershoot its constraints. See a bug. No minimal repro case has been * found yet. A known workaround is the solution provided on StackOverflow: * http://stackoverflow.com/a/5535672 */ public class AutoResizeTextView extends TextView { private static final int NO_LINE_LIMIT = -1; private static final float DEFAULT_MIN_TEXT_SIZE = 16.0f; private static final int DEFAULT_RESIZE_STEP_UNIT = TypedValue.COMPLEX_UNIT_PX; private final DisplayMetrics displayMetrics = getResources().getDisplayMetrics(); private final RectF availableSpaceRect = new RectF(); private final SparseIntArray textSizesCache = new SparseIntArray(); private final TextPaint textPaint = new TextPaint(); private int resizeStepUnit = DEFAULT_RESIZE_STEP_UNIT; private float minTextSize = DEFAULT_MIN_TEXT_SIZE; private float maxTextSize; private int maxWidth; public AutoResizeTextView(Context context) { super(context, null, 0); initialize(context, null, 0, 0); } public AutoResizeTextView(Context context, AttributeSet attrs) { super(context, attrs, 0); initialize(context, attrs, 0, 0); } public AutoResizeTextView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); initialize(context, attrs, defStyleAttr, 0); } public AutoResizeTextView( Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); initialize(context, attrs, defStyleAttr, defStyleRes); } private void initialize( Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { TypedArray typedArray = context.getTheme().obtainStyledAttributes( attrs, R.styleable.AutoResizeTextView, defStyleAttr, defStyleRes); readAttrs(typedArray); typedArray.recycle(); textPaint.set(getPaint()); } /** * Although this overrides the setTextSize method from the TextView base class, it changes the * semantics a bit: Calling setTextSize now specifies the maximum text size to be used by this * view. If the text can't fit with that text size, the text size will be scaled down, up to the * minimum text size specified in {@link #setMinTextSize}. * *

Note that the final size unit will be truncated to the nearest integer value of the * specified unit. */ @Override public final void setTextSize(int unit, float size) { float maxTextSize = TypedValue.applyDimension(unit, size, displayMetrics); if (this.maxTextSize != maxTextSize) { this.maxTextSize = maxTextSize; // TODO(tobyj): It's not actually necessary to clear the whole cache here. To optimize cache // deletion we'd have to delete all entries in the cache with a value equal or larger than // MIN(old_max_size, new_max_size) when changing maxTextSize; and all entries with a value // equal or smaller than MAX(old_min_size, new_min_size) when changing minTextSize. textSizesCache.clear(); requestLayout(); } } /** * Sets the lower text size limit and invalidate the view. * *

The parameters follow the same behavior as they do in {@link #setTextSize}. * *

Note that the final size unit will be truncated to the nearest integer value of the * specified unit. */ public final void setMinTextSize(int unit, float size) { float minTextSize = TypedValue.applyDimension(unit, size, displayMetrics); if (this.minTextSize != minTextSize) { this.minTextSize = minTextSize; textSizesCache.clear(); requestLayout(); } } /** * Sets the unit to use as step units when computing the resized font size. This view's text * contents will always be rendered as a whole integer value in the unit specified here. For * example, if the unit is {@link TypedValue#COMPLEX_UNIT_SP}, then the text size may end up * being 13sp or 14sp, but never 13.5sp. * *

By default, the AutoResizeTextView uses the unit {@link TypedValue#COMPLEX_UNIT_PX}. * * @param unit the unit type to use; must be a known unit type from {@link TypedValue}. */ public final void setResizeStepUnit(int unit) { if (resizeStepUnit != unit) { resizeStepUnit = unit; requestLayout(); } } private void readAttrs(TypedArray typedArray) { resizeStepUnit = typedArray.getInt( R.styleable.AutoResizeTextView_autoResizeText_resizeStepUnit, DEFAULT_RESIZE_STEP_UNIT); minTextSize = (int) typedArray.getDimension( R.styleable.AutoResizeTextView_autoResizeText_minTextSize, DEFAULT_MIN_TEXT_SIZE); maxTextSize = (int) getTextSize(); } private void adjustTextSize() { int maxWidth = getMeasuredWidth() - getPaddingLeft() - getPaddingRight(); int maxHeight = getMeasuredHeight() - getPaddingBottom() - getPaddingTop(); if (maxWidth <= 0 || maxHeight <= 0) { return; } this.maxWidth = maxWidth; availableSpaceRect.right = maxWidth; availableSpaceRect.bottom = maxHeight; int minSizeInStepSizeUnits = (int) Math.ceil(convertToResizeStepUnits(minTextSize)); int maxSizeInStepSizeUnits = (int) Math.floor(convertToResizeStepUnits(maxTextSize)); float textSize = computeTextSize( minSizeInStepSizeUnits, maxSizeInStepSizeUnits, availableSpaceRect); super.setTextSize(resizeStepUnit, textSize); } private boolean suggestedSizeFitsInSpace(float suggestedSizeInPx, RectF availableSpace) { textPaint.setTextSize(suggestedSizeInPx); String text = getText().toString(); int maxLines = getMaxLines(); if (maxLines == 1) { // If single line, check the line's height and width. return textPaint.getFontSpacing() <= availableSpace.bottom && textPaint.measureText(text) <= availableSpace.right; } else { // If multiline, lay the text out, then check the number of lines, the layout's height, // and each line's width. StaticLayout layout = new StaticLayout(text, textPaint, maxWidth, Alignment.ALIGN_NORMAL, getLineSpacingMultiplier(), getLineSpacingExtra(), true); // Return false if we need more than maxLines. The text is obviously too big in this case. if (maxLines != NO_LINE_LIMIT && layout.getLineCount() > maxLines) { return false; } // Return false if the height of the layout is too big. return layout.getHeight() <= availableSpace.bottom; } } /** * Computes the final text size to use for this text view, factoring in any previously * cached computations. * * @param minSize the minimum text size to allow, in units of {@link #resizeStepUnit} * @param maxSize the maximum text size to allow, in units of {@link #resizeStepUnit} */ private float computeTextSize(int minSize, int maxSize, RectF availableSpace) { CharSequence text = getText(); if (text != null && textSizesCache.get(text.hashCode()) != 0) { return textSizesCache.get(text.hashCode()); } int size = binarySearchSizes(minSize, maxSize, availableSpace); textSizesCache.put(text == null ? 0 : text.hashCode(), size); return size; } /** * Performs a binary search to find the largest font size that will still fit within the size * available to this view. * @param minSize the minimum text size to allow, in units of {@link #resizeStepUnit} * @param maxSize the maximum text size to allow, in units of {@link #resizeStepUnit} */ private int binarySearchSizes(int minSize, int maxSize, RectF availableSpace) { int bestSize = minSize; int low = minSize + 1; int high = maxSize; int sizeToTry; while (low <= high) { sizeToTry = (low + high) / 2; float dimension = TypedValue.applyDimension(resizeStepUnit, sizeToTry, displayMetrics); if (suggestedSizeFitsInSpace(dimension, availableSpace)) { bestSize = low; low = sizeToTry + 1; } else { high = sizeToTry - 1; bestSize = high; } } return bestSize; } private float convertToResizeStepUnits(float dimension) { // To figure out the multiplier between a raw dimension and the resizeStepUnit, we invert the // conversion of 1 resizeStepUnit to a raw dimension. float multiplier = 1 / TypedValue.applyDimension(resizeStepUnit, 1, displayMetrics); return dimension * multiplier; } @Override protected final void onTextChanged( final CharSequence text, final int start, final int before, final int after) { super.onTextChanged(text, start, before, after); adjustTextSize(); } @Override protected final void onSizeChanged(int width, int height, int oldWidth, int oldHeight) { super.onSizeChanged(width, height, oldWidth, oldHeight); if (width != oldWidth || height != oldHeight) { textSizesCache.clear(); adjustTextSize(); } } @Override protected final void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { adjustTextSize(); super.onMeasure(widthMeasureSpec, heightMeasureSpec); } }