One TextView to Rule Them All: Justifying Text on Android!

Published Dec 15, 2017
One TextView to Rule Them All: Justifying Text on Android!

The Pre-Game!


What’s the problem?

Android TextView does not support justified text!

We see articles everywhere about integrating libraries into your projects, but, as developers, we can’t become so dependent on third party libraries that we forget how to customize native tools and components to do what we need them to do.

Don’t get me wrong — I love finding the perfect library to do most of the work for me, but this may not always be the correct approach! There is even a library to help you justify text, but this is not necessarily the best way to do what we need — the developer even says this in the readme file.

In this article, we will dive into native Android customizability... because sometimes you just need to get your hands dirty and do things yourself!

What are we going to do about it?

We are going to create our own TextView!

Don’t freak out! We are not starting from scratch, but, in fact, we will extend Android’s native AppCompatTextView to build our own SmartTextView. We will be customizing this view to draw the text onto the canvas ourselves!

In this article, you will learn what methods we need to override, as well as what native view methods we will need to calculate positions and render the text.

The hidden purpose of this article...

More than just learning about Android's TextView, you will:

  1. Become less intimidated changing native Android views.
  2. See how granular we can get and how much control we have over native customizability.
  3. Maybe even start to believe that with patience and persistence, we can do almost anything in Android!

What you need before we begin:

You don’t need to be an Android master, but you will need:

  • Some experience with Android development
  • A project to create our SmartTextView inside
  • A good understanding of strings, characters, and index based traversal

Game On! Let’s get started!


Development Breakdown

Here is a quick summary of the work we are about to do:

  1. Extend the AppCompatTextView and understand important attributes and methods
  2. Override the onDraw() method to draw the text on the canvas ourselves!
  3. Additional Functionality (Optional Read)

⋅⋅⋅* Configuration with fonts — directly in the SmartTextView
⋅⋅⋅* Switching text displayed in the view (with a default fade animation)
4. Add support for our attributes in XML to set everything up in layout files.
5. Go the extra mile to make our SmartTextView more efficient! (Optional Read)
6. The Full Class Code

1. Extend the AppCompatTextView

First, let's extend the AppCompatTextView, create some constructors, and an empty initialization method initialize that we will worry about later:

public class SmartTextView extends AppCompatTextView {

  public SmartTextView(Context context) {
        super(context);
        initialize(null);
    }

    public SmartTextView(Context context, AttributeSet attrs) {
        super(context, attrs);
        initialize(attrs);
    }

    public SmartTextView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        initialize(attrs);
    }

    private void initialize(AttributeSet attrs) { }

}

Now that we have a generic custom class to work with, let's understand some AppCompatTextView methods we are going to need:

  1. onDraw(Canvas canvas) — we will need to override this to draw the text
  2. getCurrentTextColor() — this should be self-explanatory...
  3. getDrawableState() — returns list of active state (clicked, checked, touched, etc..) drawables to render (shadow, backgrounds, etc...)
  4. getLineHeight() — returns the height of a line taking into account the font, default line spacing, and extra added space
  5. getMeasuredWidth() — returns the width of our view as it will be on-screen
  6. getPaddingLeft(), getPaddingRight() — returns the view's respective padding
  7. getPaint() — returns the Paint that is used to draw the text

Don't worry about remembering these or understanding why we need them just yet, this will become clear as we move on.

2. Override the onDraw(Canvas canvas) method

This is the bread and butter! Now we will be writing the code to actually draw the words on our SmartTextView's canvas. Here is the full code for the onDraw(Canvas canvas) method:

