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/android-utils
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;  
  }  
 }

Tuesday, 20 May 2014

Why is programming fun?

"First is the sheer joy of making things...

Second is the pleasure of making things that are useful to other people...

Third is the fascination of fashioning complex puzzle-like objects of interlocking moving parts...

Fourth is the joy of always learning, which springs from the non-repeating nature of the task...

Finally, there is the joy of working in such a tractable medium..."

(Snippet from 'The Mythical Man-Month' by Frederick P. Brooks, Jr. [Chapter 1 - The Tar Pit])

Saturday, 5 April 2014

Java - convert an integer to a binary (bits/bytes) String

So! You want to convert an integer to its binary (bits and bytes) representation. There is an existing method which does this:

Integer.toBinaryString(int);

But! This will not include the leading 0s (i.e. the 0s before the most-significant 1). If you want a String with all 32 bits that make up the integer, here's an alternative method:

/**
 * @param value
 * @return the binary representation of {@code value}, including leading 0s.
 */
String toBinaryString(int value) {
  final byte[] bytes = ByteBuffer
      .allocate(4)
      .order(ByteOrder.BIG_ENDIAN)
      .putInt(value)
      .array();

  return toBinaryString(bytes);
}

/**
 * @param bytes is non-null.
 * @return the binary representation of the bytes in {@code bytes}.
 */
String toBinaryString(byte[] bytes) {
  final StringBuilder sb = new StringBuilder();

  for (int i = 0; i < bytes.length; i++) {
    sb.append(toBinaryString(bytes[i]));
    sb.append(" ");
  }

  return sb.toString();
}

/**
 * @param value
 * @return the binary representation of {@code value}.
 */
String toBinaryString(byte value) {
  final StringBuilder sb = new StringBuilder();

  for (int i = 7; i >= 0; i--) {
    boolean bitSet = (value & (1 << i)) > 0;
    sb.append(bitSet ? "1" : "0");
  }

  return sb.toString();
}

Monday, 10 March 2014

Android/Java - formatting a String containing both English and Arabic

I ran across a problem today where I needed to compose a String containing both English (left-to-right text) and Arabic (right-to-left text) parts. In particular, what I needed to do was format a String containing three blocks to display as follows:

[1. English left-to-right text] [2. Arabic right-to-left text] [3. English left-to-right text]

The problem I found was that some of the English in the third block was displayed to the left of the Arabic in the second block, i.e. the implicit text ordering algorithm wasn't able to correctly determine where the Arabic right-to-left block had completed and the next English left-to-right block had started.

To get around this problem I had to explicitly specify directional formatting characters (\u202A, \u202B, \u202C, \u202D, \u202E etc) something as follows:

[1. English left-to-right text] \u202B [2. Arabic right-to-left text] \u202C [3. English left-to-right text]

To get the text to render exactly as you intend you might need to play around a little with the directional formatting characters. For example, you might have to surround the English block with directional formatting characters as well as the Arabic text, as follows:

\u202D [1. English left-to-right text] \u202B [2. Arabic right-to-left text] \u202C [3. English left-to-right text] \u202C

Here's the Stack Overflow thread which led me to the solution:
http://stackoverflow.com/questions/6177294/string-concatenation-containing-arabic-and-western-characters

For a deeper understanding of directional formatting characters and the Unicode Bidirectional Algorithm, check out the following document:
https://unicode.org/reports/tr9

Monday, 10 February 2014

Android - how to make a custom view which looks like a Spinner

I'll show you in this post how to make a custom view which looks like a Spinner and to which you can assign your own text and click listener. There's just two steps.

Step 1 - define an xml layout for your custom view as follows:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
  xmlns:android="http://schemas.android.com/apk/res/android"
  android:id="@+id/view_root"
  android:layout_width="match_parent"
  android:layout_height="wrap_content"
  style="?android:attr/spinnerStyle" >

  <include
    layout="@android:layout/simple_spinner_item"
    android:id="@+id/view_text"
    android:layout_width="match_parent"
    android:layout_height="wrap_content" />

