Skip to content

Instantly share code, notes, and snippets.

@ruiwen
Created January 6, 2014 01:24
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ruiwen/8276644 to your computer and use it in GitHub Desktop.
Save ruiwen/8276644 to your computer and use it in GitHub Desktop.
package com.thoughtmonkeys.pitstop;
import java.util.ArrayList;
import java.util.List;
import android.content.Context;
import android.graphics.Paint;
import android.util.AttributeSet;
import android.util.Log;
import android.util.TypedValue;
import android.view.ViewGroup.LayoutParams;
import android.view.ViewTreeObserver.OnGlobalLayoutListener;
import android.widget.TextView;
/**
* This class builds a new android Widget named AutoFitText which can be used instead of a TextView
* to have the text font size in it automatically fit to match the screen width. Credits go largely
* to Dunni, gjpc, gregm and speedplane from Stackoverflow, method has been (style-) optimized and
* rewritten to match android coding standards and our MBC. This version upgrades the original
* "AutoFitTextView" to now also be adaptable to height and to accept the different TextView types
* (Button, TextClock etc.)
*
* @author pheuschk
* @createDate: 18.04.2013
* http://pastebin.com/e6WyrwSN
*/
@SuppressWarnings("unused")
public class AutoFitText extends TextView {
/** Global min and max for text size. Remember: values are in pixels! */
private final int MIN_TEXT_SIZE = 10;
private final int MAX_TEXT_SIZE = 400;
/** Flag for singleLine */
private boolean mSingleLine = false;
/**
* A dummy {@link TextView} to test the text size without actually showing anything to the user
*/
private TextView mTestView;
/**
* A dummy {@link Paint} to test the text size without actually showing anything to the user
*/
private Paint mTestPaint;
/**
* Scaling factor for fonts. It's a method of calculating independently (!) from the actual
* density of the screen that is used so users have the same experience on different devices. We
* will use DisplayMetrics in the Constructor to get the value of the factor and then calculate
* SP from pixel values
*/
private final float mScaledDensityFactor;
/**
* Defines how close we want to be to the factual size of the Text-field. Lower values mean
* higher precision but also exponentially higher computing cost (more loop runs)
*/
private final float mThreshold = 0.5f;
/**
* Constructor for call without attributes --> invoke constructor with AttributeSet null
*
* @param context
*/
public AutoFitText(Context context) {
this(context, null);
}
public AutoFitText(Context context, AttributeSet attrs) {
super(context, attrs);
mScaledDensityFactor = context.getResources().getDisplayMetrics().scaledDensity;
mTestView = new TextView(context);
mTestPaint = new Paint();
mTestPaint.set(this.getPaint());
this.getViewTreeObserver().addOnGlobalLayoutListener(new OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
// make an initial call to onSizeChanged to make sure that refitText is triggered
onSizeChanged(AutoFitText.this.getWidth(), AutoFitText.this.getHeight(), 0, 0);
// Remove the LayoutListener immediately so we don't run into an infinite loop
AutoFitText.this.getViewTreeObserver().removeOnGlobalLayoutListener(this);
}
});
}
/**
* Main method of this widget. Resizes the font so the specified text fits in the text box
* assuming the text box has the specified width. This is done via a dummy text view that is
* refit until it matches the real target width and height up to a certain threshold factor
*
* @param targetFieldWidth
* The width that the TextView currently has and wants filled
* @param targetFieldHeight
* The width that the TextView currently has and wants filled
*/
private void refitText(String text, int targetFieldWidth, int targetFieldHeight) {
// Variables need to be visible outside the loops for later use. Remember size is in pixels
float lowerTextSize = MIN_TEXT_SIZE;
float upperTextSize = MAX_TEXT_SIZE;
// Force the text to wrap. In principle this is not necessary since the dummy TextView
// already does this for us but in rare cases adding this line can prevent flickering
this.setMaxWidth(targetFieldWidth);
// Padding should not be an issue since we never define it programmatically in this app
// but just to to be sure we cut it off here
targetFieldWidth = targetFieldWidth - this.getPaddingLeft() - this.getPaddingRight();
targetFieldHeight = targetFieldHeight - this.getPaddingTop() - this.getPaddingBottom();
// Initialize the dummy with some params (that are largely ignored anyway, but this is
// mandatory to not get a NullPointerException)
mTestView.setLayoutParams(new LayoutParams(targetFieldWidth, targetFieldHeight));
// maxWidth is crucial! Otherwise the text would never line wrap but blow up the width
mTestView.setMaxWidth(targetFieldWidth);
if (mSingleLine) {
// the user requested a single line. This is very easy to do since we primarily need to
// respect the width, don't have to break, don't have to measure...
/*************************** Converging algorithm 1 ***********************************/
for (float testSize; (upperTextSize - lowerTextSize) > mThreshold;) {
// Go to the mean value...
testSize = (upperTextSize + lowerTextSize) / 2;
mTestView.setTextSize(TypedValue.COMPLEX_UNIT_SP, testSize / mScaledDensityFactor);
mTestView.setText(text);
mTestView.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
if (mTestView.getMeasuredWidth() >= targetFieldWidth) {
upperTextSize = testSize; // Font is too big, decrease upperSize
}
else {
lowerTextSize = testSize; // Font is too small, increase lowerSize
}
}
/**************************************************************************************/
// In rare cases with very little letters and width > height we have vertical overlap!
mTestView.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
if (mTestView.getMeasuredHeight() > targetFieldHeight) {
upperTextSize = lowerTextSize;
lowerTextSize = MIN_TEXT_SIZE;
/*************************** Converging algorithm 1.5 *****************************/
for (float testSize; (upperTextSize - lowerTextSize) > mThreshold;) {
// Go to the mean value...
testSize = (upperTextSize + lowerTextSize) / 2;
mTestView.setTextSize(TypedValue.COMPLEX_UNIT_SP, testSize
/ mScaledDensityFactor);
mTestView.setText(text);
mTestView.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
if (mTestView.getMeasuredHeight() >= targetFieldHeight) {
upperTextSize = testSize; // Font is too big, decrease upperSize
}
else {
lowerTextSize = testSize; // Font is too small, increase lowerSize
}
}
/**********************************************************************************/
}
}
else {
/*********************** Converging algorithm 2 ***************************************/
// Upper and lower size converge over time. As soon as they're close enough the loop
// stops
// TODO probe the algorithm for cost (ATM possibly O(n^2)) and optimize if possible
for (float testSize; (upperTextSize - lowerTextSize) > mThreshold;) {
// Go to the mean value...
testSize = (upperTextSize + lowerTextSize) / 2;
// ... inflate the dummy TextView by setting a scaled textSize and the text...
mTestView.setTextSize(TypedValue.COMPLEX_UNIT_SP, testSize / mScaledDensityFactor);
mTestView.setText(text);
// ... call measure to find the current values that the text WANTS to occupy
mTestView.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
int tempHeight = mTestView.getMeasuredHeight();
// int tempWidth = mTestView.getMeasuredWidth();
// LOG.debug("Measured: " + tempWidth + "x" + tempHeight);
// LOG.debug("TextSize: " + testSize / mScaledDensityFactor);
// ... decide whether those values are appropriate.
if (tempHeight >= targetFieldHeight) {
upperTextSize = testSize; // Font is too big, decrease upperSize
}
else {
lowerTextSize = testSize; // Font is too small, increase lowerSize
}
}
/**************************************************************************************/
// It is possible that a single word is wider than the box. The Android system would
// wrap this for us. But if you want to decide fo yourself where exactly to break or to
// add a hyphen or something than you're going to want to implement something like this:
mTestPaint.setTextSize(lowerTextSize);
List<String> words = new ArrayList<String>();
for (String s : text.split(" ")) {
// Log.i("tag", "Word: " + s);
words.add(s);
}
for (String word : words) {
if (mTestPaint.measureText(word) >= targetFieldWidth) {
List<String> pieces = new ArrayList<String>();
// pieces = breakWord(word, mTestPaint.measureText(word), targetFieldWidth);
// Add code to handle the pieces here...
}
}
}
/**
* We are now at most the value of threshold away from the actual size. To rather undershoot
* than overshoot use the lower value. To match different screens convert to SP first. See
* {@link http://developer.android.com/guide/topics/resources/more-resources.html#Dimension}
* for more details
*/
this.setTextSize(TypedValue.COMPLEX_UNIT_SP, lowerTextSize / mScaledDensityFactor);
return;
}
/**
* This method receives a call upon a change in text content of the TextView. Unfortunately it
* is also called - among others - upon text size change which means that we MUST NEVER CALL
* {@link #refitText(String)} from this method! Doing so would result in an endless loop that
* would ultimately result in a stack overflow and termination of the application
*
* So for the time being this method does absolutely nothing. If you want to notify the view of
* a changed text call {@link #setText(CharSequence)}
*/
@Override
protected void onTextChanged(CharSequence text, int start, int lengthBefore, int lengthAfter) {
// Super implementation is also intentionally empty so for now we do absolutely nothing here
super.onTextChanged(text, start, lengthBefore, lengthAfter);
}
@Override
protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) {
if (width != oldWidth && height != oldHeight) {
refitText(this.getText().toString(), width, height);
}
}
/**
* This method is guaranteed to be called by {@link TextView#setText(CharSequence)} immediately.
* Therefore we can safely add our modifications here and then have the parent class resume its
* work. So if text has changed you should always call {@link TextView#setText(CharSequence)} or
* {@link TextView#setText(CharSequence, BufferType)} if you know whether the {@link BufferType}
* is normal, editable or spannable. Note: the method will default to {@link BufferType#NORMAL}
* if you don't pass an argument.
*/
@Override
public void setText(CharSequence text, BufferType type) {
int targetFieldWidth = this.getWidth();
int targetFieldHeight = this.getHeight();
if (targetFieldWidth <= 0 || targetFieldHeight <= 0 || text.equals("")) {
// Log.v("tag", "Some values are empty, AutoFitText was not able to construct properly");
}
else {
refitText(text.toString(), targetFieldWidth, targetFieldHeight);
}
super.setText(text, type);
}
/**
* TODO add sensibility for {@link #setMaxLines(int)} invocations
*/
@Override
public void setMaxLines(int maxLines) {
// TODO Implement support for this. This could be relatively easy. The idea would probably
// be to manipulate the targetHeight in the refitText-method and then have the algorithm do
// its job business as usual. Nonetheless, remember the height will have to be lowered
// dynamically as the font size shrinks so it won't be a walk in the park still
if (maxLines == 1) {
this.setSingleLine(true);
}
else {
throw new UnsupportedOperationException(
"MaxLines != 1 are not implemented in AutoFitText yet, use TextView instead");
}
}
@Override
public void setSingleLine(boolean singleLine) {
// save the requested value in an instance variable to be able to decide later
mSingleLine = singleLine;
super.setSingleLine(singleLine);
}
}
@RoqueCampos
Copy link