@Override
protected void onDraw(Canvas canvas) {
    if (!mJustified) {
        super.onDraw(canvas);
        return;
    }

    // Manipulations to mTextPaint found in super.onDraw()...
    getPaint().setColor(getCurrentTextColor());
    getPaint().drawableState = getDrawableState();

    // The actual String that contains all the words we will draw on screen
    String fullText = mAllCaps ? getText().toString().toUpperCase() : getText().toString();

    float lineSpacing = getLineHeight();
    float drawableWidth = getDrawableWidth();

  // Variables we need to traverse our fullText and build our lines
    int lineNum = 1, lineStartIndex = 0;
    int lastWordEnd, currWordEnd = 0;

    if (fullText.indexOf(' ', 0) == -1) flushWord(canvas, getPaddingTop() + lineSpacing, fullText);
    else {
        while (currWordEnd >= 0) {
            lastWordEnd = currWordEnd + 1;
            currWordEnd = fullText.indexOf(' ', lastWordEnd);

            if (currWordEnd != -1) {
                getPaint().getTextBounds(fullText, lineStartIndex, currWordEnd, mLineBounds);

                if (mLineBounds.width() >= drawableWidth) {
                    flushLine(canvas, lineNum, fullText.substring(lineStartIndex, lastWordEnd));
                    lineStartIndex = lastWordEnd;
                    lineNum++;
                }

            } else {
                getPaint().getTextBounds(fullText, lineStartIndex, fullText.length(), mLineBounds);

                if (mLineBounds.width() >= drawableWidth) {
                    flushLine(canvas, lineNum, fullText.substring(lineStartIndex, lastWordEnd));
                    rawFlushLine(canvas, ++lineNum, fullText.substring(lastWordEnd));
                } else {
                    if (lineNum == 1) {
                        rawFlushLine(canvas, lineNum, fullText);
                    } else {
                        rawFlushLine(canvas, lineNum, fullText.substring(lineStartIndex));
                    }
                }
            }

        }
    }
}

So, let us go through this code step by step, and start by defining these magical flush methods. For now, just know what they do. We will dive into their implementations soon enough:

rawFlushLine(Canvas canvas, int lineNum, String line)
Prints the line at lineNum on the canvas without adjusting the spacing between words at all.

flushLine(Canvas canvas, int lineNum, String line)
Calculates the space between all words required to strech the line out to fit the full width of the view.

flushWord(Canvas canvas, float yLine, String word)
Spaces the letters of the word out to strech (or squish) to fit the full width of the view.

Now, what are we doing in the onDraw(Canvas canvas) method?

First, it is easy to understand that if we are not justifying the text, we can use the TextView's base onDraw(Canvas canvas) method to render the text normally:

if (!mJustified) {
    super.onDraw(canvas);
    return;
}

Next, we need to make the base changes to the private TextView variable mTextPaint that are normally done by the base method. We access mTextPaint with getPaint(). Don't worry too much about these — I found them by trial and error as well as by analyzing the base code:

getPaint().setColor(getCurrentTextColor());
getPaint().drawableState = getDrawableState();

The TextView's mTextPaint variable is extremely important because it takes into account the font, text size, text style (bold, italics, etc) and pretty much everything that has to do with our text. It is used in all calculations to determine line heights, text widths, etc...

Once we have set up the TextView's paint, we can define the vaiables we need for our calculations:

// The actual String that contains all of the words we will draw on screen
String fullText = mAllCaps ? getText().toString().toUpperCase() : getText().toString();

float lineSpacing = getLineHeight();
float drawableWidth = getDrawableWidth();

// Variables we need to traverse our fullText and build our lines
int lineNum = 1, lineStartIndex = 0;
int lastWordEnd, currWordEnd = 0;

mAllCaps is a variable we use to simplify tracking if all displayed text should be capitalized, and to ensure this value is accurate, we override the setAllCaps method:

@Override
public void setAllCaps(boolean allCaps) {
    mAllCaps = allCaps;
    super.setAllCaps(allCaps);
}

getDrawableWidth() is our method that calculates the width of the TextView so that we can actually draw on by taking into account the padding:

private float getDrawableWidth() {
    return getMeasuredWidth() - getPaddingLeft() - getPaddingRight();
}

Now, we can start traversing fullText and flushing our lines as we create them:

The first edge case is if we only have one word, in which case we simply flush the word:

if (fullText.indexOf(' ', 0) == -1) flushWord(canvas, getPaddingTop() + lineSpacing, fullText);

