Justifying text inside a TextView in android

13,950

So, after looking a bit more at this: https://github.com/ufo22940268/android-justifiedtextview and TextView in general, I discovered that my main problem was my approach.

Using the approach of scaling the width of the " " characters was sound in theory, but after doing so, the width of the line changes again, as it seems that the width of the line is NOT the sum of its parts.

I have changed my approach and took inspiration from the link above, and so in my new approach I draw each character by itself, instead of drawing the whole line. If the text needs to be justified (based on a custom "justify" boolean attribute) then it will draw the line and justify it, else it will just draw the line.

Edit: I have changed the code now so that it also supports RTL texts. I will upload the code somewhere in the next few days.

Here's the result: justify textview

Here's the code:

public class DTextView extends AppCompatTextView {


    private boolean justify;
    private float textAreaWidth;
    private float spaceCharSize;
    private float lineY;

    public DTextView(Context context) {
        super(context);
    }

    public DTextView(Context context, AttributeSet attrs) {
        super(context, attrs);
        init(attrs);
    }

    public DTextView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(attrs);
    }

    /**
     * @param attrs the attributes from the xml
     *              This function loads all the parameters from the xml
     */
    private void init(AttributeSet attrs) {

        TypedArray ta = getContext().obtainStyledAttributes(attrs, R.styleable.DTextView, 0, 0);

        justify = ta.getBoolean(R.styleable.DTextView_justify, false);

        ta.recycle();
    }

    @Override
    protected void onDraw(Canvas canvas) {
        drawText(canvas);
    }

    private void drawText(Canvas canvas) {
        TextPaint paint = getPaint();
        paint.setColor(getCurrentTextColor());
        paint.drawableState = getDrawableState();
        textAreaWidth = getMeasuredWidth() - (getPaddingLeft() + getPaddingRight());

        spaceCharSize = paint.measureText(" ");

        String text = getText().toString();
        lineY = getTextSize();

        Layout textLayout = getLayout();

        if (textLayout == null)
            return;

        Paint.FontMetrics fm = paint.getFontMetrics();
        int textHeight = (int) Math.ceil(fm.descent - fm.ascent);
        textHeight = (int) (textHeight * getLineSpacingMultiplier() + textLayout.getSpacingAdd());

        for (int i = 0; i < textLayout.getLineCount(); i++) {

            int lineStart = textLayout.getLineStart(i);
            int lineEnd = textLayout.getLineEnd(i);

            float lineWidth = StaticLayout.getDesiredWidth(text, lineStart, lineEnd, paint);
            String line = text.substring(lineStart, lineEnd);

            if (line.charAt(line.length() - 1) == ' ') {
                line = line.substring(0, line.length() - 1);
            }

            if (justify && i < textLayout.getLineCount() - 1) {
                drawLineJustified(canvas, line, lineWidth);
            } else {
                canvas.drawText(line, 0, lineY, paint);
            }

            lineY += textHeight;
        }

    }

    private void drawLineJustified(Canvas canvas, String line, float lineWidth) {
        TextPaint paint = getPaint();

        float emptySpace = textAreaWidth - lineWidth;
        int spaces = line.split(" ").length - 1;
        float newSpaceSize = (emptySpace / spaces) + spaceCharSize;

        float charX = 0;

        for (int i = 0; i < line.length(); i++) {
            String character = String.valueOf(line.charAt(i));
            float charWidth = StaticLayout.getDesiredWidth(character, paint);
            if (!character.equals(" ")) {
                canvas.drawText(character, charX, lineY, paint);
            }

            if (character.equals(" ") && i != line.length() - 1)
                charX += newSpaceSize;
            else
                charX += charWidth;
        }

    }
}

and the XML:

<il.co.drapp.views.text.DTextView
                android:layout_width="match_parent"
                android:inputType="textMultiLine|textNoSuggestions"
                app:justify="true"
                android:id="@+id/justifyText"
                android:text="@string/article_dummy_text"
                android:layout_height="wrap_content" />

Thanks to Aditya Vyas-Lakhan for the links

Share:
13,950
ShayR
Author by

ShayR

Updated on June 15, 2022

