How to have an image with a dynamic text in it, all in a drawable, like the "today" action item on Google Calendar app?
Asked Answered
C

2

16

Background

Google Calendar app has an action item that dynamically change according to the current day ("today"):

enter image description here

I'm required to do a very similar thing, but with a slightly different image that surrounds the text.

The problem

I did succeed to make it work, by creating a Drawable that has text and image in it (based on here).

However, I'm don't think I did it well enough :

  1. Text font might be different across devices, so might not fit well in what I wrote.
  2. Not sure if it's because of the VectorDrawable or the text, but I think the text doesn't seem so centered . Seems a bit to the left. This is especially true if I use 2 digits:

enter image description here

  1. For centering vertically, I don't think I did the correct calculation. I tried much more logical things there, but they were not centered.

What I've tried

Here's the full code (also available here in a project) :

TextDrawable.java

public class TextDrawable extends Drawable {
    private static final int DEFAULT_COLOR = Color.WHITE;
    private static final int DRAWABLE_SIZE = 24;
    private static final int DEFAULT_TEXT_SIZE = 8;
    private Paint mPaint;
    private CharSequence mText;
    private final int mIntrinstSize;
    private final Drawable mDrawable;

    public TextDrawable(Context context, CharSequence text) {
        mText = text;
        mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mPaint.setColor(DEFAULT_COLOR);
        mPaint.setTextAlign(Align.CENTER);
        float textSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, DEFAULT_TEXT_SIZE, context.getResources().getDisplayMetrics());
        mPaint.setTextSize(textSize);
        mIntrinstSize = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, DRAWABLE_SIZE, context.getResources().getDisplayMetrics());
        mDrawable = AppCompatResources.getDrawable(context, R.drawable.ic_backtodate);
        mDrawable.setBounds(0, 0, mIntrinstSize, mIntrinstSize);
    }

    @Override
    public void draw(Canvas canvas) {
        Rect bounds = getBounds();
        mDrawable.draw(canvas);
        canvas.drawText(mText, 0, mText.length(),
                bounds.centerX(), bounds.centerY() + mPaint.getFontMetricsInt(null) / 3, mPaint); // this seems very wrong
    }

    @Override
    public int getOpacity() {
        return mPaint.getAlpha();
    }

    @Override
    public int getIntrinsicWidth() {
        return mIntrinstSize;
    }

    @Override
    public int getIntrinsicHeight() {
        return mIntrinstSize;
    }

    @Override
    public void setAlpha(int alpha) {
        mPaint.setAlpha(alpha);
    }

    @Override
    public void setColorFilter(ColorFilter filter) {
        mPaint.setColorFilter(filter);
    }
}

MainActivity.kt

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val drawable = TextDrawable(this, "1")
        imageView.setImageDrawable(drawable)
    }
}

ic_backtodate.xml

<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="25dp" android:height="25dp"
        android:viewportHeight="76.0" android:viewportWidth="76.0">
    <path
        android:fillColor="#ffffff" android:fillType="evenOdd"
        android:pathData="M47.294,60.997H28.704C21.148,60.997 15,54.755 15,47.083V28.905c0,-7.672 6.148,-13.913 13.704,-13.913h18.59C54.852,14.992 61,21.233 61,28.905v18.178c0,7.672 -6.148,13.914 -13.706,13.914zM57.592,28.905c0,-5.763 -4.62,-10.453 -10.298,-10.453h-18.59c-5.676,0 -10.296,4.69 -10.296,10.453v18.178c0,5.765 4.62,10.454 10.296,10.454h18.59c5.678,0 10.298,-4.689 10.298,-10.454z"/>
</vector>

The questions

  1. How can I overcome the different fonts issues? I already use "Lato" font globally (not in the sample app, but in the real app, using "downloaded fonts" API of the support library, but having them built into the app instead), but I don't think Paint Object can use it, right?

  2. How can I center the text well?

  3. I've looked, via View-hierarchy tool, at how Google Calendar works for this part. To me it seems they just used TextView. How did they do it? Maybe using a 9-patch? But does it work well for Toolbar items?


EDIT:

For now, because I'm tight on schedule, I can't use the drawable solution. Would still be nice to know how to do it well.

