如何在其中包含动态文本的图像,全部在可绘制对象中,例如 Google 日历应用程序上的 "today" 操作项?

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

背景

Google 日历应用程序有一个根据当天动态变化的操作项 ("today"):

我需要做一个非常相似的事情,但文本周围的图像略有不同。

问题

我确实成功地让它工作了,方法是创建一个包含文本和图像的 Drawable(基于 here)。

但是,我认为我做得不够好:

  1. 文本字体可能因设备而异,因此可能不适合我写的内容。
  2. 不确定是 VectorDrawable 还是文本的原因,但我认为文本似乎没有那么居中。好像有点偏左如果我使用 2 位数字,则尤其如此:

  1. 对于垂直居中,我认为我的计算不正确。我在那里尝试了更多合乎逻辑的东西,但它们没有居中。

我试过的

这是完整的代码(也可以在项目中 here 获得):

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>

问题

  1. 如何解决不同字体的问题?我已经在全球范围内使用 "Lato" 字体(不是在示例应用程序中,而是在真实应用程序中,使用支持库的 "downloaded fonts" API,而是将它们内置到应用程序中),但是我不认为Paint对象可以使用它,对吗?

  2. 如何让文字居中?

  3. 我已经通过视图层次工具查看了 Google 日历在这部分的工作方式。对我来说,他们似乎只是使用了 TextView。他们是如何做到的呢?也许使用 9 补丁?但它适用于工具栏项目吗?


编辑:

目前,由于时间紧迫,我无法使用 drawable 解决方案。仍然很高兴知道如何做好。

我目前的解决方案不涉及它。我只是使用一个模仿正常操作项的特殊视图。它并不完美(没有完全模仿真实的动作项目),但现在已经足够了。因为它并不完美,所以我在一个新线程上写了它,


编辑:因为这实际上可以很好地工作,并且仍然作为一个正常的行动项目,我决定再试一次。

我已经设法很好地使文本居中,但现在字体是个问题。似乎如果 OS 使用自己的字体,即使我将 "Lato" 设置为应用程序之一,它也不会在我制作的 drawable 中使用:

我认为这是我需要解决的最后一个问题。

代码如下:

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

}

编辑:

我想我已经找到了如何为文本设置字体,方法是:

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

不确定如何填写参数。仍在调查中...

好的,找到了关于如何为 Drawable 的 TextPaint 使用相同字体的答案class 我做了:

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

结果:

下面是这个 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()
    }

}

编辑:此代码现已完成并且运行良好。它应该工作正常,并且部分基于日历应用程序本身,正如我推荐的那样 ( and here) .

参考了你的另一个问题

我已将我对上述问题的回答中的方法合并到您对此问题的回答中自定义可绘制对象的实现中。下面是 TextDrawable.java 的新版本,它动态构建一个盒装 TextView 以显示为菜单项的所需图标。它避免了绘图缓存,只是在内部管理一个 TextView 用于显示。

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

调用此自定义 Drawable 如下 (Kotlin):

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

更改显示日期 (Kotlin):

mTextDrawable?.setText(i.toString())