How come my spannable isn't shown?
Asked Answered
V

4

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?

Vendible answered 25/12, 2017 at 10:34 Comment(8)
Did you try Html.toHtml?Counterpoison
@Zoe No, but now that I have, it shows a different issue: instead of the text "1" and the dot, it just shows <p dir="ltr">1</p> . I hope to use the code in a similar way to other, normal spans.Vendible
@Zoe Html.toHtml supports only built-in spans, not custom onesAnnatto
@Annatto Do you know perhaps the reason why what I wrote doesn't work well (at least in this case) ?Vendible
When you say "it doesn't show up at all", does that mean that the dot doesn't appear, or does it mean that the dot and the text do not appear?Galumph
@BenP. The entire TextView content. Both the text and the dot. Will update the question.Vendible
can you try making TextView width to "match_parent" (just for test purposes)?Gorden
@Gorden I don't want it on the real app, but even using "match_parent" (for both width and height), both text and the dot do not appear.Vendible
H
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.")
}
Hydrogen answered 3/1, 2018 at 3:37 Comment(30)
I still see it truncated. Look : i.sstatic.net/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.Vendible
@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.Hydrogen
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.Vendible
@androiddeveloper Can you present some code that does not work as expected and post it here?Hydrogen
ok, updated question to have full project link. Search for "project available here"Vendible
@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.Hydrogen
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.Vendible
@androiddeveloper See rewriteHydrogen
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 :)Vendible
@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.Hydrogen
@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.Hydrogen
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?Vendible
@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.Hydrogen
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?Vendible
@androiddeveloper What is your drawable for UnderDrawableSpan? It works the same as UnderDotSpan just with a drawable and not a circle?Hydrogen
Just a simple drawable. Could be a gradientDrawable that I choose to set its size at runtime, for example.Vendible
Works, but I don't see "mMargin" being used... Also, what's "shim" ?Vendible
@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.Hydrogen
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?Vendible
@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.Hydrogen
Should I put it in canvas.translate , or canvas.drawText , or it doesn't matter?Vendible
@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.Hydrogen
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 )Vendible
@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.Hydrogen
Nice. Thank you for all your time. You deserve this answer being accepted and also have +1 . :)Vendible
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.Vendible
@androiddeveloper Good point. You had the default dot size expressed in dp, so I just went with that.Hydrogen
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 PXVendible
@androiddeveloper I have generalized the class here if you are interested .Hydrogen
Very nice repo sample. Please update code here too, though.Vendible
B
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"

Bickerstaff answered 29/12, 2017 at 11:13 Comment(15)
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?Vendible
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.Bickerstaff
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?Vendible
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.Bickerstaff
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: i.sstatic.net/BbLlj.png ) , and doesn't show a dot at all in the new code of putting the dot below the text (see here: i.sstatic.net/DXXqi.png ) . Using leading (meaning paint.fontMetricsInt.leading) also caused a truncated dot (see here : i.sstatic.net/epE8e.png )Vendible
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.Vendible
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)Bickerstaff
Let us continue this discussion in chat.Bickerstaff
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: github.com/hidroh/calendar , see screenshot here: github.com/hidroh/calendar/blob/master/screenshots/2.png ) .Vendible
Still bad result. The dot is truncated (see here: i.sstatic.net/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 shouldVendible
to remove \n you should give fixed size like github.com/hidroh/calendar/blob/master/app/src/main/res/layout/… and use getsize() mehod which was in my main answer ( not in updated answer).Bickerstaff
Let us continue this discussion in chat. chat.stackoverflow.com/rooms/162327/…Bickerstaff
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.Vendible
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.Bickerstaff
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.Vendible
U
-2

Once text is set on textview then used:

textview.setMovementMethod(LinkMovementMethod.getInstance());

Example:

tvDescription.setText(hashText);
tvDescription.setMovementMethod(LinkMovementMethod.getInstance());
Uneven answered 2/1, 2018 at 9:16 Comment(1)
Didn't work. Still I see no text and no dot underneath itVendible
U
-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());
}
Uneven answered 2/1, 2018 at 9:46 Comment(1)
I don't see the UnderDotSpan being used here, and you've added other styles instead...Vendible

© 2022 - 2024 — McMap. All rights reserved.