10

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?

android developer
  • 114,585
  • 152
  • 739
  • 1,270

4 Answers4

6

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.")
}
Cheticamp
  • 61,413
  • 10
  • 78
  • 131
  • I still see it truncated. Look : https://i.stack.imgur.com/GXVza.png . Even if I add more padding to the bottom, it's still truncated. I've even tried to use 'android:clipToPadding="false" android:clipChildren="false" ' on both the TextView and its parent. – android developer Jan 03 '18 at 08:30
  • @androiddeveloper That is odd. The truncated dot seems to be much larger than the `4dp` that you specify especially when compared to the text size. I went back and incorporated your `UnderDotSpan` class into my demo. The results were the same. I have updated my answer with the exact code that I am running with an updated image. Maybe it can help you identify the issue. I would check `mDotSize` in the draw method to make sure that it is what you expect. – Cheticamp Jan 03 '18 at 13:45
  • I don't get why the clipping attributes do not help. I also don't get why the code I've written works in some cases, and on some it doesn't. Please let me know if you have a stable solution. – android developer Jan 03 '18 at 14:00
  • @androiddeveloper Can you present some code that does not work as expected and post it here? – Cheticamp Jan 03 '18 at 14:04
  • ok, updated question to have full project link. Search for "project available here" – android developer Jan 03 '18 at 14:34
  • @androiddeveloper That project doesn't display the text at all - a condition that I have reproduced. I was looking for a project that displays the truncated dot. – Cheticamp Jan 03 '18 at 15:37
  • That's what I work with. You can just put your code there instead (some is commented, so you can un-comment it). Please start from there. – android developer Jan 03 '18 at 15:52
  • @androiddeveloper See rewrite – Cheticamp Jan 03 '18 at 19:02
  • Seems to work well. What did you do? What was missing? What was wrong? What would you change in order for the UnderDrawableSpan to work as well? For now I grant the bounty. If you succeed answering all of those, you will also get accepted answer and +1 :) – android developer Jan 03 '18 at 21:18
  • @androiddeveloper .`UnderDrawableSpan` works as intended and as you documented. What was missing is some code in the larger calendar project where you found `UnderDrawableSpan ` to set the height of the span. I didn't track that down, but the height just needs to be set sometime before drawing. The clipping issues are non-existent as long as the dot fits in the defined bounds of the `TextView`. Problems arose because the dot was `16dp` and not `4dp` as used in that same calendar project. – Cheticamp Jan 03 '18 at 21:38
  • @androiddeveloper As for `UnderDrawableSpan`, I would take the updated `UnderDotSpan` and replace `draw()`. Also, pass in the `TextView` and work from there. Everything should basically stay the same. I assume the drawable isn't overly large. – Cheticamp Jan 03 '18 at 21:39
  • Please show the parts you've changed. I don't see what you did to make it work. The previous code didn't even show the text. Please also show the modified UnderDrawableSpan that will make it work. Are you sure it's considered ok to change padding of the TextView within the span? Is it safe? Is it a common thing to do? – android developer Jan 04 '18 at 09:25
  • @androiddeveloper You are right to challenge the use of `TextView` padding to make the dot fit. I have changed that - see the update. I have also added comments into the code to highlight the changes I made to `UnderDotSpan`. In essence, this class create a new, augmented font type that is just the font of the `TextView` with the addition of the dot underneath. – Cheticamp Jan 04 '18 at 14:43
  • Seems nice too. You've replaced the `canvas.drawText(text, start, end, x + startShim, y.toFloat(), paint)` with code that moves the text above the dot. But can you please show how to make the UnderDrawableSpan also work? – android developer Jan 04 '18 at 15:02
  • @androiddeveloper What is your drawable for `UnderDrawableSpan`? It works the same as `UnderDotSpan` just with a drawable and not a circle? – Cheticamp Jan 04 '18 at 15:05
  • Just a simple drawable. Could be a gradientDrawable that I choose to set its size at runtime, for example. – android developer Jan 04 '18 at 22:25
  • Works, but I don't see "mMargin" being used... Also, what's "shim" ? – android developer Jan 07 '18 at 07:44
  • @androiddeveloper If the graphic is wider than the text, the text must be shifted over to keep everything lined up. `mStartShim` is that amount. The use of margin was undefined, but I kept it in the code so you can apply it as you need to. – Cheticamp Jan 07 '18 at 12:30
  • It's the margin between the text and the dot/drawable . Odd I didn't add it to the dot code. Was sure I did. Currently you put them both directly below the text, without any space, right? If so, I think I can add the margin usage myself. It will just be adding more to the Y axis, right? – android developer Jan 07 '18 at 14:26
  • @androiddeveloper That's correct. Currently, the drawable goes right beneath the text, so the margin will shift the drawable down, so it is an adjustment to where the drawable is placed on the y-axis. – Cheticamp Jan 07 '18 at 14:31
  • Should I put it in `canvas.translate` , or `canvas.drawText` , or it doesn't matter? – android developer Jan 07 '18 at 14:58
  • @androiddeveloper I think that you will have to increase the height by your margin amount. So, in `chooseHeight()` you will have `fontMetricsInt.bottom = fm.bottom + mDrawableHeight + mMargin`. Then in `draw()` you will have `canvas.translate(offset, bottom - mDrawableHeight - mMargin))`. I think that should work OK. – Cheticamp Jan 07 '18 at 15:14
  • I think your calculation is incorrect. If I do this, I get the drawable overlap the text (say, with width&height of 16, and margin of 8, in the parameters ) – android developer Jan 08 '18 at 07:57
  • @androiddeveloper It is incorrect. Add the margin to the height (`ontMetricsInt.bottom = fm.bottom + mDrawableHeight + mMargin`) but a translation is not needed other than what is already there. I updated the code in the answer to add in the margin. – Cheticamp Jan 08 '18 at 13:46
  • Nice. Thank you for all your time. You deserve this answer being accepted and also have +1 . :) – android developer Jan 08 '18 at 14:28
  • Just so you know, I don't think it's common to use DP units as parameter of classes. Almost in every API of Android, it's pure pixels, and if you wish to use DP, you do it outside of the call. – android developer Jan 08 '18 at 14:30
  • @androiddeveloper Good point. You had the default dot size expressed in `dp`, so I just went with that. – Cheticamp Jan 08 '18 at 14:39
  • I've only set it as the default size. Whoever uses the class has the option: if not specified, it uses the default size (DP), but if specified, it's in PX – android developer Jan 08 '18 at 14:55
  • @androiddeveloper I have generalized the class [here](https://github.com/Cheticamp/UnderGraphicSpanDemo) if you are interested . – Cheticamp Jan 08 '18 at 22:40
  • Very nice repo sample. Please update code here too, though. – android developer Jan 09 '18 at 20:37
4

your span is not showing because draw method is not called due to height is not set.

please refer this link

https://developer.android.com/reference/android/text/style/ReplacementSpan.html

GetSize() - Returns the width of the span. Extending classes can set the height of the span by updating attributes of Paint.FontMetricsInt. 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.

Paint.FontMetricsInt object's all variable that we are getting is 0 so there is no hight, so draw method is not called.

For How Paint.FontMatricsInt work you can refer this link.

Meaning of top, ascent, baseline, descent, bottom, and leading in Android's FontMetrics

So we are setting Paint.FontMetricsInt with help of paint object that we are getting in getSize's arguments.

Here is My code I change few things related to set height.

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 = 16
    }

    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?): Int {
        val asd = paint.getFontMetricsInt()
        fm?.leading = asd.leading
        fm?.top = asd.top
        fm?.bottom = asd.bottom
        fm?.ascent = asd.ascent
        fm?.descent = asd.descent
        return Math.round(measureText(paint, 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 /2).toFloat(), mDotSize / 2, paint)
        paint.color = mTextColor
        canvas.drawText(text, start, end, x, y.toFloat(), paint)
    }

    private fun measureText(paint: Paint, text: CharSequence, start: Int, end: Int): Float {
        return paint.measureText(text, start, end)
    }
}

