Escala automática de texto TextView para ajustarlo dentro de los límites
Estoy buscando una forma óptima de cambiar el tamaño del texto ajustado TextView
para que encaje dentro de sus límites getHeight y getWidth. No estoy simplemente buscando una manera de ajustar el texto; quiero asegurarme de que se ajuste y sea lo suficientemente pequeño como para caber completamente en la pantalla.
He visto algunos casos en StackOverflow en los que se necesitaba un cambio de tamaño automático, pero son casos muy especiales con soluciones de pirateo, no tienen solución o implican volver a dibujar el archivo de forma TextView
recursiva hasta que sea lo suficientemente pequeño (lo cual consume mucha memoria y obliga a usuario ver cómo el texto se reduce paso a paso con cada recursión).
Pero estoy seguro de que alguien ha encontrado una buena solución que no involucra lo que estoy haciendo: escribir varias rutinas pesadas que analizan y miden el texto, cambian el tamaño del texto y lo repiten hasta encontrar un tamaño adecuadamente pequeño.
¿Qué rutinas TextView
utiliza para ajustar el texto? ¿No podrían usarse de alguna manera para predecir si el texto será lo suficientemente pequeño?
tl;dr : ¿Existe una forma recomendada de cambiar automáticamente el tamaño de un TextView
para que se ajuste, envuelto, en sus límites getHeight y getWidth?
Como desarrollador de dispositivos móviles, me entristeció no encontrar nada nativo que admita el cambio de tamaño automático. Mis búsquedas no arrojaron nada que funcionara para mí y, al final, pasé la mayor parte de mi fin de semana y creé mi propia vista de texto con cambio de tamaño automático. Publicaré el código aquí y espero que sea útil para alguien más.
Esta clase utiliza un diseño estático con la pintura de texto de la vista de texto original para medir la altura. A partir de ahí, bajo 2 píxeles de fuente y vuelvo a medir hasta tener un tamaño que se ajuste. Al final, si el texto aún no encaja, agrego puntos suspensivos. Tenía requisitos para animar el texto y reutilizar vistas y esto parece funcionar bien en los dispositivos que tengo y parece funcionar lo suficientemente rápido para mí.
/**
* DO WHAT YOU WANT TO PUBLIC LICENSE
* Version 2, December 2004
*
* Copyright (C) 2004 Sam Hocevar <[email protected]>
*
* Everyone is permitted to copy and distribute verbatim or modified
* copies of this license document, and changing it is allowed as long
* as the name is changed.
*
* DO WHAT YOU WANT TO PUBLIC LICENSE
* TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
*
* 0. You just DO WHAT YOU WANT TO.
*/
import android.content.Context;
import android.text.Layout.Alignment;
import android.text.StaticLayout;
import android.text.TextPaint;
import android.util.AttributeSet;
import android.util.TypedValue;
import android.widget.TextView;
/**
* Text view that auto adjusts text size to fit within the view.
* If the text size equals the minimum text size and still does not
* fit, append with an ellipsis.
*
* @author Chase Colburn
* @since Apr 4, 2011
*/
public class AutoResizeTextView extends TextView {
// Minimum text size for this text view
public static final float MIN_TEXT_SIZE = 20;
// Interface for resize notifications
public interface OnTextResizeListener {
public void onTextResize(TextView textView, float oldSize, float newSize);
}
// Our ellipse string
private static final String mEllipsis = "...";
// Registered resize listener
private OnTextResizeListener mTextResizeListener;
// Flag for text and/or size changes to force a resize
private boolean mNeedsResize = false;
// Text size that is set from code. This acts as a starting point for resizing
private float mTextSize;
// Temporary upper bounds on the starting text size
private float mMaxTextSize = 0;
// Lower bounds for text size
private float mMinTextSize = MIN_TEXT_SIZE;
// Text view line spacing multiplier
private float mSpacingMult = 1.0f;
// Text view additional line spacing
private float mSpacingAdd = 0.0f;
// Add ellipsis to text that overflows at the smallest text size
private boolean mAddEllipsis = true;
// Default constructor override
public AutoResizeTextView(Context context) {
this(context, null);
}
// Default constructor when inflating from XML file
public AutoResizeTextView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
// Default constructor override
public AutoResizeTextView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
mTextSize = getTextSize();
}
/**
* When text changes, set the force resize flag to true and reset the text size.
*/
@Override
protected void onTextChanged(final CharSequence text, final int start, final int before, final int after) {
mNeedsResize = true;
// Since this view may be reused, it is good to reset the text size
resetTextSize();
}
/**
* If the text view size changed, set the force resize flag to true
*/
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
if (w != oldw || h != oldh) {
mNeedsResize = true;
}
}
/**
* Register listener to receive resize notifications
* @param listener
*/
public void setOnResizeListener(OnTextResizeListener listener) {
mTextResizeListener = listener;
}
/**
* Override the set text size to update our internal reference values
*/
@Override
public void setTextSize(float size) {
super.setTextSize(size);
mTextSize = getTextSize();
}
/**
* Override the set text size to update our internal reference values
*/
@Override
public void setTextSize(int unit, float size) {
super.setTextSize(unit, size);
mTextSize = getTextSize();
}
/**
* Override the set line spacing to update our internal reference values
*/
@Override
public void setLineSpacing(float add, float mult) {
super.setLineSpacing(add, mult);
mSpacingMult = mult;
mSpacingAdd = add;
}
/**
* Set the upper text size limit and invalidate the view
* @param maxTextSize
*/
public void setMaxTextSize(float maxTextSize) {
mMaxTextSize = maxTextSize;
requestLayout();
invalidate();
}
/**
* Return upper text size limit
* @return
*/
public float getMaxTextSize() {
return mMaxTextSize;
}
/**
* Set the lower text size limit and invalidate the view
* @param minTextSize
*/
public void setMinTextSize(float minTextSize) {
mMinTextSize = minTextSize;
requestLayout();
invalidate();
}
/**
* Return lower text size limit
* @return
*/
public float getMinTextSize() {
return mMinTextSize;
}
/**
* Set flag to add ellipsis to text that overflows at the smallest text size
* @param addEllipsis
*/
public void setAddEllipsis(boolean addEllipsis) {
mAddEllipsis = addEllipsis;
}
/**
* Return flag to add ellipsis to text that overflows at the smallest text size
* @return
*/
public boolean getAddEllipsis() {
return mAddEllipsis;
}
/**
* Reset the text to the original size
*/
public void resetTextSize() {
if (mTextSize > 0) {
super.setTextSize(TypedValue.COMPLEX_UNIT_PX, mTextSize);
mMaxTextSize = mTextSize;
}
}
/**
* Resize text after measuring
*/
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
if (changed || mNeedsResize) {
int widthLimit = (right - left) - getCompoundPaddingLeft() - getCompoundPaddingRight();
int heightLimit = (bottom - top) - getCompoundPaddingBottom() - getCompoundPaddingTop();
resizeText(widthLimit, heightLimit);
}
super.onLayout(changed, left, top, right, bottom);
}
/**
* Resize the text size with default width and height
*/
public void resizeText() {
int heightLimit = getHeight() - getPaddingBottom() - getPaddingTop();
int widthLimit = getWidth() - getPaddingLeft() - getPaddingRight();
resizeText(widthLimit, heightLimit);
}
/**
* Resize the text size with specified width and height
* @param width
* @param height
*/
public void resizeText(int width, int height) {
CharSequence text = getText();
// Do not resize if the view does not have dimensions or there is no text
if (text == null || text.length() == 0 || height <= 0 || width <= 0 || mTextSize == 0) {
return;
}
if (getTransformationMethod() != null) {
text = getTransformationMethod().getTransformation(text, this);
}
// Get the text view's paint object
TextPaint textPaint = getPaint();
// Store the current text size
float oldTextSize = textPaint.getTextSize();
// If there is a max text size set, use the lesser of that and the default text size
float targetTextSize = mMaxTextSize > 0 ? Math.min(mTextSize, mMaxTextSize) : mTextSize;
// Get the required text height
int textHeight = getTextHeight(text, textPaint, width, targetTextSize);
// Until we either fit within our text view or we had reached our min text size, incrementally try smaller sizes
while (textHeight > height && targetTextSize > mMinTextSize) {
targetTextSize = Math.max(targetTextSize - 2, mMinTextSize);
textHeight = getTextHeight(text, textPaint, width, targetTextSize);
}
// If we had reached our minimum text size and still don't fit, append an ellipsis
if (mAddEllipsis && targetTextSize == mMinTextSize && textHeight > height) {
// Draw using a static layout
// modified: use a copy of TextPaint for measuring
TextPaint paint = new TextPaint(textPaint);
// Draw using a static layout
StaticLayout layout = new StaticLayout(text, paint, width, Alignment.ALIGN_NORMAL, mSpacingMult, mSpacingAdd, false);
// Check that we have a least one line of rendered text
if (layout.getLineCount() > 0) {
// Since the line at the specific vertical position would be cut off,
// we must trim up to the previous line
int lastLine = layout.getLineForVertical(height) - 1;
// If the text would not even fit on a single line, clear it
if (lastLine < 0) {
setText("");
}
// Otherwise, trim to the previous line and add an ellipsis
else {
int start = layout.getLineStart(lastLine);
int end = layout.getLineEnd(lastLine);
float lineWidth = layout.getLineWidth(lastLine);
float ellipseWidth = textPaint.measureText(mEllipsis);
// Trim characters off until we have enough room to draw the ellipsis
while (width < lineWidth + ellipseWidth) {
lineWidth = textPaint.measureText(text.subSequence(start, --end + 1).toString());
}
setText(text.subSequence(0, end) + mEllipsis);
}
}
}
// Some devices try to auto adjust line spacing, so force default line spacing
// and invalidate the layout as a side effect
setTextSize(TypedValue.COMPLEX_UNIT_PX, targetTextSize);
setLineSpacing(mSpacingAdd, mSpacingMult);
// Notify the listener if registered
if (mTextResizeListener != null) {
mTextResizeListener.onTextResize(this, oldTextSize, targetTextSize);
}
// Reset force resize flag
mNeedsResize = false;
}
// Set the text size of the text paint object and use a static layout to render text off screen before measuring
private int getTextHeight(CharSequence source, TextPaint paint, int width, float textSize) {
// modified: make a copy of the original TextPaint object for measuring
// (apparently the object gets modified while measuring, see also the
// docs for TextView.getPaint() (which states to access it read-only)
TextPaint paintCopy = new TextPaint(paint);
// Update the text paint object
paintCopy.setTextSize(textSize);
// Measure using a static layout
StaticLayout layout = new StaticLayout(source, paintCopy, width, Alignment.ALIGN_NORMAL, mSpacingMult, mSpacingAdd, true);
return layout.getHeight();
}
}
Advertencia. Hay un error solucionado importante que afecta a Android 3.1 - 4.04 y que provoca que todos los widgets AutoResizingTextView no funcionen. Lea: https://stackoverflow.com/a/21851157/2075875
Desde junio de 2018, Android comenzó oficialmente a admitir esta función para Android 4.0 (API nivel 14) y versiones posteriores.
Compruébalo en: Autodimensionamiento de TextViews
Con Android 8.0 (API nivel 26) y superior :
<?xml version="1.0" encoding="utf-8"?>
<TextView
android:layout_width="match_parent"
android:layout_height="200dp"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="12sp"
android:autoSizeMaxTextSize="100sp"
android:autoSizeStepGranularity="2sp" />
Programáticamente:
setAutoSizeTextTypeUniformWithConfiguration(int autoSizeMinTextSize, int autoSizeMaxTextSize,
int autoSizeStepGranularity, int unit)
textView.setAutoSizeTextTypeUniformWithConfiguration(
1, 17, 1, TypedValue.COMPLEX_UNIT_DIP);
Versiones de Android anteriores a Android 8.0 (nivel de API 26) :
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:layout_width="match_parent"
android:layout_height="200dp"
app:autoSizeTextType="uniform"
app:autoSizeMinTextSize="12sp"
app:autoSizeMaxTextSize="100sp"
app:autoSizeStepGranularity="2sp" />
</LinearLayout>
Programáticamente:
TextViewCompat.setAutoSizeTextTypeUniformWithConfiguration(
TextView textView, int autoSizeMinTextSize, int autoSizeMaxTextSize, int autoSizeStepGranularity, int unit)
TextViewCompat.setAutoSizeTextTypeUniformWithConfiguration(textView, 1, 17, 1,
TypedValue.COMPLEX_UNIT_DIP);
Atención: TextView debe tener layout_width=" match_parent " o tamaño absoluto.
ACTUALIZACIÓN: El siguiente código también cumple con el requisito de un AutoScaleTextView ideal como se describe aquí: Ajuste automático de TextView para Android y está marcado como ganador.
ACTUALIZACIÓN 2: Se agregó soporte para maxlines, ahora funciona bien antes del nivel API 16.
Actualización 3: Se agregó soporte para etiquetas android:drawableLeft
, y , gracias a la solución simple de MartinH aquí .android:drawableRight
android:drawableTop
android:drawableBottom
Mis requisitos eran un poco diferentes. Necesitaba una forma eficiente de ajustar el tamaño porque estaba animando un número entero, que puede ser de 0 a ~4000 en TextView
2 segundos y quería ajustar el tamaño en consecuencia. Mi solución funciona un poco diferente. Así es como se ve el resultado final:
y el código que lo produjo:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp" >
<com.vj.widgets.AutoResizeTextView
android:layout_width="match_parent"
android:layout_height="100dp"
android:ellipsize="none"
android:maxLines="2"
android:text="Auto Resized Text, max 2 lines"
android:textSize="100sp" /> <!-- maximum size -->
<com.vj.widgets.AutoResizeTextView
android:layout_width="match_parent"
android:layout_height="100dp"
android:ellipsize="none"
android:gravity="center"
android:maxLines="1"
android:text="Auto Resized Text, max 1 line"
android:textSize="100sp" /> <!-- maximum size -->
<com.vj.widgets.AutoResizeTextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Auto Resized Text"
android:textSize="500sp" /> <!-- maximum size -->
</LinearLayout>
Y por último el código java:
import android.annotation.TargetApi;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.RectF;
import android.os.Build;
import android.text.Layout.Alignment;
import android.text.StaticLayout;
import android.text.TextPaint;
import android.util.AttributeSet;
import android.util.SparseIntArray;
import android.util.TypedValue;
import android.widget.TextView;
public class AutoResizeTextView extends TextView {
private interface SizeTester {
/**
*
* @param suggestedSize
* Size of text to be tested
* @param availableSpace
* available space in which text must fit
* @return an integer < 0 if after applying {@code suggestedSize} to
* text, it takes less space than {@code availableSpace}, > 0
* otherwise
*/
public int onTestSize(int suggestedSize, RectF availableSpace);
}
private RectF mTextRect = new RectF();
private RectF mAvailableSpaceRect;
private SparseIntArray mTextCachedSizes;
private TextPaint mPaint;
private float mMaxTextSize;
private float mSpacingMult = 1.0f;
private float mSpacingAdd = 0.0f;
private float mMinTextSize = 20;
private int mWidthLimit;
private static final int NO_LINE_LIMIT = -1;
private int mMaxLines;
private boolean mEnableSizeCache = true;
private boolean mInitiallized;
public AutoResizeTextView(Context context) {
super(context);
initialize();
}
public AutoResizeTextView(Context context, AttributeSet attrs) {
super(context, attrs);
initialize();
}
public AutoResizeTextView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
initialize();
}
private void initialize() {
mPaint = new TextPaint(getPaint());
mMaxTextSize = getTextSize();
mAvailableSpaceRect = new RectF();
mTextCachedSizes = new SparseIntArray();
if (mMaxLines == 0) {
// no value was assigned during construction
mMaxLines = NO_LINE_LIMIT;
}
mInitiallized = true;
}
@Override
public void setText(final CharSequence text, BufferType type) {
super.setText(text, type);
adjustTextSize(text.toString());
}
@Override
public void setTextSize(float size) {
mMaxTextSize = size;
mTextCachedSizes.clear();
adjustTextSize(getText().toString());
}
@Override
public void setMaxLines(int maxlines) {
super.setMaxLines(maxlines);
mMaxLines = maxlines;
reAdjust();
}
public int getMaxLines() {
return mMaxLines;
}
@Override
public void setSingleLine() {
super.setSingleLine();
mMaxLines = 1;
reAdjust();
}
@Override
public void setSingleLine(boolean singleLine) {
super.setSingleLine(singleLine);
if (singleLine) {
mMaxLines = 1;
} else {
mMaxLines = NO_LINE_LIMIT;
}
reAdjust();
}
@Override
public void setLines(int lines) {
super.setLines(lines);
mMaxLines = lines;
reAdjust();
}
@Override
public void setTextSize(int unit, float size) {
Context c = getContext();
Resources r;
if (c == null)
r = Resources.getSystem();
else
r = c.getResources();
mMaxTextSize = TypedValue.applyDimension(unit, size,
r.getDisplayMetrics());
mTextCachedSizes.clear();
adjustTextSize(getText().toString());
}
@Override
public void setLineSpacing(float add, float mult) {
super.setLineSpacing(add, mult);
mSpacingMult = mult;
mSpacingAdd = add;
}
/**
* Set the lower text size limit and invalidate the view
*
* @param minTextSize
*/
public void setMinTextSize(float minTextSize) {
mMinTextSize = minTextSize;
reAdjust();
}
private void reAdjust() {
adjustTextSize(getText().toString());
}
private void adjustTextSize(String string) {
if (!mInitiallized) {
return;
}
int startSize = (int) mMinTextSize;
int heightLimit = getMeasuredHeight() - getCompoundPaddingBottom()
- getCompoundPaddingTop();
mWidthLimit = getMeasuredWidth() - getCompoundPaddingLeft()
- getCompoundPaddingRight();
mAvailableSpaceRect.right = mWidthLimit;
mAvailableSpaceRect.bottom = heightLimit;
super.setTextSize(
TypedValue.COMPLEX_UNIT_PX,
efficientTextSizeSearch(startSize, (int) mMaxTextSize,
mSizeTester, mAvailableSpaceRect));
}
private final SizeTester mSizeTester = new SizeTester() {
@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
@Override
public int onTestSize(int suggestedSize, RectF availableSPace) {
mPaint.setTextSize(suggestedSize);
String text = getText().toString();
boolean singleline = getMaxLines() == 1;
if (singleline) {
mTextRect.bottom = mPaint.getFontSpacing();
mTextRect.right = mPaint.measureText(text);
} else {
StaticLayout layout = new StaticLayout(text, mPaint,
mWidthLimit, Alignment.ALIGN_NORMAL, mSpacingMult,
mSpacingAdd, true);
// return early if we have more lines
if (getMaxLines() != NO_LINE_LIMIT
&& layout.getLineCount() > getMaxLines()) {
return 1;
}
mTextRect.bottom = layout.getHeight();
int maxWidth = -1;
for (int i = 0; i < layout.getLineCount(); i++) {
if (maxWidth < layout.getLineWidth(i)) {
maxWidth = (int) layout.getLineWidth(i);
}
}
mTextRect.right = maxWidth;
}
mTextRect.offsetTo(0, 0);
if (availableSPace.contains(mTextRect)) {
// may be too small, don't worry we will find the best match
return -1;
} else {
// too big
return 1;
}
}
};
/**
* Enables or disables size caching, enabling it will improve performance
* where you are animating a value inside TextView. This stores the font
* size against getText().length() Be careful though while enabling it as 0
* takes more space than 1 on some fonts and so on.
*
* @param enable
* enable font size caching
*/
public void enableSizeCache(boolean enable) {
mEnableSizeCache = enable;
mTextCachedSizes.clear();
adjustTextSize(getText().toString());
}
private int efficientTextSizeSearch(int start, int end,
SizeTester sizeTester, RectF availableSpace) {
if (!mEnableSizeCache) {
return binarySearch(start, end, sizeTester, availableSpace);
}
String text = getText().toString();
int key = text == null ? 0 : text.length();
int size = mTextCachedSizes.get(key);
if (size != 0) {
return size;
}
size = binarySearch(start, end, sizeTester, availableSpace);
mTextCachedSizes.put(key, size);
return size;
}
private static int binarySearch(int start, int end, SizeTester sizeTester,
RectF availableSpace) {
int lastBest = start;
int lo = start;
int hi = end - 1;
int mid = 0;
while (lo <= hi) {
mid = (lo + hi) >>> 1;
int midValCmp = sizeTester.onTestSize(mid, availableSpace);
if (midValCmp < 0) {
lastBest = lo;
lo = mid + 1;
} else if (midValCmp > 0) {
hi = mid - 1;
lastBest = hi;
} else {
return mid;
}
}
// make sure to return last best
// this is what should always be returned
return lastBest;
}
@Override
protected void onTextChanged(final CharSequence text, final int start,
final int before, final int after) {
super.onTextChanged(text, start, before, after);
reAdjust();
}
@Override
protected void onSizeChanged(int width, int height, int oldwidth,
int oldheight) {
mTextCachedSizes.clear();
super.onSizeChanged(width, height, oldwidth, oldheight);
if (width != oldwidth || height != oldheight) {
reAdjust();
}
}
}