If we do indeed have several words, we need to go through fullText word by word.

while (currWordEnd >= 0) {
    lastWordEnd = currWordEnd + 1;
    currWordEnd = fullText.indexOf(' ', lastWordEnd);
  ...   
}

As we traverse our fullText, we need to build our lines and draw them. A line is defined by the most words that can fit in the drawable width of our view. If including the next word makes the length of our line greater than our drawable width, we can close this line and draw it on our canvas.

if (currWordEnd != -1) {
    getPaint().getTextBounds(fullText, lineStartIndex, currWordEnd, mLineBounds);

    if (mLineBounds.width() >= drawableWidth) {
        flushLine(canvas, lineNum, fullText.substring(lineStartIndex, lastWordEnd));
        lineStartIndex = lastWordEnd;
        lineNum++;
    }
}

if (currWordEnd != -1) is used to ensure that we do in fact have another word. If it returns false, then we have hit the end of fullText.

mLineBounds is used to store the height and width of the substring between the indexes lineStartIndex and currWordEnd inside fullText. Remember when I emphasized the importance of our mTextPaint? We are now using it to calculate these bounds:

getPaint().getTextBounds(fullText, lineStartIndex, currWordEnd, mLineBounds);

Finally, if we see that including this next word makes our line too big, we exclude it by flushing the substring between lineStartIndex and lastWordEnd (the index of the previous word's last character):

if (mLineBounds.width() >= drawableWidth) {
  flushLine(canvas, lineNum, fullText.substring(lineStartIndex, lastWordEnd));
    ...

If we do not have another word, and we have run through the entire fullText, we need to print our last line. This line does not need to be justified. However, there are a few edge cases to consider:

  1. If including the final word makes the line larger then the drawable width, we need to break it up and draw the final word on the next line. This may be a little confusing — if it is, take a closer look at what lastWordEnd and currWordEnd are defined as for this iteration.
  2. If this is the first line, we can ignore lineStartIndex and just flush fullText.

Here is the code that handles the last line (which is determined implicitly by currWordEnd == -1 with the else):

else {
    getPaint().getTextBounds(fullText, lineStartIndex, fullText.length(), mLineBounds);

    if (mLineBounds.width() >= drawableWidth) {
        // flush everthing up untill the end of the final word
        flushLine(canvas, lineNum, fullText.substring(lineStartIndex, lastWordEnd));
        // flush the final word (here lastWordEnd can be inturpreted as the start of the final word in the line) 
        rawFlushLine(canvas, ++lineNum, fullText.substring(lastWordEnd));
    } else {
        if (lineNum == 1) {
            rawFlushLine(canvas, lineNum, fullText);
        }
        else {
            rawFlushLine(canvas, lineNum, fullText.substring(lineStartIndex));
        }
    }
}

That's it for onDraw(Canvas canvas)!

Before we get into the details of our flush methods, we need to talk about mFirstLineTextHeight. We need this variable to hold the raw pixel height of a single line not including the spacing between lines. The following method will calcuate this:

private void setFirstLineTextHeight(String firstLine) {
    getPaint().getTextBounds(firstLine, 0, firstLine.length(), mLineBounds);
    mFirstLineTextHeight = mLineBounds.height();
}

Now, what are we doing in our flush methods?

flushLine: Calculates the space between all words required to strech the line out to fit the full width of the view:

private void flushLine(Canvas canvas, int lineNum, String line) {
    if (lineNum == 1) setFirstLineTextHeight(line);

    float yLine = getPaddingTop() + mFirstLineTextHeight + (lineNum - 1) * getLineHeight();

    String[] words = line.split("\\s+");
    StringBuilder lineBuilder = new StringBuilder();

    for (String word : words) {
        lineBuilder.append(word);
    }

    float xStart = getPaddingLeft();
    float wordWidth = getPaint().measureText(lineBuilder.toString());
    float spacingWidth = (getDrawableWidth() - wordWidth) / (words.length - 1);

    for (String word : words) {
        canvas.drawText(word, xStart, yLine, getPaint());
        xStart += getPaint().measureText(word) + spacingWidth;
    }
}

Some important calculations in this method are the following:
yLine - The y point of the bottom left corner of the word we want to draw. This is calculated by adding the top padding (getPaddingTop()) + the raw height of the first line (mFirstLineTextHeight) + the height of the lines preceeding the first line, which accounts for the spacing between the lines ((lineNum - 1) * getLineHeight()):

float yLine = getPaddingTop() + mFirstLineTextHeight + (lineNum - 1) * getLineHeight();

Remember, getLineHeight() takes into account the spacing between lines.
Also, this value is the same for all words in this line.

yPointMeasurements.png

wordWidth — this is the width of all of the words, without the spaces in between. To calculate this, we can store all of the words (just the words, no spaces) in another string and use getPaint().measureText() to calculate the width:

String[] words = line.split("\\s+");
StringBuilder lineBuilder = new StringBuilder();

for (String word : words) {
    lineBuilder.append(word);
}

float wordWidth = getPaint().measureText(lineBuilder.toString());

spacingWidth — this is the average space between words. A simple calculation after we have the wordWidth:

float spacingWidth = (getDrawableWidth() - wordWidth) / (words.length - 1);

The last thing left to do is to actually draw each word of this line onto the canvas. Here we have the yLine, which is the same for all words. We can simultaneously draw a word and calculate the xStart for the next word:

for (String word : words) {
    canvas.drawText(word, xStart, yLine, getPaint());
    xStart += getPaint().measureText(word) + spacingWidth;
}

rawFlushLine — prints the line at lineNum on the canvas without adjusting the spacing between words at all. Here, we can simply draw the entire line at the calculated position with yLine and getPaddingLeft() as the x point:

private void rawFlushLine(Canvas canvas, int lineNum, String line) {
  if (lineNum == 1) setFirstLineTextHeight(line);

    float yLine = getPaddingTop() + mFirstLineTextHeight + (lineNum - 1) * getLineHeight();
    canvas.drawText(line, getPaddingLeft(), yLine, getPaint());
}

flushWord — spaces the letters of the word out to strech (or squish) to fit the full width of the view. This is very similar to flushLine. However, spacingWidth refers to the spacing between letters. We draw the word on the canvas character by character:

private void flushWord(Canvas canvas, float yLine, String word) {
    float xStart = getPaddingLeft();
    float wordWidth = getPaint().measureText(word);
    float spacingWidth = (getDrawableWidth() - wordWidth) / (word.length() - 1);

    for (int i = 0; i < word.length(); i++) {
        canvas.drawText(word, i, i + 1, xStart, yLine, getPaint());
        xStart += getPaint().measureText(word, i, i + 1) + spacingWidth;
    }
}

That is everything for drawing the justified text on our canvas ourselves!

Now, I understand that this may be a lot of information, so here is a shortened recap of what we did in onDraw(Canvas canvas):

  1. Break the text up into words
  2. Traverse word by word, appending the words to our line as we traverse
  3. When no more words can fit in the line, we flush the line.
    Flushing the line:
    1. We determine the y position of this line
    2. We determine the spacing between words
    3. We draw each word one by one using the constant y position, and the changing x postion of each word.

3. Additional Functionality * — Optional Read*

Our SmartTextView is already pretty smart with this Justify functionality, but this is probably not enough for it to be the "One TextView to Rule Them All!" To take it even further, let's add support for the following:

  1. Configuration with fonts — directly in the SmartTextView
  2. Switching text displayed in the view (with a default fade animation)

1. Configuration with fonts — directly in the SmartTextView

We will be allowing the configuration of three fonts, one for each FontType, defined as:

public enum FontType {
    Thin, Reg, Thick
}

Let's store the fonts in a static HashMap for easy access. Also, let's create public static methods to register a font Typeface for each:

private static final HashMap<FontType, Typeface> mRegisteredFonts = new HashMap<>();

public static void registerFont(FontType fType, Typeface fTypeface) {
    if (mRegisteredFonts.containsKey(fType)) mRegisteredFonts.remove(fType);
    mRegisteredFonts.put(fType, fTypeface);
}
public static Typeface getFontTypeface(FontType fType) {
    return mRegisteredFonts.get(fType);
}

Ideally, registerFont should only be called once in the Application's onCreate() method for each FontType. An example call would be:

AssetManager assets = getAssets();
SmartTextView.registerFont(SmartTextView.FontType.Thin, Typeface.createFromAsset(assets, "fonts/Roboto-Thin.ttf"));

Now that we have added the functionality to register fonts for the SmartTextView class, let's add some methods to set the font for a particular SmartTextView instance:

public void setFontType(FontType fType) {
    mFontType = fType;
    setTypeface(getFontTypeface());
}
public void setFontType(FontType fType, int style) {
    mFontType = fType;
    setTypeface(getFontTypeface(), style);
}

The second method is just to make it easier to set the font and the style (bold, italics, etc.) at the same time.

In setFontType, we also keep track of which FontType is currently set for this 'SmartTextView' with the property mFontType.

2. Switching text displayed in the view (with a default fade animation)

We will be adding very basic functionality to switch the text being displayed in our SmartTextView with a basic fade transition animation. This is basically just a simple method to standardize updating text:

public void switchText(int resId) {
    switchText(getResources().getString(resId));
}
public void switchText(final String newText) {
    animate().alpha(0).setDuration(150).withEndAction(new Runnable() {
        @Override public void run() {
            setText(newText);
            animate().alpha(1).setDuration(150).start();
        }
    }).start();
}

The first method just makes it easier to display text we have saved as a resource.

4. Add support for our attributes in XML

We definitely want to be able to set all of this great functionality directly from our layout XML files when we add a SmartTextView. We need to add attributes to justify text and set the FontType. These attributes need to be defined in the attrs.xml file inside the res/values folder of our app. We also need to update our implementation of SmartTextView to take them into account.

First, let's add the attributes to the attrs.xml file:

<declare-styleable name="SmartTextView">
    <attr name="justified" format="boolean"/>
    <attr name="android:textAllCaps"/>
    <attr name="android:textStyle"/>
    <attr name="font" format="enum">
        <enum name="thin" value="0"/>
        <enum name="reg" value="1"/>
        <enum name="thick" value="2"/>
    </attr>
</declare-styleable>

font attribute is in regards to the Additional Functionality (optional) section.
android:textAllCaps and android:textStyle are native Android TextView attributes, but we need to add them to our declaration so that we can access them from our custom SmartTextView.

The attributes can now be set in our XML layouts as follows:

<ra.smarttextview.SmartTextView
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_marginTop="100dp"
    android:layout_marginBottom="30dp"
    android:textColor="@color/text_bright"
    android:text="@string/cmx_title"
    android:textAllCaps="true"
    android:singleLine="true"
    android:textSize="40sp"
    app:justified="true"
    app:font="thin"/>

Note: the last two attributes are our custom attributes, accessed using the preface app:

Finally, we need to read these attributes in our constructors and utilize them. For this, we will implement the initialize method we are calling in all of our constructors:

private void initialize(AttributeSet attrs) {
    int styleIndex = 0;

    if (attrs != null) {
        TypedArray attrArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.SmartTextView, 0, 0);
        if (attrArray != null) {
            mJustified = attrArray.getBoolean(R.styleable.SmartTextView_justified, false);
            mFontType = FontType.values()[attrArray.getInt(R.styleable.SmartTextView_font, 1)];
            mAllCaps = attrArray.getBoolean(R.styleable.SmartTextView_android_textAllCaps, false);
            styleIndex = attrArray.getInt(R.styleable.SmartTextView_android_textStyle, 0);
        }
    }

    setTypeface(getFontTypeface(), styleIndex);
}

getFontTypeface is our internal method for getting the registered Typeface for our current FontType:

private Typeface getFontTypeface() {
    return !mRegisteredFonts.containsKey(mFontType) ? getTypeface() : mRegisteredFonts.get(mFontType);
}

It uses the native getTypeface() method to get the system font if we do not have a registered one for this FontType

5. Go the Extra Mile - Optional Read

At this point, we have done a lot! But for the over achievers who want to take it even further, you can attempt to make our implementation of onDraw() and the flush methods even more efficient.

You may have noticed that I am doing a lot of String manipulations here, and these immutable String objects are constantly being created and destroyed through the implementation. Can you work with chars arrays where possible to minimize overhead? Give it a try if you're up for the challenge!

6. The Full Class Code

And finally... here is the complete code for SmartTextView:

import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Rect;
import android.graphics.Typeface;
import android.support.v7.widget.AppCompatTextView;
import android.util.AttributeSet;

import java.util.HashMap;

/**
 * Created by rugvedambekar on 2016-04-10.
 */
public class SmartTextView extends AppCompatTextView {

    private static final String TAG = SmartTextView.class.getSimpleName();

    private static final HashMap<FontType, Typeface> mRegisteredFonts = new HashMap<>();

    public static void registerFont(FontType fType, Typeface fTypeface) {
        if (mRegisteredFonts.containsKey(fType)) mRegisteredFonts.remove(fType);
        mRegisteredFonts.put(fType, fTypeface);
    }
    public static Typeface getFontTypeface(FontType fType) {
        return mRegisteredFonts.get(fType);
    }

    private boolean mJustified;
    private boolean mAllCaps;
    private FontType mFontType;

    private int mFirstLineTextHeight = 0;
    private Rect mLineBounds = new Rect();

    public SmartTextView(Context context) {
        super(context);
        initialize(null);
    }

    public SmartTextView(Context context, AttributeSet attrs) {
        super(context, attrs);
        initialize(attrs);
    }

    public SmartTextView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        initialize(attrs);
    }

    private void initialize(AttributeSet attrs) {
        int styleIndex = 0;

        if (attrs != null) {
            TypedArray attrArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.SmartTextView, 0, 0);
            if (attrArray != null) {
                mJustified = attrArray.getBoolean(R.styleable.SmartTextView_justified, false);
                mFontType = FontType.values()[attrArray.getInt(R.styleable.SmartTextView_font, 1)];
                mAllCaps = attrArray.getBoolean(R.styleable.SmartTextView_android_textAllCaps, false);
                styleIndex = attrArray.getInt(R.styleable.SmartTextView_android_textStyle, 0);
            }
        }

        setTypeface(getFontTypeface(), styleIndex);
    }

    private Typeface getFontTypeface() {
        return !mRegisteredFonts.containsKey(mFontType) ? getTypeface() : mRegisteredFonts.get(mFontType);
    }

    public void setJustified(boolean justified) {
        mJustified = justified;
    }

    public void setFontType(FontType fType) {
        mFontType = fType;
        setTypeface(getFontTypeface());
    }
    public void setFontType(FontType fType, int style) {
        mFontType = fType;
        setTypeface(getFontTypeface(), style);
    }

    @Override
    public void setAllCaps(boolean allCaps) {
        mAllCaps = allCaps;
        super.setAllCaps(allCaps);
    }

    @Override
    public void setTypeface(Typeface tf, int style) {
        if (tf == null) tf = getFontTypeface();
        super.setTypeface(tf, style);
    }

    public void switchText(int resId) {
        switchText(getResources().getString(resId));
    }
    public void switchText(final String newText) {
        animate().alpha(0).setDuration(150).withEndAction(new Runnable() {
            @Override public void run() {
                setText(newText);
                animate().alpha(1).setDuration(150).start();
            }
        }).start();
    }

    @Override
    protected void onDraw(Canvas canvas) {
        if (!mJustified) {
            super.onDraw(canvas);
            return;
        }

        // Manipulations to mTextPaint found in super.onDraw()...
        getPaint().setColor(getCurrentTextColor());
        getPaint().drawableState = getDrawableState();

        String fullText = mAllCaps ? getText().toString().toUpperCase() : getText().toString();

        float lineSpacing = getLineHeight();
        float drawableWidth = getDrawableWidth();

        int lineNum = 1, lineStartIndex = 0;
        int lastWordEnd, currWordEnd = 0;
        
        if (fullText.indexOf(' ', 0) == -1) flushWord(canvas, getPaddingTop() + lineSpacing, fullText);
        else {
        while (currWordEnd >= 0) {
            lastWordEnd = currWordEnd + 1;
            currWordEnd = fullText.indexOf(' ', lastWordEnd);

                if (currWordEnd != -1) {
                    getPaint().getTextBounds(fullText, lineStartIndex, currWordEnd, mLineBounds);

                    if (mLineBounds.width() >= drawableWidth) {
                        flushLine(canvas, lineNum, fullText.substring(lineStartIndex, lastWordEnd));
                        lineStartIndex = lastWordEnd;
                        lineNum++;
                    }

                } else {
                    getPaint().getTextBounds(fullText, lineStartIndex, fullText.length(), mLineBounds);

                    if (mLineBounds.width() >= drawableWidth) {
                        flushLine(canvas, lineNum, fullText.substring(lineStartIndex, lastWordEnd));
                        rawFlushLine(canvas, ++lineNum, fullText.substring(lastWordEnd));
                    } else {
                        if (lineNum == 1) {
                            rawFlushLine(canvas, lineNum, fullText);
                        }
                        else {
                            rawFlushLine(canvas, lineNum, fullText.substring(lineStartIndex));
                        }
                    }
                }

            }
        }
    }

    private float getDrawableWidth() {
        return getMeasuredWidth() - getPaddingLeft() - getPaddingRight();
    }

    private void setFirstLineTextHeight(String firstLine) {
        getPaint().getTextBounds(firstLine, 0, firstLine.length(), mLineBounds);
        mFirstLineTextHeight = mLineBounds.height();
    }

    private void rawFlushLine(Canvas canvas, int lineNum, String line) {
        if (lineNum == 1) setFirstLineTextHeight(line);

        float yLine = getPaddingTop() + mFirstLineTextHeight + (lineNum - 1) * getLineHeight();
        canvas.drawText(line, getPaddingLeft(), yLine, getPaint());
    }

    private void flushLine(Canvas canvas, int lineNum, String line) {
        if (lineNum == 1) setFirstLineTextHeight(line);

        float yLine = getPaddingTop() + mFirstLineTextHeight + (lineNum - 1) * getLineHeight();
        
        String[] words = line.split("\\s+");
        StringBuilder lineBuilder = new StringBuilder();

        for (String word : words) {
            lineBuilder.append(word);
        }

        float xStart = getPaddingLeft();
        float wordWidth = getPaint().measureText(lineBuilder.toString());
        float spacingWidth = (getDrawableWidth() - wordWidth) / (words.length - 1);

        for (String word : words) {
            canvas.drawText(word, xStart, yLine, getPaint());
            xStart += getPaint().measureText(word) + spacingWidth;
        }
    }

    private void flushWord(Canvas canvas, float yLine, String word) {
        float xStart = getPaddingLeft();
        float wordWidth = getPaint().measureText(word);
        float spacingWidth = (getDrawableWidth() - wordWidth) / (word.length() - 1);
        
        for (int i = 0; i < word.length(); i++) {
            canvas.drawText(word, i, i + 1, xStart, yLine, getPaint());
            xStart += getPaint().measureText(word, i, i + 1) + spacingWidth;
        }
    }

    public enum FontType {
        Thin, Reg, Thick
    }
}

The Post-Game!


We got through it! I know it might have been longer than you expected, but now that you have made it to the other side, I hope a few things are clearer — like just how granular we can get when manipulating native Android views and how even if the perfect third party library does not exist, that shouldn't deter us from doing what we need!

There is nothing wrong with getting our hands dirty and tinkering with native Android components — we just have to be careful, patient, and persistent by reading the core code and taking into account all of the edge cases.

Thanks for reading! I hope you enjoyed this article!

Discover and read more posts from Rugved Ambekar
get started