Sunday, 10 August 2014

Android: TextView which resizes its text to fit within its height and width

Below is an extension of TextView which resizes text to fit within its height and width. It allows a maximum and minimum text size to be specified, and allows text to span multiple lines. It is a modification (some fixes and simplifications) of Chase's answer on Stack Overflow here: http://stackoverflow.com/a/5535672/1071320

UPDATE: For the latest version of the AutoResizeTextView class, see this repository:
https://bitbucket.org/adilson05uk/utils-library
public class AutoResizeTextView extends TextView {  
   
  /** Our ellipsis string. */  
  private static final String mEllipsis = "\u2026";  
   
  /**  
   * Upper bounds for text size.  
   * This acts as a starting point for resizing.  
   */  
  private float mMaxTextSizePixels;  
   
  /** Lower bounds for text size. */  
  private float mMinTextSizePixels;  
   
  /** TextView line spacing multiplier. */  
  private float mLineSpacingMultiplier = 1.0f;  
   
  /** TextView additional line spacing. */  
  private float mLineSpacingExtra = 0.0f;  
   
  /**  
   * Default constructor override.  
   *   
   * @param context  
   */  
  public AutoResizeTextView(Context context) {  
   this(context, null);
  }  
   
  /**  
   * Default constructor when inflating from XML file.  
   *   
   * @param context  
   * @param attrs  
   */  
  public AutoResizeTextView(Context context, AttributeSet attrs) {  
   this(context, attrs, 0);
  }  
   
  /**  
   * Default constructor override.  
   *   
   * @param context  
   * @param attrs  
   * @param defStyle  
   */  
  public AutoResizeTextView(Context context, AttributeSet attrs, int defStyle) {
   super(context, attrs, defStyle);
   initialise();  
  }  

  @Override  
  protected void onLayout (boolean changed, int left, int top, int right, int bottom) {
   super.onLayout(changed, left, top, right, bottom);
   resizeText();
  }  

  @Override  
  protected void onTextChanged(CharSequence text, int start, int lengthBefore, int lengthAfter) {
   super.onTextChanged(text, start, lengthBefore, lengthAfter);
   requestLayout();
  }  
   
  @Override  
  protected void onSizeChanged(int w, int h, int oldw, int oldh) {  
   super.onSizeChanged(w, h, oldw, oldh);  
   
   if (w != oldw || h != oldh) {  
    requestLayout();  
   }  
  }  
   
  @Override  
  public void setTextSize(float size) {  
   setTextSize(TypedValue.COMPLEX_UNIT_SP, size);  
  }  
   
  @Override  
  public void setTextSize(int unit, float size) {  
   super.setTextSize(unit, size);  
   mMaxTextSizePixels = getTextSize();  
   requestLayout();  
  }  
   
  @Override  
  public void setLineSpacing(float add, float mult) {  
   super.setLineSpacing(add, mult);  
   mLineSpacingMultiplier = mult;  
   mLineSpacingExtra = add;  
   requestLayout();  
  }  
   
  @Override  
  public void setEllipsize(TruncateAt where) {  
   super.setEllipsize(where);  
   requestLayout();  
  }  
   
  /**  
   * Sets the lower text size limit and invalidates the view.  
   *   
   * @param minTextSizeScaledPixels the minimum size to use for text in this view,  
   * in scaled pixels.  
   */  
  public void setMinTextSize(float minTextSizeScaledPixels) {  
   mMinTextSizePixels = convertSpToPx(minTextSizeScaledPixels);  
   requestLayout();  
  }  
   
  /**  
   * @return lower text size limit, in pixels.  
   */  
  public float getMinTextSizePixels() {  
   return mMinTextSizePixels;  
  }  
   
  private void initialise() {  
   mMaxTextSizePixels = getTextSize();  
  }  
   