My current solution doesn't involve it. I just use a special view that mimics a normal action item. It's not perfect (doesn't fully mimics a real action item), but it will be enough for now. Because it's not perfect, I wrote about it on a new thread, here.


EDIT: since this actually can work well, and still stay as a normal action item, I've decided to give it another try.

I've managed to center the text nicely, but the font is the issue now. It seems that if the OS uses a font of its own, even if I've set "Lato" to be the one of the app, it's not used in the drawable I've made:

enter image description here

I think it's the last issue I need to fix here.

Here's the code:

styles.xml

    <item name="android:fontFamily" tools:targetApi="jelly_bean">@font/lato</item>
    <item name="fontFamily">@font/lato</item>

MainActivity.kt

class MainActivity : AppCompatActivity() {
    lateinit var textDrawable: TextDrawable

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        textDrawable = TextDrawable(this, "1")
        setSupportActionBar(toolbar)
        val handler = Handler()
        val runnable = object : Runnable {
            var i = 1
            override fun run() {
                if (isFinishing||isDestroyed)
                    return
                textDrawable.text = (i + 1).toString()
                i = (i + 1) % 31
                handler.postDelayed(this, 1000)
            }
        }
        runnable.run()
    }

    override fun onCreateOptionsMenu(menu: Menu): Boolean {
        menu.add("goToToday").setIcon(textDrawable).setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS)
        menu.add("asd").setIcon(R.drawable.abc_ic_menu_copy_mtrl_am_alpha).setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS)
        return super.onCreateOptionsMenu(menu)
    }
}

TextDrawable.kt

class TextDrawable(context: Context, text: CharSequence) : Drawable() {
    companion object {
        private val DEFAULT_COLOR = Color.WHITE
        private val DEFAULT_TEXT_SIZE = 12
    }

    var text: CharSequence = text
        set (value) {
            field = value
            invalidateSelf()
        }

    private val mPaint: TextPaint = TextPaint(Paint.ANTI_ALIAS_FLAG)
    private val mDrawable: Drawable?

    init {
        mPaint.color = DEFAULT_COLOR
        mPaint.textAlign = Align.CENTER
        val textSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, DEFAULT_TEXT_SIZE.toFloat(), context.resources.displayMetrics)
        mPaint.textSize = textSize
        mDrawable = AppCompatResources.getDrawable(context, R.drawable.ic_backtodate)
        mDrawable!!.setBounds(0, 0, mDrawable.intrinsicWidth, mDrawable.intrinsicHeight)
    }

    override fun draw(canvas: Canvas) {
        val bounds = bounds
        mDrawable!!.draw(canvas)
        canvas.drawText(text, 0, text.length,
                bounds.centerX().toFloat(), (bounds.centerY() + mPaint.getFontMetricsInt(null) / 3).toFloat(), mPaint) // this seems very wrong, but seems to work fine
    }

    override fun getOpacity(): Int = mPaint.alpha

    override fun getIntrinsicWidth(): Int = mDrawable!!.intrinsicWidth

    override fun getIntrinsicHeight(): Int = mDrawable!!.intrinsicHeight

    override fun setAlpha(alpha: Int) {
        mPaint.alpha = alpha
        invalidateSelf()
    }

    override fun setColorFilter(filter: ColorFilter?) {
        mPaint.colorFilter = filter
        invalidateSelf()
    }

}

EDIT:

I think I've found how to have a font for the text, by using :

mPaint.typeface=TypefaceCompat.createFromResourcesFamilyXml(...)

Not sure though how to fill the parameters. Still investigating...

