blob: c8647fca286740809863d480cb3064390a5f26f2 [file] [log] [blame]
/*
* 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.
*
* <p>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}.
*
* <p>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.
*
* <p>The parameters follow the same behavior as they do in {@link #setTextSize}.
*
* <p>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.
*
* <p>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);
}
}