Draw multi-line text to Canvas

Viewed 101295

A hopefully quick question, but I can't seem to find any examples... I'd like to write multi-line text to a custom View via a Canvas, and in onDraw() I have:

...
String text = "This is\nmulti-line\ntext";
canvas.drawText(text, 100, 100, mTextPaint);
...

I was hoping this would result in line breaks, but instead I am seeing cryptic characters where the \n would be.

Any pointers appreciated.

Paul

18 Answers

Yes. Use canvas.getFontSpacing() as the increment. I've tried it myself out of curiosity and it works for any font-size.

I worked with what I had, which already converted single lines to canvases, and I worked off Lumis's answer, and I ended up with this. The 1.3 and 1.3f are meant as padding between lines, relative to the size of the font.

public static Bitmap getBitmapFromString(final String text, final String font, int textSize, final int textColor)
{
    String lines[] = text.split("\n");
    textSize = getRelX(textSize);  //a method in my app that adjusts the font size relative to the screen size
    Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    paint.setTextSize(textSize);
    paint.setColor(textColor);
    paint.setTextAlign(Paint.Align.LEFT);
    Typeface face = Typeface.createFromAsset(GameActivity.getContext().getAssets(),GameActivity.getContext().getString(R.string.font) + font + GameActivity.getContext().getString(R.string.font_ttf));
    paint.setTypeface(face);
    float baseline = -paint.ascent(); // ascent() is negative
    int width = (int) (paint.measureText(text) + 0.5f); // round
    int height = (int) (baseline + paint.descent() + 0.5f);
    Bitmap image = Bitmap.createBitmap(width, (int)(height * 1.3 * lines.length), Bitmap.Config.ARGB_8888);
    Canvas canvas = new Canvas(image);
    for (int i = 0; i < lines.length; ++i)
    {
        canvas.drawText(lines[i], 0, baseline + textSize * 1.3f * i, paint);
    }
    return image;
}

In addition to drawing multiline text, one may struggle with getting the multiline text bounds (for instance in order to align it on canvas).
Default paint.getTextBounds() will not work in this case as it will measure the only line.

For convenience, I created these 2 extension functions: one for drawing multiline text and the other is for getting text bounds.

private val textBoundsRect = Rect()

/**
 * Draws multi line text on the Canvas with origin at (x,y), using the specified paint. The origin is interpreted
 * based on the Align setting in the paint.
 *
 * @param text The text to be drawn
 * @param x The x-coordinate of the origin of the text being drawn
 * @param y The y-coordinate of the baseline of the text being drawn
 * @param paint The paint used for the text (e.g. color, size, style)
 */
fun Canvas.drawTextMultiLine(text: String, x: Float, y: Float, paint: Paint) {
    var lineY = y
    for (line in text.split("\n")) {
        drawText(line, x, lineY, paint)
        lineY += paint.descent().toInt() - paint.ascent().toInt()
    }
}

/**
 * Retrieve the text boundary box, taking into account line breaks [\n] and store to [boundsRect].
 *
 * Return in bounds (allocated by the caller [boundsRect] or default mutable [textBoundsRect]) the smallest rectangle that
 * encloses all of the characters, with an implied origin at (0,0).
 *
 * @param text string to measure and return its bounds
 * @param start index of the first char in the string to measure. By default is 0.
 * @param end 1 past the last char in the string to measure. By default is test length.
 * @param boundsRect rect to save bounds. Note, you may not supply it. By default, it will apply values to the mutable [textBoundsRect] and return it.
 * In this case it will be changed by each new this function call.
 */
fun Paint.getTextBoundsMultiLine(
    text: String,
    start: Int = 0,
    end: Int = text.length,
    boundsRect: Rect = textBoundsRect
): Rect {
    getTextBounds(text, start, end, boundsRect)
    val linesCount = text.split("\n").size
    val allLinesHeight = (descent().toInt() - ascent().toInt()) * linesCount
    boundsRect.bottom = boundsRect.top + allLinesHeight
    return boundsRect
}

Now using it is as easy as that: For drawing multiline text:

canvas.drawTextMultiLine(text, x, y, yourPaint)

For measuring text:

val bounds = yourPaint.getTextBoundsMultiLine(text)

In this case, it will measure all text from the start to the end and with using of default once allocated (mutable) Rect. You may play around with passing extra parameters for extra flexibility.

I faced similar problem. but I should returned the path of text. you can draw this path on Canvas. this is my code. I use Break Text. and path.op

           public Path createClipPath(int width, int height) {
            final Path path = new Path();
            if (textView != null) {
                mText = textView.getText().toString();
                mTextPaint = textView.getPaint();
                float text_position_x = 0;
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
                    text_position_x = findTextBounds(textView).left;

                }
                boolean flag = true;
                int line = 0;
                int startPointer = 0;
                int endPointer = mText.length();

                while (flag) {
                    Path p = new Path();
                    int breakText = mTextPaint.breakText(mText.substring(startPointer), true, width, null);
                    mTextPaint.getTextPath(mText, startPointer, startPointer + breakText, text_position_x,
                            textView.getBaseline() + mTextPaint.getFontSpacing() * line, p);
                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
                        path.op(p, Path.Op.UNION);
                    }
                    endPointer -= breakText;
                    startPointer += breakText;
                    line++;
                    if (endPointer == 0) {
                        flag = false;
                    }
                }

            }
            return path;
        }