  /**  
   * Resizes this view's text size with respect to its width and height  
   * (minus padding).  
   */  
  private void resizeText() {  
   final int availableHeightPixels = getHeight() - getCompoundPaddingBottom() - getCompoundPaddingTop();  
   
   final int availableWidthPixels = getWidth() - getCompoundPaddingLeft() - getCompoundPaddingRight();  
   
   final CharSequence text = getText();  
   
   // Safety check  
   // (Do not resize if the view does not have dimensions or if there is no text)  
   if (text == null  
     || text.length() <= 0  
     || availableHeightPixels <= 0  
     || availableWidthPixels <= 0  
     || mMaxTextSizePixels <= 0) {  
    return;  
   }  
   
   float targetTextSizePixels = mMaxTextSizePixels;  
   int targetTextHeightPixels = getTextHeightPixels(text, availableWidthPixels, targetTextSizePixels);

   // Until we either fit within our TextView  
   // or we have reached our minimum text size,  
   // incrementally try smaller sizes  
   while (targetTextHeightPixels > availableHeightPixels  
     && targetTextSizePixels > mMinTextSizePixels) {  
    targetTextSizePixels = Math.max(  
      targetTextSizePixels - 2,  
      mMinTextSizePixels);  
   
    targetTextHeightPixels = getTextHeightPixels(  
      text,  
      availableWidthPixels,  
      targetTextSizePixels);  
   }  
   
   // If we have reached our minimum text size and the text still doesn't fit,  
   // append an ellipsis  
   // (NOTE: Auto-ellipsize doesn't work hence why we have to do it here)  
   // (TODO: put ellipsis at the beginning, middle or end  
   // depending on the value of getEllipsize())  
   if (getEllipsize() != null  
     && targetTextSizePixels == mMinTextSizePixels  
     && targetTextHeightPixels > availableHeightPixels) {  
    // Make a copy of the original TextPaint object for measuring  
    TextPaint textPaintCopy = new TextPaint(getPaint());  
    textPaintCopy.setTextSize(targetTextSizePixels);  
   
    // Measure using a StaticLayout instance  
    StaticLayout staticLayout = new StaticLayout(  
      text,  
      textPaintCopy,  
      availableWidthPixels,  
      Alignment.ALIGN_NORMAL,  
      mLineSpacingMultiplier,  
      mLineSpacingExtra,  
      false);  
   
    // Check that we have a least one line of rendered text  
    if (staticLayout.getLineCount() > 0) {  
     // Since the line at the specific vertical position would be cut off,  
     // we must trim up to the previous line and add an ellipsis  
     int lastLine = staticLayout.getLineForVertical(availableHeightPixels) - 1;  
   
     if (lastLine >= 0) {  
      int startOffset = staticLayout.getLineStart(lastLine);  
      int endOffset = staticLayout.getLineEnd(lastLine);  
      float lineWidthPixels = staticLayout.getLineWidth(lastLine);  
      float ellipseWidth = textPaintCopy.measureText(mEllipsis);  
   
      // Trim characters off until we have enough room to draw the ellipsis  
      while (availableWidthPixels < lineWidthPixels + ellipseWidth) {  
       endOffset--;  
       lineWidthPixels = textPaintCopy.measureText(  
         text.subSequence(startOffset, endOffset + 1).toString());  
      }  
   
      setText(text.subSequence(0, endOffset) + mEllipsis);  
     }  
    }  
   }  
   
   super.setTextSize(TypedValue.COMPLEX_UNIT_PX, targetTextSizePixels);  
   // Some devices try to auto adjust line spacing, so force default line spacing  
   super.setLineSpacing(mLineSpacingExtra, mLineSpacingMultiplier);  
  }  
   
  /**  
   * Sets the text size of a clone of the view's {@link TextPaint} object  
   * and uses a {@link StaticLayout} instance to measure the height of the text.  
   *   
   * @param source  
   * @param textPaint  
   * @param availableWidthPixels  
   * @param textSizePixels  
   * @return the height of the text when placed in a view  
   * with the specified width  
   * and when the text has the specified size.  
   */  
  private int getTextHeightPixels(  
    CharSequence source,  
    int availableWidthPixels,  
    float textSizePixels) {  
   // Make a copy of the original TextPaint object  
   // since the object gets modified while measuring  
   // (see also the docs for TextView.getPaint()  
   // which states to access it read-only)  
   TextPaint textPaintCopy = new TextPaint(getPaint());  
   textPaintCopy.setTextSize(textSizePixels);  
   
   // Measure using a StaticLayout instance  
   StaticLayout staticLayout = new StaticLayout(  
     source,  
     textPaintCopy,  
     availableWidthPixels,  
     Alignment.ALIGN_NORMAL,  
     mLineSpacingMultiplier,  
     mLineSpacingExtra,  
     true);  
   
   return staticLayout.getHeight();  
  }  
   
  /**  
   * @param scaledPixels  
   * @return the number of pixels which scaledPixels corresponds to on the device.  
   */  
  private float convertSpToPx(float scaledPixels) {  
   float pixels = scaledPixels * getContext().getResources().getDisplayMetrics().scaledDensity;  
   return pixels;  
  }  
 }