Codding answered 7/12, 2017 at 9:52 Comment(7)
You could try with a RelativeLayout inside your Toolbar and two other components: Imageview for the background drawable; TextView which is populated with numbers. The textview has the same size of the imageview, is overlapping and has its gravity centered.Duquette
@Duquette no, it is a custom Drawable class where text (number) drawing is done using low level Canvas stuff and this should be done that way - basically OP does not know how to do that rightChris
I mean, instead of extending the Drawable class...Duquette
@Duquette extending Drawable is perfectly OK - this is how dynamic content should be presented on TextView / ImageView etcChris
@Chris Indeed. Just thought it would be nicer to use it in Drawable. Maybe in the end I will just use some kind of an overlay instead, as I think this is what Google does (according to the clues I've found). Maybe 9-patch, maybe multiple views... Still, could be nice to know how to properly put text into DrawableCodding
Sadly I think I'm having some minor issues mimicking the action item, using the alternative solution I've found to this problem. I would appreciate it if you could help me there: https://mcmap.net/q/751258/-how-to-fully-mimic-action-item-view-in-the-toolbar-for-a-customized-one/878126Codding
I've returned to this solution after I was stuck with the other solution. I think it would be easier to fix issues here than there.Codding
C
8

OK, found the answer about how to have the same font for the TextPaint of the Drawable class I've made:

mPaint.typeface = ResourcesCompat.getFont(context, R.font.lato)

The result:

enter image description here

Here's the full implementation of this class:

class TextDrawable(context: Context, text: CharSequence) : Drawable() {
    companion object {
        private val DEFAULT_COLOR = Color.WHITE
        private val DEFAULT_TEXT_SIZE_IN_DP = 12
    }

    private val mTextBounds = Rect()
    private val mPaint: TextPaint = TextPaint(Paint.ANTI_ALIAS_FLAG)
    private val mDrawable: Drawable?

    var text: CharSequence = text
        set (value) {
            field = value
            invalidateSelf()
        }

    init {
        mPaint.typeface = ResourcesCompat.getFont(context, R.font.lato)
        mPaint.color = DEFAULT_COLOR
        mPaint.textAlign = Align.CENTER
        val textSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, DEFAULT_TEXT_SIZE_IN_DP.toFloat(), context.resources.displayMetrics)
        mPaint.textSize = textSize
        mDrawable = AppCompatResources.getDrawable(context, R.drawable.ic_backtodate)
        mDrawable!!.setBounds(0, 0, mDrawable.intrinsicWidth, mDrawable.intrinsicHeight)
    }

    override fun draw(canvas: Canvas) {
        val bounds = bounds
        mDrawable!!.draw(canvas)
        mPaint.getTextBounds(text.toString(), 0, text.length, mTextBounds);
        val textHeight = mTextBounds.bottom - mTextBounds.top
        canvas.drawText(text as String?, (bounds.right / 2).toFloat(), (bounds.bottom.toFloat() + textHeight + 1) / 2, mPaint)
    }

    override fun getOpacity(): Int = mPaint.alpha
    override fun getIntrinsicWidth(): Int = mDrawable!!.intrinsicWidth
    override fun getIntrinsicHeight(): Int = mDrawable!!.intrinsicHeight

    override fun setAlpha(alpha: Int) {
        mPaint.alpha = alpha
        invalidateSelf()
    }

    override fun setColorFilter(filter: ColorFilter?) {
        mPaint.colorFilter = filter
        invalidateSelf()
    }

}

EDIT: this code is now complete and works well. It should work fine, and is partially based on Calendar app itself, as was recommended to me to look at (here and here) .

Codding answered 10/12, 2017 at 14:15 Comment(0)
N
2

Reference is made to your other question "How to fully mimic Action item view in the toolbar, for a customized one?"

I have incorporated the approach in my answer to the above-referenced question into your implementation of a custom drawable in your answer to this question. Below is a new version of TextDrawable.java that dynamically builds a boxed TextView for display as the desired icon for a menu item. It avoids drawing caches and simply manages a TextView internally for display.

TextDrawable.java

public class TextDrawable extends Drawable {
    private final int mIntrinsicSize;
    private final TextView mTextView;

    public TextDrawable(Context context, CharSequence text) {
        mIntrinsicSize = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, DRAWABLE_SIZE,
                                                         context.getResources().getDisplayMetrics());
        mTextView = createTextView(context, text);
        mTextView.setWidth(mIntrinsicSize);
        mTextView.setHeight(mIntrinsicSize);
        mTextView.measure(mIntrinsicSize, mIntrinsicSize);
        mTextView.layout(0, 0, mIntrinsicSize, mIntrinsicSize);
    }

    private TextView createTextView(Context context, CharSequence text) {
        TextView textView = new TextView(context);
//        textView.setId(View.generateViewId()); // API 17+
        LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
            LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT);
        lp.gravity = Gravity.CENTER;
        textView.setLayoutParams(lp);
        textView.setGravity(Gravity.CENTER);
        textView.setBackgroundResource(R.drawable.ic_backtodate);
        textView.setTextColor(Color.WHITE);
        textView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, DEFAULT_TEXT_SIZE);
        textView.setText(text);
        return textView;
    }

    public void setText(CharSequence text) {
        mTextView.setText(text);
        invalidateSelf();
    }

    @Override
    public void draw(@NonNull Canvas canvas) {
        mTextView.draw(canvas);
    }

    @Override
    public int getOpacity() {
        return PixelFormat.OPAQUE;
    }

    @Override
    public int getIntrinsicWidth() {
        return mIntrinsicSize;
    }

    @Override
    public int getIntrinsicHeight() {
        return mIntrinsicSize;
    }

    @Override
    public void setAlpha(int alpha) {
    }

    @Override
    public void setColorFilter(ColorFilter filter) {
    }

    private static final int DRAWABLE_SIZE = 32; // device-independent pixels (DP)
    private static final int DEFAULT_TEXT_SIZE = 12; // device-independent pixels (DP)
}