and for finding text bound I used this function

    @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN_MR1)
private Rect findTextBounds(TextView textView) {
    // Force measure of text pre-layout.
    textView.measure(0, 0);
    String s = (String) textView.getText();

    // bounds will store the rectangle that will circumscribe the text.
    Rect bounds = new Rect();
    Paint textPaint = textView.getPaint();

    // Get the bounds for the text. Top and bottom are measured from the baseline. Left
    // and right are measured from 0.
    textPaint.getTextBounds(s, 0, s.length(), bounds);
    int baseline = textView.getBaseline();
    bounds.top = baseline + bounds.top;
    bounds.bottom = baseline + bounds.bottom;
    int startPadding = textView.getPaddingStart();
    bounds.left += startPadding;

    // textPaint.getTextBounds() has already computed a value for the width of the text,
    // however, Paint#measureText() gives a more accurate value.
    bounds.right = (int) textPaint.measureText(s, 0, s.length()) + startPadding;
    return bounds;
}

This is my solution. It is not perfect, but worked for me.

public static Bitmap textAsBitmap(String text, float textSize, int textColor) {
    int lines = 1;
    String lineString1 = "", lineString2 = "";
    String[] texts = text.split(" ");
    if (texts.length > 2) {
        for (int i = 0; i < 2; i++) {
            lineString1 = lineString1.concat(texts[i] + " ");
        }
        for (int i = 2; i < texts.length; i++) {
            lineString2 = lineString2.concat(texts[i] + "");
        }
    } else {
        lineString1 = text;
    }
    lineString1 = lineString1.trim();
    lineString2 = lineString2.trim();

    String[] lastText = new String[2];
    lastText[0] = lineString1;
    if (!lineString2.equals("")) {
        lines = 2;
        lastText[1] = lineString2;
    }

    Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    paint.setTextSize(textSize);
    paint.setColor(textColor);
    paint.setTextAlign(Paint.Align.LEFT);
    float baseline = -paint.ascent(); // ascent() is negative
    String maxLengthText = "";
    if (lines == 2) {
        if (lineString1.length() > lineString2.length()) {
            maxLengthText = maxLengthText.concat(lineString1);
        } else {
            maxLengthText = maxLengthText.concat(lineString2);
        }
    } else {
        maxLengthText = maxLengthText.concat(text);
    }
    int width = (int) (paint.measureText(maxLengthText) + 0.5f); // round
    int height = (int) ((baseline + paint.descent() + 0.5f) * lines);
    Bitmap image = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
    Canvas canvas = new Canvas(image);

    for (int i = 0; i < lines; i++) {
        canvas.drawText(lastText[i], 0, baseline, paint);
        baseline *= lines;
    }

    return image;
}

I designed a better way (I can't really say if it's better or not, but this method should be easy) for multi-line text in a canvas, like in a SurfaceView.

Here would be the code:

public class MultiLineText implements ObjectListener {

    private String[] lines;
    private float x, y, textSize;
    private int textColor;

    private float currentY;

    public MultiLineText(String[] lines, float x, float y, float textSize, int textColor) {
        this.lines = lines;
        this.x = x;
        this.y = y;
        this.textSize = textSize;
        this.textColor = textColor;
    }

    @Override
    public void draw(Canvas canvas) {
        Paint paint = new Paint();
        paint.setColor(textColor);
        paint.setTextSize(textSize);

        currentY = y;

        for (int i = 0; i < lines.length; i++) {
            if (i == 0)
                canvas.drawText(lines[i], x, y, paint);
            else {
                currentY = currentY + textSize;
                canvas.drawText(lines[i], x, currentY, paint);
            }
        }
    }

    @Override
    public void update() {

    }
}

Import the 2 classes with import android.graphics.Canvas; and import android.graphics.Paint; to make sure no errors can occur.

On the easy hand, create an Interface Class named "ObjectListener" (or whatever you want to call it, just change the name then), and add two following lines of code:

void draw(Canvas canvas);

void update();

To implement this, use this code in the View or your Renderer on draw(Canvas canvas) method:

new MultiLineText(new String[]{
        "This is a multi-line text.",
        "It's setup is basic. Just do the following code,",
        "and you would be done."
}, 150, 150, 32, Color.WHITE).draw(canvas);

Sorry, I just wanted to implement this text, so yeah... You can change the X and Y coordinates from 150 to your liking. A text Size of 26 is readable, and it is not too big because Canvas renders in a small text by default.

Related