4

I have the following in an xml layout :

enter image description here

Notice how the hexagon # 4 is NOT center aligned with the text. How can i do this: here is what i have tried so far:

To actually get the view with the # inside of it i inflate a view that looks like this:

//my_hexagon_button.xml:

     <?xml version="1.0" encoding="utf-8"?>
       <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
                     xmlns:tools="http://schemas.android.com/tools"
                     android:layout_width="wrap_content"
                     android:layout_height="wrap_content"
                     android:orientation="vertical"
                     android:padding="0dp"
                     tools:ignore="MissingPrefix">


           <Button
               android:id="@+id/tv_icon"
               fontPath="proxima_nova_semi_bold.otf"
               android:layout_width="16dp"
               android:layout_height="17.5dp"
               android:layout_marginBottom="5dp"
               android:layout_marginLeft="10dp"
               android:alpha=".25"
               android:background="@drawable/hexagon"
               android:clickable="true"
               android:contentDescription="@string/content_description"
               android:focusable="false"
               android:padding="0dp"
               android:text="4"
               android:textColor="@color/white"
               android:textSize="8dp"
               />

       </LinearLayout>

After inflating the view i take a copy of its drawing cache and i use that in the ImageSpan. here is how i get the copy of the drawing cache:

public Bitmap getIconBitmap() {
               LayoutInflater inflater = (LayoutInflater) getActivity().getSystemService(Context.LAYOUT_INFLATER_SERVICE);
               LinearLayout myRoot = new LinearLayout(getActivity());

               // inflate and measure the button then grab its image from the view cache
               ViewGroup parent = (ViewGroup) inflater.inflate(R.layout.my_hexagon_button, myRoot);
               TextView tv = (TextView) parent.findViewById(R.id.tv_icon);

               parent.setDrawingCacheEnabled(true);
               parent.measure(View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
                       View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED));
               parent.layout(0, 0, parent.getMeasuredWidth(), parent.getMeasuredHeight());

               parent.buildDrawingCache(true);
               // if you need bounds on the view, swap bitmap for a drawable and call setbounds, im not using bounds
               Bitmap b = Bitmap.createBitmap(parent.getDrawingCache());
               parent.setDrawingCacheEnabled(false); // clear drawing cache

               return b;
           }

So now i have a bitmap that looks like the hexagon #4 image in the drawing i attached. Now lets use that in the ImageSpan:

public Spannable createImageSpan(TextView tv,Bitmap bitmapIcon) {

                   Spannable span = new SpannableString(tv.getText());
                   int start = span.length() - 1;
                   int end = span.length();

                   ImageSpan image = new ImageSpan(new BitmapDrawable(getResources(), bitmapIcon),ImageSpan.ALIGN_BASELINE);
                   span.setSpan(image, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);

                   return span;

               }

then later on i simply set that span on my textview. also dont forget to set the bounds on the drawable or it wont display, and it wroks but the image is not aligned center in the text. Notice how it drops to the bottom. How can i resolve this cleanly ?

j2emanue
  • 60,549
  • 65
  • 286
  • 456

5 Answers5

10

You can use this class to align ImageSpan with text

public class VerticalImageSpan extends ImageSpan {

    public VerticalImageSpan(Drawable drawable) {
        super(drawable);
    }

    /**
     * update the text line height
     */
    @Override
    public int getSize(Paint paint, CharSequence text, int start, int end,
                       Paint.FontMetricsInt fontMetricsInt) {
        Drawable drawable = getDrawable();
        Rect rect = drawable.getBounds();
        if (fontMetricsInt != null) {
            Paint.FontMetricsInt fmPaint = paint.getFontMetricsInt();
            int fontHeight = fmPaint.descent - fmPaint.ascent;
            int drHeight = rect.bottom - rect.top;
            int centerY = fmPaint.ascent + fontHeight / 2;

            fontMetricsInt.ascent = centerY - drHeight / 2;
            fontMetricsInt.top = fontMetricsInt.ascent;
            fontMetricsInt.bottom = centerY + drHeight / 2;
            fontMetricsInt.descent = fontMetricsInt.bottom;
        }
        return rect.right;
    }

    /**
     * see detail message in android.text.TextLine
     *
     * @param canvas the canvas, can be null if not rendering
     * @param text the text to be draw
     * @param start the text start position
     * @param end the text end position
     * @param x the edge of the replacement closest to the leading margin
     * @param top the top of the line
     * @param y the baseline
     * @param bottom the bottom of the line
     * @param paint the work paint
     */
    @Override
    public void draw(Canvas canvas, CharSequence text, int start, int end,
                     float x, int top, int y, int bottom, Paint paint) {

        Drawable drawable = getDrawable();
        canvas.save();
        Paint.FontMetricsInt fmPaint = paint.getFontMetricsInt();
        int fontHeight = fmPaint.descent - fmPaint.ascent;
        int centerY = y + fmPaint.descent - fontHeight / 2;
        int transY = centerY - (drawable.getBounds().bottom - drawable.getBounds().top) / 2;
        canvas.translate(x, transY);
        drawable.draw(canvas);
        canvas.restore();
    }
}

