Skip to content

Instantly share code, notes, and snippets.

@markusfisch
Last active September 9, 2024 07:09
Show Gist options
  • Save markusfisch/2655909 to your computer and use it in GitHub Desktop.
Save markusfisch/2655909 to your computer and use it in GitHub Desktop.
Draw text in a given rectangle and automatically wrap lines on a Android Canvas
package de.markusfisch.android.textrect.widget;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.RectF;
import android.view.View;
import de.markusfisch.android.textrect.graphics.TextRect;
public class DemoBubblesView extends View {
private static final String[] TEXTS = new String[]{
"Hi there, I'm a blue bubble.",
"Me too!",
"There are a lot of bubbles around here. And all of them are blue.",
"And now for something completely different. According to Wikipedia, the origin of this phrase \"is credited to Christopher Trace, founding presenter of the children's television programme Blue Peter, who used it (in all seriousness) as a link between segments\". Interesting, isn't it?",
"Lorem ipsum is so boring.",
"Draw text in a given rectangle and automatically wrap lines.",
"Don't forget to rotate your device.",
};
private static final int CELLS = (int) Math.ceil(Math.sqrt(TEXTS.length));
private final RectF bubbleRect = new RectF();
private final Paint bubblePaint = new Paint();
private final TextRect textRect;
private int outerPadding;
private int outerPaddingBoth;
private int bubblePadding;
public DemoBubblesView(Context context) {
super(context);
float dp = context.getResources().getDisplayMetrics().density;
outerPadding = Math.round(16f * dp);
outerPaddingBoth = (CELLS + 1) * outerPadding;
bubblePadding = Math.round(8f * dp);
// create text rect for this font
{
Paint fontPaint = new Paint();
fontPaint.setColor(Color.WHITE);
fontPaint.setAntiAlias(true);
fontPaint.setTextSize(14 * dp);
textRect = new TextRect(fontPaint);
}
bubblePaint.setStyle(Paint.Style.FILL);
bubblePaint.setColor(Color.BLUE);
bubblePaint.setAntiAlias(true);
}
@Override
public void onDraw(Canvas canvas) {
int bubbleWidth = (getWidth() - outerPaddingBoth) / CELLS;
int bubbleHeight = (getHeight() - outerPaddingBoth) / CELLS;
int x = outerPadding;
int y = outerPadding;
for (int i = 0, l = TEXTS.length; i < l; ) {
drawTextBubble(canvas, x, y, bubbleWidth, bubbleHeight,
bubblePadding, TEXTS[i]);
if (++i % CELLS == 0) {
x = outerPadding;
y += bubbleHeight + outerPadding;
} else {
x += bubbleWidth + outerPadding;
}
}
}
private void drawTextBubble(
Canvas canvas,
int x,
int y,
int width,
int height,
int padding,
String text) {
int paddingBoth = padding * 2;
int h = textRect.prepare(
text,
width - paddingBoth,
height - paddingBoth);
bubbleRect.set(x, y, x + width, y + h + paddingBoth);
canvas.drawRoundRect(bubbleRect, padding, padding, bubblePaint);
textRect.draw(canvas, x + padding, y + padding);
}
}
package de.markusfisch.android.textrect.activity;
import android.app.Activity;
import android.os.Bundle;
import de.markusfisch.android.textrect.widget.DemoBubblesView;
public class MainActivity extends Activity {
@Override
public void onCreate(Bundle state) {
super.onCreate(state);
setContentView(new DemoBubblesView(this));
}
}
package de.markusfisch.android.textrect.graphics;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Paint.FontMetricsInt;
import android.graphics.Rect;
/**
* Draw text in a given rectangle and automatically wrap lines.
*/
public class TextRect {
// maximum number of lines; this is a fixed number in order
// to use a predefined array to avoid ArrayList (or something
// similar) because filling it does involve allocating memory
static private int MAX_LINES = 256;
// those members are stored per instance to minimize
// the number of allocations to avoid triggering the
// GC too much
private FontMetricsInt metrics;
private Paint paint;
private int[] starts = new int[MAX_LINES];
private int[] stops = new int[MAX_LINES];
private int lines = 0;
private int textHeight = 0;
private Rect bounds = new Rect();
private String text = null;
private boolean wasCut = false;
/**
* Create reusable text rectangle (use one instance per font).
*
* @param paint paint specifying the font
*/
public TextRect(Paint paint) {
metrics = paint.getFontMetricsInt();
this.paint = paint;
}
/**
* Calculate height of text block and prepare to draw it.
*
* @param text text to draw
* @param maxWidth maximum width in pixels
* @param maxHeight maximum height in pixels
* @returns height of text in pixels
*/
public int prepare(String text, int maxWidth, int maxHeight) {
lines = 0;
textHeight = 0;
this.text = text;
wasCut = false;
// get maximum number of characters in one line
paint.getTextBounds("i", 0, 1, bounds);
final int maximumInLine = maxWidth / bounds.width();
final int length = text.length();
if (length < 1) {
return 0;
}
final int lineHeight = -metrics.ascent + metrics.descent;
int start = 0;
int stop = Math.min(maximumInLine, length);
for (; ; ) {
// skip LF and spaces
for (; start < length; ++start) {
char ch = text.charAt(start);
if (ch != '\n' &&
ch != '\r' &&
ch != '\t' &&
ch != ' ') {
break;
}
}
for (int o = stop + 1; stop < o && stop > start; ) {
o = stop;
int lowest = text.indexOf("\n", start);
paint.getTextBounds(
text,
start,
stop,
bounds);
if ((lowest >= start && lowest < stop) ||
bounds.width() > maxWidth) {
--stop;
if (lowest < start || lowest > stop) {
int blank = text.lastIndexOf(" ", stop);
int hyphen = text.lastIndexOf("-", stop);
if (blank > start &&
(hyphen < start || blank > hyphen)) {
lowest = blank;
} else if (hyphen > start) {
lowest = hyphen;
}
}
if (lowest >= start && lowest <= stop) {
char ch = text.charAt(stop);
if (ch != '\n' && ch != ' ') {
++lowest;
}
stop = lowest;
}
continue;
}
break;
}
if (start >= stop) {
break;
}
int minus = 0;
// cut off lf or space
if (stop < length) {
char ch = text.charAt(stop - 1);
if (ch == '\n' || ch == ' ') {
minus = 1;
}
}
if (textHeight + lineHeight > maxHeight) {
wasCut = true;
break;
}
starts[lines] = start;
stops[lines] = stop - minus;
if (++lines > MAX_LINES) {
wasCut = true;
break;
}
if (textHeight > 0) {
textHeight += metrics.leading;
}
textHeight += lineHeight;
if (stop >= length) {
break;
}
start = stop;
stop = length;
}
return textHeight;
}
/**
* Draw prepared text at given position.
*
* @param canvas canvas to draw text into
* @param left left corner
* @param top top corner
*/
public void draw(Canvas canvas, int left, int top) {
if (textHeight == 0) {
return;
}
final int before = -metrics.ascent;
final int after = metrics.descent + metrics.leading;
int y = top;
int lastLine = lines - 1;
for (int i = 0; i < lines; ++i) {
String line;
if (wasCut && i == lastLine && stops[i] - starts[i] > 3) {
line = text.substring(starts[i], stops[i] - 3).concat("...");
} else {
line = text.substring(starts[i], stops[i]);
}
y += before;
canvas.drawText(line, left, y, paint);
y += after;
}
}
/**
* Returns true if text was cut to fit into the maximum height
*/
public boolean wasCut() {
return wasCut;
}
}
@AlbertSnow
Copy link

really cool man, the code is really robust.

@thanhbinh84
Copy link

thanks a lot man

@Lewis-Bright
Copy link

This is super useful. Unless I've mistaken, line 210 has --lines which means that every successive draw cuts a line away. For anyone viewing this in the future, you will probably need to remove that line to make it work. Please correct me if I've missed something

@markusfisch
Copy link
Author

If you remove --lines; it will crash because the condition in the for() loop is n <= lines what means stops[] will cause an OutOfBoundsException in the last iteration. The reason for decrementing lines was to know when it's the last line to append the ... in line 220. But you're totally right: invoking draw() multiple times without calling prepare() beforehand will lead not work :(

So I've updated the code to make successive draw() calls possible.

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