androidtextviewspannablespanned

How come my spannable isn't shown?


Background

I'm trying to use a simple SpannableString on a TextView, based on an UnderDotSpan class I've found (here).

The original UnderDotSpan just puts a dot of a specific size and color below the text itself (not overlapping). What I'm trying is to first use it normally, and then use a customized drawable instead of a dot.

The problem

As opposed to a normal span usage, this one just doesn't show anything. Not even the text.

Here's how it's done for a normal span:

val text = "1"
val timeSpannable = SpannableString(text)
timeSpannable.setSpan(ForegroundColorSpan(0xff00ff00.toInt()), 0, text.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
textView.setText(timeSpannable);

it will show a green "1" in the TextView.

But when I try the next spannable, it (entire TextView content: text and dot) doesn't show up at all:

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)
// this also didn't work:       textView.setText(spannable)

Weird thing is that in one project that I use, it works fine inside RecyclerView , and in another, it doesn't.

Here's the code of UnderDotSpan :

class UnderDotSpan(private val mDotSize: Float, private val mDotColor: Int, private val mTextColor: Int) : ReplacementSpan() {
    companion object {
        @JvmStatic
        private val DEFAULT_DOT_SIZE_IN_DP = 4
    }

    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) {}

    override fun getSize(paint: Paint, text: CharSequence, start: Int, end: Int, fm: Paint.FontMetricsInt?) = Math.round(paint.measureText(text, start, end))

    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.drawCircle(x + textSize / 2, bottom + mDotSize, mDotSize / 2, paint)
        paint.color = mTextColor
        canvas.drawText(text, start, end, x, y.toFloat(), paint)
    }

}

Note that the TextView doesn't have any special properties, but I will show it anyway:

<android.support.constraint.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent"
    android:layout_height="match_parent" tools:context="com.example.user.myapplication.MainActivity">

    <TextView android:id="@+id/textView"
        android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Hello World!"
        app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toTopOf="parent"/>

</android.support.constraint.ConstraintLayout>

What I've tried

I tried to extend from other span classes, and also tried to set the text to the TextView in other ways.

I've also tried other span classes I've made, based on the UnderDotSpan class. Example:

class UnderDrawableSpan(val drawable: Drawable, val drawableWidth: Int = drawable.intrinsicWidth, val drawableHeight: Int = drawable.intrinsicHeight, val margin: Int = 0) : ReplacementSpan() {
    override fun getSize(paint: Paint, text: CharSequence, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int = Math.round(paint.measureText(text, start, end))

    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)

        canvas.drawText(text, start, end, x, y.toFloat(), paint)
        canvas.save()
        canvas.translate(x + textSize / 2f - drawableWidth / 2f, y.toFloat() + margin)
        if (drawableWidth != 0 && drawableHeight != 0)
            drawable.setBounds(0, 0, drawableWidth, drawableHeight)
        drawable.draw(canvas)
        canvas.restore()
    }

}

While debugging, I've found that draw function isn't even called, while getSize do get called (and returns a >0 value).

The question

Why can't the span be shown on the TextView ?

What's wrong with the way I've used it?

How can I fix it, and use this span ?

How come it might work in other, more complex cases?


Solution

  • 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:

    enter image description here

    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.)

    enter image description here

    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.")
    }