Invoke this custom Drawable as follows (Kotlin):

mTextDrawable = TextDrawable(this, "1")
menu.add("goToToday").setIcon(mTextDrawable).setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS)

To change the displayed date (Kotlin):

mTextDrawable?.setText(i.toString())    
Nonparticipating answered 14/12, 2017 at 0:50 Comment(19)
I don't see the advantage here compared to what I did. Also, I think there are 2 issues: 1. not sure you use the font the app uses (maybe it's used automatically?) 2. You use SP instead of DP, which means it could be an issue for the action item, as it's quite small and so the text might be too large for being inside of it, or it might overlap the image it has.Codding
@androiddeveloper Good points. I am unclear on what is unsatisfactory about the accepted solution that you would request assistance.Nonparticipating
Well, for one thing, I've tried various coordinate calculations of where to put the text vertically, and in the end I've come up with something that doesn't make sense to me (addition of 1/3 of something), but seems to look fine. Other than that, I just thought for the other question, that maybe a custom view is... more flexible or more "normal" way to do it.Codding
@androiddeveloper "More normal" except that you are trying to get the action view to behave like something else. Take a look at the calendar app source: here and here. It can give you what you are looking for with some tweaks.Nonparticipating
How did you find this? I didn't even know the calendar app is open sourced... Have you tried it?Codding
@androiddeveloper It does work. See here for new files for the project. I happen to have been looking into a fork of the calendar app but hadn't looked at this particular section.Nonparticipating
I can't import your project. For some reason, it misses a lot of project configurations files...Codding
@androiddeveloper It's just a handful of files and not a project.Nonparticipating
Why do you have a layerListDrawable that has 2 of the same Drawables? Also, I don't think this will work on old Android versions, because you use layerListDrawable of VectorDrawable (will crash because they don't support it, unless they have alternative PNG files). Even then, you have a check of isJellybeanOrLater. When it's false, I don't see the text.Codding
@androiddeveloper I noticed that they have two drawables that are the same. If displayed, they would just overlay and look like a blank calendar. I don't think there is a reason other than they just needed something there until the one of the layers is replaced with text. On versions earlier than Jelly Bean, I display just an icon with no text. They used a special graphic with a calendar showing just one day highlighted. Here is the original code.Nonparticipating
Looking at the code, I see they just replace the layer of the second drawable with a new one, of the text. I think a better solution would be to have both the text and the image on the same drawable, so that it will work on previous versions of Android. It will crash if used on Android versions that don't support VectorDrawable (meaning before 5, as I remember) .Codding
@androiddeveloper That would extend the changeable date functionality to older versions of Android (pre 4.1.) What I have just presented is how the calendar app does it. As a point of reference, pre-Jelly Bean devices are now less than 1% of Android systems in circulation.Nonparticipating
Android 4.4 and below would crash, as I remember, if you use a layerListDrawable with VectorDrawable(unless you create PNG files).Codding
@androiddeveloper The calendar app uses density-specific PNG files for the layer list drawable for Jelly Bean and above and a generic drawable (also density-specific PNG files) for pre-Jelly Bean. For Jelly Bean+, the day of the month is painted on the top layer of the layer list drawable. Avoiding the layer list drawable and using your TextDrawable makes sense. If you don't like the appearance of the icon in that solution, I suggest the code in the answer I presented if it behaves and looks the way you want.Nonparticipating
I've modified the code based on what you've provided me on the Calendar app code. Thank you. If you put my solution as another alternative into your answer, I will grant the bounty, because I have mixed thoughts about the solution of the LayerListDrawable and all the handling of old Android versions, instead of just what I did... For now you get +1 for the comments. :)Codding
@androiddeveloper Incorporated through a reference.Nonparticipating
I'm not talking about this solution. I'm talking about the one written here. Meaning: https://mcmap.net/q/737839/-how-to-have-an-image-with-a-dynamic-text-in-it-all-in-a-drawable-like-the-quot-today-quot-action-item-on-google-calendar-app . The other one is too different to be mentioned on this thread... It doesn't use a drawable. It uses real views... It's of a different question...Codding
@androiddeveloper I am totally lost. I will pass on the bounty.Nonparticipating
I'm saying both solutions I've written on the current question work. I just think what I got is nicer (single drawable without worrying of Android version), yet because the calculation part of it was based on something you've mentioned (of Google Calendar app code), I wanted to give you a chance to get the bounty.Codding

© 2022 - 2024 — McMap. All rights reserved.