Final output which I getting is like below

enter image description here

UPDATED ANSWER

use this to draw circle below text

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?): Int {
        val asd = paint.getFontMetricsInt()
        fm?.leading = asd.leading + mDotSize.toInt()
        fm?.top = asd.top
        fm?.bottom = asd.bottom
        fm?.ascent = asd.ascent
        fm?.descent = asd.descent
        return 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)
    }
}

and last one IMP

val text = "1\n" instead of val text = "1"

Archit Sureja
  • 416
  • 2
  • 7
  • First of all, this code for some reason puts the dot on the text, instead of beneath it. What do you mean "height is not set" ? Where in your code is it set? What did you change in the code? How come the code I've used works in some cases, and in some it doesn't ? What should be done to fix the UnderDrawableSpan, too? – android developer Dec 31 '17 at 07:48
  • 1
    I am getting dots beneath text. see my screen shot which I have added recently.Second as I told in my answer we are getting 0 in all variable of Paint.FontMatricsInt which set the hight of your textview. You can refer link which I added recently in answer. So hight of textview is not set automatically which is happened in other spannable classes. – Archit Sureja Jan 02 '18 at 04:42
  • I don't see a dot beneath the text on your example. I see one on top of another. I meant in the Y axis. Not the Z axis. In other words, this means you need to look down a bit to see the dot. About the issue, I don't understand, then, how come it works in some cases. All you did is change the `getSize` implementation? – android developer Jan 02 '18 at 07:37
  • 1
    I have changed canvas.drawCircle path. so it is coming in top of another in y axis please check updated answer. About the issue, I don't understand too, how come it works in some cases, may be in some case hight is set by android, didn't research in deep. – Archit Sureja Jan 02 '18 at 08:31
  • I don't want to set the TextView to have match_parent as the width (or height). The dot should be under the text, no matter how large the text is. Also, the new code has the dot truncated in its original case (see here: https://i.stack.imgur.com/BbLlj.png ) , and doesn't show a dot at all in the new code of putting the dot below the text (see here: https://i.stack.imgur.com/DXXqi.png ) . Using leading (meaning `paint.fontMetricsInt.leading`) also caused a truncated dot (see here : https://i.stack.imgur.com/epE8e.png ) – android developer Jan 02 '18 at 09:15
  • I've also tried to avoid clipping of the TextView, using `android:clipChildren="false" android:clipToPadding="false"` on it and on its parent, but this didn't work. – android developer Jan 02 '18 at 09:17
  • can you use text "archit sureja \n" like this means with newLine . If you can you can use it with `fm?.leading = asd.leading + mDotSize.toInt()` and `canvas.drawCircle(x + textSize / 2, bottom.toFloat()+mDotSize / 2, mDotSize / 2, paint)` – Archit Sureja Jan 02 '18 at 09:26
  • Let us [continue this discussion in chat](http://chat.stackoverflow.com/rooms/162327/discussion-between-archit-sureja-and-android-developer). – Archit Sureja Jan 02 '18 at 09:29
  • I don't understand your new instructions. Please just use the UnderDotDrawable as I've used. The text is very short, and the dot should still be shown, just like on the repo (here: https://github.com/hidroh/calendar , see screenshot here: https://github.com/hidroh/calendar/blob/master/screenshots/2.png ) . – android developer Jan 02 '18 at 09:36
  • Still bad result. The dot is truncated (see here: https://i.stack.imgur.com/mZTAW.png ) . And the need of the `\n` should be removed too. I don't want the TextView to affect anything around it, more than it should – android developer Jan 02 '18 at 10:02
  • to remove `\n` you should give fixed size like https://github.com/hidroh/calendar/blob/master/app/src/main/res/layout/grid_item_content.xml and use getsize() mehod which was in my main answer ( not in updated answer). – Archit Sureja Jan 02 '18 at 10:12
  • Let us continue this discussion in chat. http://chat.stackoverflow.com/rooms/162327/discussion-between-archit-sureja-and-android-developer – Archit Sureja Jan 02 '18 at 10:18
  • OK, still, I need it to be wrap_content. The text can be short, and can be long. Using even "match_parent" from the original code of the repo doesn't seem to work, so I still don't get why it works on their case, and not here. Yet as you wrote, if I use "wrap_content" in their code, it doesn't work well. – android developer Jan 02 '18 at 11:47
  • If text is long then how you want to handle. means If text is taking two link then you want to put dot below second line or first line. and can we do discussion in chat. – Archit Sureja Jan 02 '18 at 11:57
  • Below the entire span text, but in my case it doesn't matter because the text is supposed to always be short enough to be in one line. I need it in a very similar case to the calendar UI I've shown on the repo. – android developer Jan 02 '18 at 13:40
-2

Once text is set on textview then used:

textview.setMovementMethod(LinkMovementMethod.getInstance());

Example:

tvDescription.setText(hashText);
tvDescription.setMovementMethod(LinkMovementMethod.getInstance());
alecxe
  • 462,703
  • 120
  • 1,088
  • 1,195
-3
/*
 * Set text with hashtag and mentions on TextView
 * */
public void setTextOnTextView(String description, TextView tvDescription)
{
    SpannableString hashText = new SpannableString(description);
    Pattern pattern = Pattern.compile("@([A-Za-z0-9_-]+)");
    Matcher matcher = pattern.matcher(hashText);
    while (matcher.find()) {
        final StyleSpan bold = new StyleSpan(android.graphics.Typeface.BOLD); // Span to make text bold
        hashText.setSpan(bold, matcher.start(), matcher.end(), 0);
    }
    Pattern patternHash = Pattern.compile("#([A-Za-z0-9_-]+)");
    Matcher matcherHash = patternHash.matcher(hashText);
    while (matcherHash.find()) {
        final StyleSpan bold = new StyleSpan(android.graphics.Typeface.BOLD); // Span to make text bold
        hashText.setSpan(bold, matcherHash.start(), matcherHash.end(), 0);
    }
    tvDescription.setText(hashText);
    tvDescription.setMovementMethod(LinkMovementMethod.getInstance());
}
android developer
  • 114,585
  • 152
  • 739
  • 1,270