I am creating a complex text view, meaning different text styles in the same view. some of the text needs to have a small image just above it. but the text should still be there (not just replaced) so a simple ImageSpan will not do. I can't use a collection of TextViews because I need the text to wrap(or Am I wrong and this can be done with TextViews?).
I tried to combine two spans over the same characters but while that works for styling the text it does not for the ImageSpan.
What I am going for:
Any ideas?
Reading this blog post: http://old.flavienlaurent.com/blog/2014/01/31/spans/ Helped a lot but i'm still not there.
After reading the excellent article you referenced, poring over Android source code, and coding lots of Log.d()
s, I finally figured out what you need and it is -- are you ready? -- a ReplacementSpan
subclass.
ReplacementSpan
is counter-intuitive for your case because you aren't replacing the text, you're drawing some additional stuff. But it turns out that ReplacementSpan
is what gives you the two things you need: the hook to size the line height for your graphic and the hook to draw your graphic. So you'll just draw the text in there too, since the superclass isn't going to do it.
I've been interested in learning more about spans and text layout, so I started a demo project to play with.
I came up with two different ideas for you. In the first class, you have an icon that you can access as a Drawable
. You pass the Drawable
in on the constructor. Then you use the Drawable
's dimensions to help size your line height. A benefit here is that the Drawable
's dimensions have already been adjusted for the device's display density.
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.drawable.Drawable;
import android.text.style.ReplacementSpan;
import android.util.Log;
public class IconOverSpan extends ReplacementSpan {
private static final String TAG = "IconOverSpan";
private Drawable mIcon;
public IconOverSpan(Drawable icon) {
mIcon = icon;
Log.d(TAG, "<ctor>, icon intrinsic dimensions: " + icon.getIntrinsicWidth() + " x " + icon.getIntrinsicHeight());
}
@Override
public int getSize(Paint paint, CharSequence text, int start, int end, Paint.FontMetricsInt fm) {
/*
* This method is where we make room for the drawing.
* We are passed in a FontMetrics that we can check to see if there is enough space.
* If we need to, we can alter these FontMetrics to suit our needs.
*/
if (fm != null) { // test for null because sometimes fm isn't passed in
/*
* Everything is measured from the baseline, so the ascent is a negative number,
* and the top is an even more negative number. We are going to make sure that
* there is enough room between the top and the ascent line for the graphic.
*/
int h = mIcon.getIntrinsicHeight();
if (- fm.top + fm.ascent < h) {
// if there is not enough room, "raise" the top
fm.top = fm.ascent - h;
}
}
/*
* the number returned is actually the width of the span.
* you will want to make sure the span is wide enough for your graphic.
*/
int textWidth = (int) Math.ceil(paint.measureText(text, start, end));
int w = mIcon.getIntrinsicWidth();
Log.d(TAG, "getSize(), returning " + textWidth + ", fm = " + fm);
return Math.max(textWidth, w);
}
@Override
public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, Paint paint) {
Log.d(TAG, "draw(), x = " + x + ", top = " + top + ", y = " + y + ", bottom = " + bottom);
// first thing we do is draw the text that is not drawn because it is being "replaced"
// you may have to adjust x if the graphic is wider and you want to center-align
canvas.drawText(text, start, end, x, y, paint);
// Set the bounds on the drawable. If bouinds aren't set, drawable won't render at all
// we set the bounds relative to upper left corner of the span
mIcon.setBounds((int) x, top, (int) x + mIcon.getIntrinsicWidth(), top + mIcon.getIntrinsicHeight());
mIcon.draw(canvas);
}
}
The second idea is better if you are going to use really simple shapes for your graphics. You can define a Path
for your shape and then just render the Path
. Now you have to take display density into account, and to make it easy I just take it from a constructor parameter.
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.drawable.Drawable;
import android.text.style.ReplacementSpan;
import android.util.Log;
public class PathOverSpan extends ReplacementSpan {
private static final String TAG = "PathOverSpan";
private float mDensity;
private Path mPath;
private int mWidth;
private int mHeight;
private Paint mPaint;
public PathOverSpan(float density) {
mDensity = density;
mPath = new Path();
mWidth = (int) Math.ceil(16 * mDensity);
mHeight = (int) Math.ceil(16 * mDensity);
// we will make a small triangle
mPath.moveTo(mWidth/2, 0);
mPath.lineTo(mWidth, mHeight);
mPath.lineTo(0, mHeight);
mPath.close();
/*
* set up a paint for our shape.
* The important things are the color and style = fill
*/
mPaint = new Paint();
mPaint.setColor(Color.GREEN);
mPaint.setStyle(Paint.Style.FILL);
}
@Override
public int getSize(Paint paint, CharSequence text, int start, int end, Paint.FontMetricsInt fm) {
/*
* This method is where we make room for the drawing.
* We are passed in a FontMetrics that we can check to see if there is enough space.
* If we need to, we can alter these FontMetrics to suit our needs.
*/
if (fm != null) {
/*
* Everything is measured from the baseline, so the ascent is a negative number,
* and the top is an even more negative number. We are going to make sure that
* there is enough room between the top and the ascent line for the graphic.
*/
if (- fm.top + fm.ascent < mHeight) {
// if there is not enough room, "raise" the top
fm.top = fm.ascent - mHeight;
}
}
/*
* the number returned is actually the width of the span.
* you will want to make sure the span is wide enough for your graphic.
*/
int textWidth = (int) Math.ceil(paint.measureText(text, start, end));
return Math.max(textWidth, mWidth);
}
@Override
public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, Paint paint) {
Log.d(TAG, "draw(), x = " + x + ", top = " + top + ", y = " + y + ", bottom = " + bottom);
// first thing we do is draw the text that is not drawn because it is being "replaced"
// you may have to adjust x if the graphic is wider and you want to center-align
canvas.drawText(text, start, end, x, y, paint);
// calculate an offset to center the shape
int textWidth = (int) Math.ceil(paint.measureText(text, start, end));
int offset = 0;
if (textWidth > mWidth) {
offset = (textWidth - mWidth) / 2;
}
// we set the bounds relative to upper left corner of the span
canvas.translate(x + offset, top);
canvas.drawPath(mPath, mPaint);
canvas.translate(-x - offset, -top);
}
}
Here's how I used these classes in the main activity:
SpannableString spannableString = new SpannableString("Some text and it can have an icon over it");
UnderlineSpan underlineSpan = new UnderlineSpan();
IconOverSpan iconOverSpan = new IconOverSpan(getResources().getDrawable(R.drawable.ic_star));
PathOverSpan pathOverSpan = new PathOverSpan(getResources().getDisplayMetrics().density);
spannableString.setSpan(underlineSpan, 5, 9, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
spannableString.setSpan(iconOverSpan, 21, 25, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
spannableString.setSpan(pathOverSpan, 29, 38, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
TextView textView = (TextView) findViewById(R.id.textView);
textView.setText(spannableString);
There! Now we both learned something.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With