Thanks to this answer

rana_sadam
  • 1,216
  • 10
  • 18
7

For API >29 you can do this using the ImageSpan.ALIGN_CENTER constant. (Code samples below in Kotlin.)

val image: ImageSpan = ImageSpan(
    BitmapDrawable(resources, bitmapIcon),
    ImageSpan.ALIGN_CENTER);
span.setSpan(image, start, end, 0);

If you need to support API levels below 29 (as I imagine most people will for a while) you'll still need to subclass ImageSpan as in RoShan Shan's answer. You only strictly need to override draw to get the behaviour to work, however:

class CenteredImageSpanSubclass(
    context: Context, 
    bitmap: Bitmap): ImageSpan(context, bitmap) {

    override fun draw(...) {

        canvas.save()

        val transY = (bottom - top) / 2 - drawable.bounds.height() / 2

        canvas.translate(x, transY.toFloat())
        drawable.draw(canvas)
        canvas.restore()
    }
}
M.Pomeroy
  • 997
  • 11
  • 22
  • Below API 29, you can use `DynamicDrawableSpan.ALIGN_BASELINE` instead of `ImageSpan.ALIGN_CENTER`. I think it's a better option even for API over 29. – Thomas Dec 15 '20 at 18:31
  • `ALIGN_BASELINE` and `ALIGN_CENTER` are not the same thing, though. If you want to to align the the drawable to the baseline than you'd for definitely use `ALIGN_BASELINE`, but if you want to center the image in the span based on the text then using that constant isn't going to give you the behaviour you want. – M.Pomeroy Dec 15 '20 at 20:27
  • Actually I tested it in emulators and devices pre API 29 and it seems to work flawlessly – mitsest Jun 21 '21 at 17:23
6

You can try my CenteredImageSpan. You can customize in draw method by calculating transY -= (paint.getFontMetricsInt().descent / 2 - 8);. (Good luck. :) )

public class CenteredImageSpan extends ImageSpan {
    private WeakReference<Drawable> mDrawableRef;

    // Extra variables used to redefine the Font Metrics when an ImageSpan is added
    private int initialDescent = 0;
    private int extraSpace = 0;

    public CenteredImageSpan(Context context, final int drawableRes) {
        super(context, drawableRes);
    }

    public CenteredImageSpan(Drawable drawableRes, int verticalAlignment) {
        super(drawableRes, verticalAlignment);
    }

    @Override
    public int getSize(Paint paint, CharSequence text,
                       int start, int end,
                       Paint.FontMetricsInt fm) {
        Drawable d = getCachedDrawable();
        Rect rect = d.getBounds();

//        if (fm != null) {
//            Paint.FontMetricsInt pfm = paint.getFontMetricsInt();
//            // keep it the same as paint's fm
//            fm.ascent = pfm.ascent;
//            fm.descent = pfm.descent;
//            fm.top = pfm.top;
//            fm.bottom = pfm.bottom;
//        }

        if (fm != null) {
            // Centers the text with the ImageSpan
            if (rect.bottom - (fm.descent - fm.ascent) >= 0) {
                // Stores the initial descent and computes the margin available
                initialDescent = fm.descent;
                extraSpace = rect.bottom - (fm.descent - fm.ascent);
            }

            fm.descent = extraSpace / 2 + initialDescent;
            fm.bottom = fm.descent;

            fm.ascent = -rect.bottom + fm.descent;
            fm.top = fm.ascent;
        }

        return rect.right;
    }

    @Override
    public void draw(@NonNull Canvas canvas, CharSequence text,
                     int start, int end, float x,
                     int top, int y, int bottom, @NonNull Paint paint) {
        Drawable b = getCachedDrawable();
        canvas.save();

//        int drawableHeight = b.getIntrinsicHeight();
//        int fontAscent = paint.getFontMetricsInt().ascent;
//        int fontDescent = paint.getFontMetricsInt().descent;
//        int transY = bottom - b.getBounds().bottom +  // align bottom to bottom
//                (drawableHeight - fontDescent + fontAscent) / 2;  // align center to center

        int transY = bottom - b.getBounds().bottom;
        // this is the key
        transY -= (paint.getFontMetricsInt().descent / 2 - 8);

//        int bCenter = b.getIntrinsicHeight() / 2;
//        int fontTop = paint.getFontMetricsInt().top;
//        int fontBottom = paint.getFontMetricsInt().bottom;
//        int transY = (bottom - b.getBounds().bottom) -
//                (((fontBottom - fontTop) / 2) - bCenter);


        canvas.translate(x, transY);
        b.draw(canvas);
        canvas.restore();
    }