</FrameLayout>

We'll assume you've saved the above layout in res/layout/spinner_lookalike.xml.

Step 2 - create your custom view class as follows:

/**
 * This class is essentially a {@link FrameLayout}
 * which looks like a {@link Spinner}.
 */
public class SpinnerLookalikeView extends FrameLayout {

  private ViewGroup rootView;
  private TextView textView;

  /**
   * Constructor to use when creating View from code.
   * */
  public SpinnerLookalikeView(Context context) {
    super(context);
    initialise();
  }

  /**
   * Constructor that is used when inflating View from XML.
   * */
  public SpinnerLookalikeView(Context context, AttributeSet attrs) {
    super(context, attrs);
      initialise();
  }

  /**
   * Constructor that is used when inflating View from XML
   * and applying a class-specific base style.
   * */
  public SpinnerLookalikeView(Context context, AttributeSet attrs, int defStyle) {
    super(context, attrs, defStyle);
    initialise();
  }

  @Override
  public void setOnClickListener(OnClickListener listener) {
    rootView.setOnClickListener(listener);
  }

  /**
   * Sets the text shown in this view.
   * 
   * @param text
   */
  public void setText(String text) {
    textView.setText(text);
  }

  /**
   * Initialisation method to be called by the constructors of this class only.
   */
  private void initialise() {
    LayoutInflater inflater = (LayoutInflater) getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE);
    inflater.inflate(R.layout.spinner_lookalike, this);

    rootView = (ViewGroup) findViewById(R.id.view_root);
    textView = (TextView) findViewById(R.id.view_text);
  }

}

That's it! Now you can add this custom view to your activities and assign text and a click listener to it.

Sunday, 26 January 2014

Android: NumberPicker - calling setWrapSelectorWheel(false) does nothing!!

In case you're using a NumberPicker instance, you have a good range of min-max values and you're calling NumberPicker.setWrapSelectorWheel(false) but it isn't disabling wrapping around the min/max values, it could be a simple issue of re-ordering your method calls. So, this won't work...

numberPicker.setWrapSelectorWheel(false);
numberPicker.setMinValue(0);
numberPicker.setMaxValue(10);
numberPicker.setValue(5);

... but this will...

numberPicker.setMinValue(0);
numberPicker.setMaxValue(10);
numberPicker.setValue(5);
numberPicker.setWrapSelectorWheel(false);

The key is to call NumberPicker.setWrapSelectorWheel(false) after you've set your NumberPicker's min and max values.

Saturday, 23 November 2013

Java/Android - Method for getting a url's domain name

Below is a method which extracts a url's domain name and which uses simple String matching. What it actually does is extract the bit between the first "://" (or index 0 if there's no "://" contained) and the first subsequent "/" (or index String.length() if there's no subsequent "/"). The remaining, preceding "www(_)*." bit is chopped off. I'm sure there'll be cases where this won't be good enough but it should be good enough in most cases!

I read on forums that the java.net.URI class could do this (and was preferred to the java.net.URL class) but I encountered problems with the URI class. Notably, URI.getHost() gives a null value if the url does not include the scheme, i.e. the "http(s)" bit.


/**
* Extracts the domain name from {@code url}
* by means of String manipulation
* rather than using the {@link URI} or {@link URL} class.
*
* @param url is non-null.
* @return the domain name within {@code url}.
*/
public String getUrlDomainName(String url) {
  String domainName = new String(url);

  int index = domainName.indexOf("://");

  if (index != -1) {
    // keep everything after the "://"
    domainName = domainName.substring(index + 3);
  }

  index = domainName.indexOf('/');

  if (index != -1) {
    // keep everything before the '/'
    domainName = domainName.substring(0, index);
  }

  // check for and remove a preceding 'www'
  // followed by any sequence of characters (non-greedy)
  // followed by a '.'
  // from the beginning of the string
  domainName = domainName.replaceFirst("^www.*?\\.", "");

  return domainName;
}