Comments

  • ShayR
    ShayR about 2 years

    So, as most of you know, there is no text justifying inside a TextView in Android. So, I built a custom TextView to get around the problem. However, for some reason, sometimes punctuation marks break the line for some reason in some devices. I tested on an LG G3 and emulator (Nexus 4 running latest version) and a comma "," for instance breaks the justification on the LG G3 but not on the emulator.

    If I add a Padding start and end (or left and right) of at least 2, the problem is solved. This looks very arbitrary to me.

    Basically, my logic was that in order to justify the text, I would need to know the width of the TextView itself, construct the text into lines that are at maximum that length. Then, by finding the number of spaces in the line and the remaining empty space, stretch the " " (space) characters to be scaled according to remaining pixels (or, space in the view).

    It works almost perfectly, and most of the time it supports RTL text as well.

    here're some pictures of the text (a simple lorem impsum) with and without the offending marks (first one is on emulator nexus 4 running 7.1.1, second one is on LG G3 running v5.0) Text on emulator running nexus 4 running Text on LG G3 running android 5.0

    Here's the code:

    public class DTextView extends AppCompatTextView {
    
        private boolean justify;
    
        public DTextView(Context context) {
            super(context);
        }
    
        public DTextView(Context context, @Nullable AttributeSet attrs) {
            super(context, attrs);
            init(attrs);
        }
    
        public DTextView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
            super(context, attrs, defStyleAttr);
            init(attrs);
        }
    
        private void setJustify(boolean justify) {
            this.justify = justify;
            if (justify) {
                justify();
            }
        }
    
        private void init(@Nullable AttributeSet attrs) {
            TypedArray ta = getContext().obtainStyledAttributes(attrs, R.styleable.DTextView, 0, 0);
            justify = ta.getBoolean(R.styleable.DTextView_justify, false);
    
            ta.recycle();
        }
    
        private SpannableStringBuilder justifyText() {
    
            String[] words = getText().toString().split(" ");
            setText("");
    
            int maxLineWidth = getWidth() - getPaddingLeft() - getPaddingRight();
    
            SpannableStringBuilder justifiedTextSpannable = new SpannableStringBuilder();
    
            //This will build the new text with the lines rearranged so that they will have a width
            //bigger than the View's own width
            ArrayList<String> lines = new ArrayList<>(0);
            String line = "";
            for (String word : words) {
                if (getWordWidth(line + word) < maxLineWidth) {
                    line += word + " ";
                } else {
                    line = line.substring(0, line.length() - 1);
                    lines.add(line);
                    line = word + " ";
                }
            }
            //Add the last line
            lines.add(line);
    
            for (int i = 0; i < lines.size() - 1; i++) {
                justifiedTextSpannable.append(justifyLine(lines.get(i), maxLineWidth));
                justifiedTextSpannable.append("\n");
            }
    
            justifiedTextSpannable.append(lines.get(lines.size() - 1));
    
    
            return justifiedTextSpannable;
        }
    
        private SpannableString justifyLine(String line, float maxWidth) {
    
            SpannableString sLine = new SpannableString(line);
            float spaces = line.split(" ").length - 1;
    
            float spaceCharSize = getWordWidth(" ");
            float emptySpace = maxWidth - getWordWidth(line);
            float newSpaceSize = (emptySpace / spaces) + spaceCharSize;
            float scaleX = newSpaceSize / spaceCharSize;
    
            for (int i = 0; i < line.length(); i++) {
                if (line.charAt(i) == ' ') {
                    sLine.setSpan(new ScaleXSpan(scaleX), i, i + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
                }
            }
    
            return sLine;
        }
    
        private void justify() {
            justify = false;
            setText(justifyText());
            invalidate();
        }
    
        private float getWordWidth(String word) {
            return getPaint().measureText(word);
        }
    
        @Override
        protected void onDraw(Canvas canvas) {
            if (!justify)
                super.onDraw(canvas);
            else
                justify();
        }
    }
    

    I would very much appreciate anyone that can shed some light on this.

  • ShayR
    ShayR about 7 years
    I already saw this one when searching online. Two reasons it is not good for me: 1. It doesn't support Android >5 and 2. I wanted to do it myself as an exercise
  • Amit Thakkar
    Amit Thakkar about 7 years
    Appreciate your enthusiasm. Everyone is here to learn new things only. But you can also take it as a reference to create your own custom view.
  • ShayR
    ShayR about 7 years
    I see that my implementation is a bit similar to the one here github.com/navabi/JustifiedTextView. I actually get the width of the textview the same way. I still have no idea why it works on some devices and some not. I have tried with 6.x, 5.x and 7.x and it seems like it works when it wants
  • ShayR
    ShayR about 7 years
    I am looking deeper at this library, and although it looks cool, it uses the ScrollView as its base. I am using TextView as the base so I can still enjoy all the other aspects of TextView. Also, because TextView has builtin support for scrolling, a ScrollView is not needed
  • Mohammad Afrashteh
    Mohammad Afrashteh about 6 years
    Is this whole of your code? Does your solution work on all android versions?
  • ShayR
    ShayR about 6 years
    This is the old code. The new code should support RTL (including parenthesis and such). It's an old side project that I haven't used in a long time, so I will upload it to Github sometime this week.
  • Mohammad Afrashteh
    Mohammad Afrashteh about 6 years
    Thank you. I used your code, it correctly justified text. Please share your new code to support RTL contexts.
  • ShayR
    ShayR about 6 years
    Ok, had time today so there it is, you can find it in this git repo: github.com/shayr1/android
  • Sam
    Sam about 2 years
    This answer is missing the stylable resources. I couldn't compile it.
  • Sam
    Sam about 2 years
    Why is justify never updated in the repo?