    // Redefined locally because it is a private member from DynamicDrawableSpan
    private Drawable getCachedDrawable() {
        WeakReference<Drawable> wr = mDrawableRef;
        Drawable d = null;

        if (wr != null)
            d = wr.get();

        if (d == null) {
            d = getDrawable();
            mDrawableRef = new WeakReference<>(d);
        }

        return d;
    }
}

EDIT

I implemented above code like this:

Drawable myIcon = getResources().getDrawable(R.drawable.btn_feedback_yellow);
            int width = (int) Functions.convertDpToPixel(75, getActivity());
            int height = (int) Functions.convertDpToPixel(23, getActivity());
            myIcon.setBounds(0, 0, width, height);
            CenteredImageSpan btnFeedback = new CenteredImageSpan(myIcon, ImageSpan.ALIGN_BASELINE);
            ssBuilder.setSpan(
                    btnFeedback, // Span to add
                    getString(R.string.text_header_answer).length() - 1, // Start of the span (inclusive)
                    getString(R.string.text_header_answer).length(), // End of the span (exclusive)
                    Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);// Do not extend the span when text add later
RoShan Shan
  • 2,924
  • 1
  • 18
  • 38
  • your code i did not modify. i passed in a bitmapdrawable to it but it wont show the imagine. when i click in the area of the image the clickable span i put there works though. its just the image is not visible. – j2emanue Apr 14 '17 at 09:47
  • seems promising. one question. for the line transY -= (paint.getFontMetricsInt().descent / 2 - 8); when i tweak the #8 value i can adjust the height but this is in pixels right ? so i should convert from DP to px and then adjust fontMetrics right ? because it does not work consistently across screen sizes. for example xhdpi vs xxhdpi the icon shows up higher on xhdpi. – j2emanue Apr 14 '17 at 20:54
  • If you want it works for multi screens, you can define multi dimens for multi screens: Ex: folder values-xhdpi with dimens.xml, values-xxhdpi with dimens.xml. And define a value relevant for each screen sizes. You have to make it manually because your text size and image are different in different screens. – RoShan Shan Apr 15 '17 at 04:45
0

this is my solution,it support single line and multi-line text

class CenteredImageSpan(dr: Drawable) : ImageSpan(dr) {
    private var mDrawableRef: WeakReference<Drawable>? = null
    override fun getSize(paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int {
        val d = cachedDrawable
        val rect: Rect = d!!.bounds
        val pfm = paint.fontMetricsInt
        if (fm != null) {
            fm.ascent = -rect.height() / 2 + pfm.ascent / 2
            fm.descent = Math.max(0, rect.height() / 2 + pfm.ascent / 2)
            fm.top = fm.ascent
            fm.bottom = fm.descent
        }
        return rect.right
    }

    override fun draw(canvas: Canvas, text: CharSequence?, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, @NonNull paint: Paint) {
        val b = cachedDrawable!!
        canvas.save()
        var transY = (bottom + top) / 2 - b.bounds.height() / 2
        canvas.translate(x, transY.toFloat())
        b.draw(canvas)
        canvas.restore()
    }

    // Redefined locally because it is a private member from DynamicDrawableSpan
    private val cachedDrawable: Drawable?
        private get() {
            val wr: WeakReference<Drawable>? = mDrawableRef
            var d: Drawable? = null
            if (wr != null) d = wr.get()
            if (d == null) {
                d = drawable
                mDrawableRef = WeakReference(d)
            }
            return d
        }
}
Iii X
  • 1
  • 1
0

I found a much easier way to handle all the alignment things is to do it another way. we will create a imageSpan, but the bitmap will be from a inflated view.

so inflate your imageview (the alignments can all be adjusted here with margins) like this:

<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/tv"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:background="@drawable/myImage"
    android:paddingLeft="3dp"
    android:paddingTop="1dp"
    android:paddingRight="3dp"
    android:paddingBottom="1dp"
    android:layout_marginEnd="6dp"
    tools:text="for sale" />

you can create a parent viewgroup programatically and add this tv to it if you want to.

now lets grab the bitmap from this tv:

 private fun getBitmap(myTextView: View): Bitmap {
        val bitmap = Bitmap.createBitmap(myTextView.width, myTextView.height, Bitmap.Config.ARGB_8888)
        val myCanvas = Canvas(bitmap)
        view.draw(myCanvas)
        return bitmap
    }

now that you have the image as a bitmap just apply it to the imageSpan and add it how you see fit. i love this way as im able to control how the span aligns insteads of relying on font metrics which is so complicated.

j2emanue
  • 60,549
  • 65
  • 286
  • 456