The basic problem is that the height is not set for the ReplacementSpan
. As stated in the the source for ReplacementSpan
:
If the span covers the whole text, and the height is not set, draw(Canvas, CharSequence, int, int, float, int, int, int, Paint)} will not be called for the span.
This is a repeat of what Archit Sureja posted. In my original post, I updated the height of the ReplacementSpan
in getSize()
but I now implement the LineHeightSpan.WithDensity
interface to do the same. (Thanks to vovahost here for this information.)
There are, however, additional issues that you have brought up that need to be addressed.
The issue raised by your supplied project is that the dot does not fit within the TextView
in which it must reside. What you are seeing is truncation of the dot. What to do if the size of the dot exceeds either the width of the text or its height?
First, regarding the height, the chooseHeight()
method of interface LineHeightSpan.WithDensity
adjusts what is considered the bottom of the TextView
font by adding in the size of the dot to the font's effective height. To do this, the height of the dot is added to the the bottom of the font:
fontMetricsInt.bottom = fm.bottom + mDotSize.toInt();
(This is a change from the last iteration of this answer which used the TextView
's padding. Since this change, the TextView
no longer is needed by the UnderDotSpan
class. Although I had added the TextView
, it is not really needed.)
The last issue is that the dot is cutoff at the start and end if it is wider than the text. clipToPadding="false"
doesn't work here because the dot is cutoff not because it is being clipped to the padding but because it is being clipped to what we said the text width is in getSize()
. To fix this, I modified the getSize()
method to detect when the dot is wider than the text measurement and to increase the returned value to match the dot's width. A new value called mStartShim
is the amount that must be applied to the drawing of the text and the dot to make things fit.
The final issue is that the center of the dot is the radius of the dot below the bottom of the text and not the diameter, so the code to draw the dot was changed in draw()
to:
canvas.drawCircle(x + textSize / 2, bottom.toFloat(), mDotSize / 2, paint)
(I also changed the code to do Canvas
translation instead of adding the offsets. The effect is the same.)
Here is the result:
activity_main.xml
<android.support.constraint.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/darker_gray">
<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:background="@android:color/white"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</android.support.constraint.ConstraintLayout>
MainActivity.java
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val text = "1"
val spannable = SpannableString(text)
spannable.setSpan(UnderDotSpan(this@MainActivity, 0xFF039BE5.toInt(), textView.currentTextColor),
0, text.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
textView.setText(spannable, TextView.BufferType.SPANNABLE)
}
}
UnderDotSpan.kt
// From the original UnderDotSpan: Also implement the LineHeightSpan.WithDensity interface to
// compute the height of our "dotted" font.
class UnderDotSpan(private val mDotSize: Float, private val mDotColor: Int, private val mTextColor: Int) : ReplacementSpan(), LineHeightSpan.WithDensity {
companion object {
@JvmStatic
private val DEFAULT_DOT_SIZE_IN_DP = 16
}
// Additional horizontal space to the start, if needed, to fit the dot
var mStartShim = 0;
constructor(context: Context, dotColor: Int, textColor: Int)
: this(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, DEFAULT_DOT_SIZE_IN_DP.toFloat(),
context.resources.displayMetrics), dotColor, textColor)
// ReplacementSpan override to determine the size (length) of the text.
override fun getSize(paint: Paint, text: CharSequence, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int {
val baseTextWidth = paint.measureText(text, start, end)
// If the width of the text is less than the width of our dot, increase the text width
// to match the dot's width; otherwise, just return the width of the text.
mStartShim = if (baseTextWidth < mDotSize) ((mDotSize - baseTextWidth) / 2).toInt() else 0
return Math.round(baseTextWidth + mStartShim * 2)
}
override fun draw(canvas: Canvas, text: CharSequence, start: Int, end: Int, x: Float, top: Int,
y: Int, bottom: Int, paint: Paint) {
if (TextUtils.isEmpty(text)) {
return
}
val textSize = paint.measureText(text, start, end)
paint.color = mDotColor
canvas.save()
// Draw the circle in the horizontal center and under the text. Add in the
// offset (mStartShim) if we had to increase the length of the text to accommodate our dot.
canvas.translate(mStartShim.toFloat(), -mDotSize / 2)
// Draw a circle, but this could be any other shape or drawable. It just has
// to fit into the allotted space which is the size of the dot.
canvas.drawCircle(x + textSize / 2, bottom.toFloat(), mDotSize / 2, paint)
paint.color = mTextColor
// Keep the starting shim, but reset the y-translation to write the text.
canvas.translate(0f, mDotSize / 2)
canvas.drawText(text, start, end, x, y.toFloat(), paint)
canvas.restore()
}
// LineHeightSpan.WithDensity override to determine the height of the font with the dot.
override fun chooseHeight(charSequence: CharSequence, i: Int, i1: Int, i2: Int, i3: Int,
fontMetricsInt: Paint.FontMetricsInt, textPaint: TextPaint) {
val fm = textPaint.fontMetricsInt
fontMetricsInt.top = fm.top
fontMetricsInt.ascent = fm.ascent
fontMetricsInt.descent = fm.descent
// Our "dotted" font now must accommodate the size of the dot, so change the bottom of the
// font to accommodate the dot.
fontMetricsInt.bottom = fm.bottom + mDotSize.toInt();
fontMetricsInt.leading = fm.leading
}
// LineHeightSpan.WithDensity override that is needed to satisfy the interface but not called.
override fun chooseHeight(charSequence: CharSequence, i: Int, i1: Int, i2: Int, i3: Int,
fontMetricsInt: Paint.FontMetricsInt) {
}
}
For the more general case of placing a small drawable under the text the following class works and is based upon UnderDotSpan
:
UnderDrawableSpan.java
public class UnderDrawableSpan extends ReplacementSpan implements LineHeightSpan.WithDensity {
final private Drawable mDrawable;
final private int mDrawableWidth;
final private int mDrawableHeight;
final private int mMargin;
// How much we need to jog the text to line up with a larger-than-text-width drawable.
private int mStartShim = 0;
UnderDrawableSpan(Context context, Drawable drawable, int drawableWidth, int drawableHeight,
int margin) {
DisplayMetrics metrics = context.getResources().getDisplayMetrics();
mDrawable = drawable;
mDrawableWidth = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,
(float) drawableWidth, metrics);
mDrawableHeight = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,
(float) drawableHeight, metrics);
mMargin = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,
(float) margin, metrics);
}
@Override
public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x, int top, int y,
int bottom, @NonNull Paint paint) {
if (TextUtils.isEmpty(text)) {
return;
}
float textWidth = paint.measureText(text, start, end);
float offset = mStartShim + x + (textWidth - mDrawableWidth) / 2;
mDrawable.setBounds(0, 0, mDrawableWidth, mDrawableHeight);
canvas.save();
canvas.translate(offset, bottom - mDrawableHeight);
mDrawable.draw(canvas);
canvas.restore();
canvas.save();
canvas.translate(mStartShim, 0);
canvas.drawText(text, start, end, x, y, paint);
canvas.restore();
}
// ReplacementSpan override to determine the size (length) of the text.
@Override
public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, Paint.FontMetricsInt fm) {
float baseTextWidth = paint.measureText(text, start, end);
// If the width of the text is less than the width of our drawable, increase the text width
// to match the drawable's width; otherwise, just return the width of the text.
mStartShim = (baseTextWidth < mDrawableWidth) ? (int) (mDrawableWidth - baseTextWidth) / 2 : 0;
return Math.round(baseTextWidth + mStartShim * 2);
}
// LineHeightSpan.WithDensity override to determine the height of the font with the dot.
@Override
public void chooseHeight(CharSequence charSequence, int i, int i1, int i2, int i3,
Paint.FontMetricsInt fontMetricsInt, TextPaint textPaint) {
Paint.FontMetricsInt fm = textPaint.getFontMetricsInt();
fontMetricsInt.top = fm.top;
fontMetricsInt.ascent = fm.ascent;
fontMetricsInt.descent = fm.descent;
// Our font now must accommodate the size of the drawable, so change the bottom of the
// font to accommodate the drawable.
fontMetricsInt.bottom = fm.bottom + mDrawableHeight + mMargin;
fontMetricsInt.leading = fm.leading;
}
// Required but not used.
@Override
public void chooseHeight(CharSequence charSequence, int i, int i1, int i2, int i3,
Paint.FontMetricsInt fontMetricsInt) {
}
}
Use of the following drawable XML with UnderDrawableSpan
produces this result:. (Width and height of the drawable is set to 12dp
. Font size of the text is 24sp
.)
gradient_drawable.xml
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<size
android:width="4dp"
android:height="4dp" />
<gradient
android:type="radial"
android:gradientRadius="60%p"
android:endColor="#e96507"
android:startColor="#ece6e1" />
</shape>
I had an opportunity to revisit this question and answer recently. I am posting a more flexible version of the UnderDrawableSpan code. There is a demo project on GitHub.
UnderDrawableSpan.kt (updated)
/**
* Place a drawable at the bottom center of text within a span. Because this class is extended
* from [ReplacementSpan], the span must reside on a single line and cannot span lines.
*/
class UnderDrawableSpan(
context: Context, drawable: Drawable, drawableWidth: Int, drawableHeight: Int, margin: Int
) : ReplacementSpan(), LineHeightSpan.WithDensity {
// The image to draw under the spanned text. The image and text will be horizontally centered.
private val mDrawable: Drawable
// The width if the drawable in dip
private var mDrawableWidth: Int
// The width if the drawable in dip
private var mDrawableHeight: Int
// Margin in dip to place around the drawable
private var mMargin: Int
// Amount to offset the text from the start.
private var mTextOffset = 0f
// Amount to offset the drawable from the start.
private var mDrawableOffset = 0f
// Descent specified in font metrics of the TextPaint.
private var mBaseDescent = 0f
init {
val metrics: DisplayMetrics = context.resources.displayMetrics
mDrawable = drawable
mDrawableWidth = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, drawableWidth.toFloat(), metrics
).toInt()
mDrawableHeight = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, drawableHeight.toFloat(), metrics
).toInt()
mMargin = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, margin.toFloat(), metrics
).toInt()
}
override fun draw(
canvas: Canvas, text: CharSequence, start: Int, end: Int, x: Float, top: Int, y: Int,
bottom: Int, paint: Paint
) {
canvas.drawText(text, start, end, x + mTextOffset, y.toFloat(), paint)
mDrawable.setBounds(0, 0, mDrawableWidth, mDrawableHeight)
canvas.save()
canvas.translate(x + mDrawableOffset + mMargin, y + mBaseDescent + mMargin)
mDrawable.draw(canvas)
canvas.restore()
}
// ReplacementSpan override to determine the width that the text and drawable should occupy.
// The computed width is determined by the greater of the text width and the drawable width
// plus the requested margins.
override fun getSize(
paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?
): Int {
val textWidth = paint.measureText(text, start, end)
val additionalWidthNeeded = mDrawableWidth + mMargin * 2 - textWidth
// If the width of the text is less than the width of our drawable, increase the text width
// to match the drawable's width; otherwise, just return the width of the text.
return if (additionalWidthNeeded >= 0) {
// Drawable is wider than text, so we need to offset the text to center it.
mTextOffset = additionalWidthNeeded / 2
textWidth + additionalWidthNeeded
} else {
// Text is wider than the drawable, so we need to offset the drawable to center it.
// We do not need to expand the width.
mDrawableOffset = -additionalWidthNeeded / 2
textWidth
}.toInt()
}
// Determine the height for the ReplacementSpan.
override fun chooseHeight(
text: CharSequence?, start: Int, end: Int, spanstartv: Int, lineHeight: Int,
fm: Paint.FontMetricsInt, paint: TextPaint
) {
// The text height must accommodate the size of the drawable. To make the accommodation,
// change the bottom of the font so there is enough room to fit the drawable between the
// font bottom and the font's descent.
val tpMetric = paint.fontMetrics
mBaseDescent = tpMetric.descent
val spaceAvailable = fm.descent - mBaseDescent
val spaceNeeded = mDrawableHeight + mMargin * 2
if (spaceAvailable < spaceNeeded) {
fm.descent += (spaceNeeded - spaceAvailable).toInt()
fm.bottom = fm.descent + (tpMetric.bottom - tpMetric.descent).toInt()
}
}
// StaticLayout prefers LineHeightSpan.WithDensity over this function.
override fun chooseHeight(
charSequence: CharSequence?, i: Int, i1: Int, i2: Int, i3: Int, fm: Paint.FontMetricsInt
) = throw IllegalStateException("LineHeightSpan.chooseHeight() called but is not supported.")
}
Html.toHtml
? – Counterpoison<p dir="ltr">1</p>
. I hope to use the code in a similar way to other, normal spans. – VendibleHtml.toHtml
supports only built-in spans, not custom ones – Annatto