Dear Friend,

I'm using your AutoFitText class in my project, and I have to say that it works very well. Thank you for your work.

But I have a little problem in certain android versions, more concretely in api 14 and maybe api 15. The problem is in the line 165 of your code:

 if (mTestView.getMeasuredHeight() >= targetFieldHeight) {

The thing is that mTestView.getMeasuredHeight() returns always the same value, the same value as in the first iteration. The consequence is that the final value for the font size is the minimum possible.

Maybe you have couple of minutes to have a look at this :)

Best regards,

R. Campos.

@RoqueCampos
Copy link

Dear friend,

finally I managed to solve it myself. If you create a new textview in each iteration of the for loop, it works fine in api 14:

/*************************** Converging algorithm 1.5 *****************************/
float difference=upperTextSize - lowerTextSize;
for (float testSize; difference > mThreshold; ) {

                // Go to the mean value...
                testSize = (upperTextSize + lowerTextSize) / 2;

                TextView tvv=new TextView(getContext());
                tvv.setLayoutParams(new LayoutParams(targetFieldWidth, targetFieldHeight));

                // maxWidth is crucial! Otherwise the text would never line wrap but blow up the width
                tvv.setMaxWidth(targetFieldWidth);
                tvv.setTextSize(TypedValue.COMPLEX_UNIT_SP, testSize/mScaledDensityFactor);
                tvv.setText(text);
                tvv.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);

                int newHeight = tvv.getMeasuredHeight();
                if (newHeight >= targetFieldHeight) {
                    upperTextSize = testSize; // Font is too big, decrease upperSize
                } else {
                    lowerTextSize = testSize; // Font is too small, increase lowerSize
                }
                difference=upperTextSize - lowerTextSize;
            }
            /**********************************************************************************/

Best regards

R. Campos.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment