TextView 中 drawableLeft/Right 多行文字不好用,图文混排的终结者: ImageSpan

图文混排各种问题。drawableStart / End总是无法满足用法。

如果用drawableStart 是这样的,UI设计稿 盾牌的图片只和第一行对齐。而不是居中对齐。
在这里插入图片描述

干脆用ImageSpan直接插入TextView中,自定义VerticalCenterImageSpan。
使用示例和效果图

        SpannableStringBuilder sb2 = new SpannableStringBuilder("Your privacy matters. No personally identifiable information will be collected.");
        VerticalCenterImageSpan.injectAtStart(mContext, sb2, R.drawable.ic_safe, Util.dpToPx(2));
        TextView tvBtnDesc = findViewById(R.id.tv_bottom_desc);
        tvBtnDesc.setText(sb2);

在这里插入图片描述

如果你只用国内,不做国际化,尤其是RTL适配,下面代码足够。


import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.style.ImageSpan;

import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.Px;
import androidx.core.content.ContextCompat;

import java.util.ArrayList;
import java.util.List;

import stat.util.ListUtil;

/**
 * 垂直居中ImageSpan,仅LTR布局,不支持RTL
 */
public class VerticalCenterImageSpan extends ImageSpan {
    public static final String SPACE = "{space}";
    private final Rect mMargin = new Rect();

    public VerticalCenterImageSpan(@NonNull Drawable drawable) {
        super(drawable);
    }

    public VerticalCenterImageSpan(@NonNull Context context, int resourceId) {
        super(context, resourceId);
    }

    private VerticalCenterImageSpan(@NonNull Context context, @DrawableRes int resourceId, @Px int leftPadding) {
        super(context, resourceId);
        mMargin.left = leftPadding;
    }

    private VerticalCenterImageSpan(@NonNull Context context, @DrawableRes int resourceId, @Px int rightPadding) {
        super(context, resourceId);
        mMargin.right = rightPadding;
    }

    public VerticalCenterImageSpan(@NonNull Context context, @DrawableRes int resourceId, int width, int height, Rect margin) {
        super(ContextCompat.getDrawable(context, resourceId));
        if (margin != null) {
            mMargin.set(margin);
        }
        getDrawable().setBounds(0, 0, width, height);
    }

    @Override
    public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, @Nullable Paint.FontMetricsInt fm) {
        // 总占位宽度 = 原图宽度 + 左间距 + 右间距
        return super.getSize(paint, text, start, end, fm) + mMargin.left + mMargin.right;
    }

    @Override
    public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, Paint paint) {
        final Drawable drawable = getDrawable();
        Paint.FontMetricsInt fontMetricsInt = paint.getFontMetricsInt();
        // 垂直居中偏移公式不变
        int transY = (y + fontMetricsInt.descent + y + fontMetricsInt.ascent) / 2 - drawable.getBounds().bottom / 2;
        // LTR 只需要统一向右偏移左margin
        canvas.save();
        canvas.translate(x + mMargin.left, transY);
        drawable.draw(canvas);
        canvas.restore();
    }

    // ====================== 静态注入API(完全兼容原有业务调用) ======================
    public static void inject(@NonNull Context context, @NonNull SpannableStringBuilder builder, @DrawableRes int resourceId) {
        builder.append(SPACE);
        ImageSpan span = new VerticalCenterImageSpan(context, resourceId);
        builder.setSpan(span, builder.length() - SPACE.length(), builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
    }

    // 图标插在文字后方,文字与图标之间的间距 → 左margin
    public static void inject(@NonNull Context context,
                              @NonNull SpannableStringBuilder builder,
                              @DrawableRes int resourceId,
                              @Px int drawablePadding) {
        builder.append(SPACE);
        ImageSpan span = new VerticalCenterImageSpan(context, resourceId, drawablePadding);
        builder.setSpan(span, builder.length() - SPACE.length(), builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
    }

    public static void injectAtStart(@NonNull Context context,
                                     @NonNull SpannableStringBuilder builder,
                                     @DrawableRes int resourceId) {
        builder.insert(0, SPACE);
        ImageSpan span = new VerticalCenterImageSpan(context, resourceId);
        builder.setSpan(span, 0, SPACE.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
    }

    // 图标插在文字前方,图标与文字之间的间距 → 右margin
    public static void injectAtStart(@NonNull Context context,
                                     @NonNull SpannableStringBuilder builder,
                                     @DrawableRes int resourceId,
                                     @Px int drawablePadding) {
        builder.insert(0, SPACE);
        ImageSpan span = new VerticalCenterImageSpan(context, resourceId, drawablePadding);
        mMargin.right = drawablePadding;
        builder.setSpan(span, 0, SPACE.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
    }

    public static void inject(@NonNull Context context, @NonNull SpannableStringBuilder builder,
                              @DrawableRes int resourceId, int width, int height, Rect margin) {
        builder.append(SPACE);
        ImageSpan span = new VerticalCenterImageSpan(context, resourceId, width, height, margin);
        builder.setSpan(span, builder.length() - SPACE.length(), builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
    }

    public static void inject(@NonNull Context context, @NonNull SpannableStringBuilder builder, @DrawableRes int resourceId, String... strs) {
        if (strs == null || strs.length == 0) return;
        List<String> list = new ArrayList<>();
        for (String str : strs) {
            if (!TextUtils.isEmpty(str)) list.add(str);
        }
        for (int i = 0; i < list.size(); i++) {
            builder.append(list.get(i));
            if (i != list.size() - 1) inject(context, builder, resourceId);
        }
    }

    public static void inject(@NonNull Context context, @NonNull SpannableStringBuilder builder, @DrawableRes int resourceId, List<String> list) {
        ListUtil.removeEmpty(list);
        if (ListUtil.isEmpty(list)) return;
        for (int i = 0; i < list.size(); i++) {
            builder.append(list.get(i));
            if (i != list.size() - 1) inject(context, builder, resourceId);
        }
    }
}

如果需要支持RTL的话用下面代码

/**
 * 垂直居中的 ImageSpan。
 * <p>
 * 带 {@code drawablePadding} 的 inject / injectAtStart 按 layoutDirection 解析间距
 * LTR / RTL 均把空隙放在图标与相邻文字之间。
 */
public class VerticalCenterImageSpan extends ImageSpan {
    /**
     * 占位标记
     */
    public static final String SPACE = "{space}";

    /**
     * 绝对物理边距
     */
    private static final int PADDING_ABSOLUTE = 0;
    /**
     * 图标在逻辑末尾:空隙在「前方文字 ↔ 图标」之间
     */
    private static final int PADDING_BEFORE_ICON = 1;
    /**
     * 图标在逻辑首部:空隙在「图标 ↔ 后方文字」之间
     */
    private static final int PADDING_AFTER_ICON = 2;

    private final Context mAppContext;
    private final Rect mAbsoluteMargin = new Rect();
    private int mPaddingMode = PADDING_ABSOLUTE;
    @Px
    private int mDrawablePadding;

    public VerticalCenterImageSpan(@NonNull Drawable drawable) {
        super(drawable);
        mAppContext = null;
    }

    public VerticalCenterImageSpan(@NonNull Context context, int resourceId) {
        super(context, resourceId);
        mAppContext = context.getApplicationContext();
    }

    private VerticalCenterImageSpan(@NonNull Context context, @DrawableRes int resourceId,
                                    int paddingMode, @Px int drawablePadding) {
        super(context, resourceId);
        mAppContext = context.getApplicationContext();
        mPaddingMode = paddingMode;
        mDrawablePadding = Math.max(drawablePadding, 0);
    }

    public VerticalCenterImageSpan(@NonNull Context context, @DrawableRes int resourceId, int width,
                                   int height, Rect margin) {
        super(ContextCompat.getDrawable(context, resourceId));
        mAppContext = context.getApplicationContext();
        if (margin != null) {
            mAbsoluteMargin.set(margin);
        }
        getDrawable().setBounds(0, 0, width, height);
    }


    /**
     * 相对模式下随 RTL 翻转,保证空隙始终在图标与文字之间。
     */
    private int getLeftMargin() {
        if (mPaddingMode == PADDING_ABSOLUTE || mDrawablePadding <= 0) {
            return mAbsoluteMargin.left;
        }
        boolean rtl = isRtl(mAppContext);
        if (mPaddingMode == PADDING_AFTER_ICON) {
            // 首部图标:RTL 左留白
            return rtl ? mDrawablePadding : 0;
        }
        // 末尾图标:LTR 左留白
        return rtl ? 0 : mDrawablePadding;
    }

    private int getRightMargin() {
        if (mPaddingMode == PADDING_ABSOLUTE || mDrawablePadding <= 0) {
            return mAbsoluteMargin.right;
        }
        boolean rtl = isRtl(mAppContext);
        if (mPaddingMode == PADDING_AFTER_ICON) {
            // 首部图标:LTR 右留白
            return rtl ? 0 : mDrawablePadding;
        }
        // 末尾图标:RTL 右留白
        return rtl ? mDrawablePadding : 0;
    }

    @Override
    public int getSize(@NonNull Paint paint, CharSequence text, int start, int end,
                       @Nullable Paint.FontMetricsInt fm) {
        return super.getSize(paint, text, start, end, fm) + getLeftMargin() + getRightMargin();
    }

    @Override
    public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top, int y,
                     int bottom, Paint paint) {
        final Drawable drawable = getDrawable();
        Paint.FontMetricsInt fontMetricsInt = paint.getFontMetricsInt();
        int transY = (y + fontMetricsInt.descent + y + fontMetricsInt.ascent) / 2 - drawable.getBounds().bottom / 2;
        canvas.save();
        canvas.translate(x + getLeftMargin(), transY);
        drawable.draw(canvas);
        canvas.restore();
    }

    /**
     * 将图片注入到SpannableStringBuilder中
     */
    public static void inject(@NonNull Context context, @NonNull SpannableStringBuilder builder, @DrawableRes int resourceId) {
        builder.append(SPACE);
        ImageSpan span = new VerticalCenterImageSpan(context, resourceId);
        builder.setSpan(span, builder.length() - SPACE.length(), builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
    }

    /**
     * 将图片注入到SpannableStringBuilder末尾,并设置图片与前方文字的间距(LTR/RTL 自适应)
     *
     * @param drawablePadding 图片与文字的间距,单位 px
     */
    public static void inject(@NonNull Context context,
                              @NonNull SpannableStringBuilder builder,
                              @DrawableRes int resourceId,
                              @Px int drawablePadding) {
        builder.append(SPACE);
        ImageSpan span = new VerticalCenterImageSpan(
                context, resourceId, PADDING_BEFORE_ICON, drawablePadding);
        builder.setSpan(span, builder.length() - SPACE.length(), builder.length(),
                Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
    }

    /**
     * 将图片注入到SpannableStringBuilder首部
     */
    public static void injectAtStart(@NonNull Context context,
                                     @NonNull SpannableStringBuilder builder,
                                     @DrawableRes int resourceId) {
        builder.insert(0, SPACE);
        ImageSpan span = new VerticalCenterImageSpan(context, resourceId);
        builder.setSpan(span, 0, SPACE.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
    }

    /**
     * 将图片注入到SpannableStringBuilder首部,并设置图片与后方文字的间距(LTR/RTL 自适应)
     *
     * @param drawablePadding 图片与文字的间距,单位 px
     */
    public static void injectAtStart(@NonNull Context context,
                                     @NonNull SpannableStringBuilder builder,
                                     @DrawableRes int resourceId,
                                     @Px int drawablePadding) {
        builder.insert(0, SPACE);
        ImageSpan span = new VerticalCenterImageSpan(
                context, resourceId, PADDING_AFTER_ICON, drawablePadding);
        builder.setSpan(span, 0, SPACE.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
    }

    /**
     * 将图片注入到SpannableStringBuilder中,可以设置图片宽高和左右边距(物理 left/right,不随 RTL 翻转)
     */
    public static void inject(@NonNull Context context, @NonNull SpannableStringBuilder builder,
                              @DrawableRes int resourceId, int width, int height, Rect margin) {
        builder.append(SPACE);
        ImageSpan span = new VerticalCenterImageSpan(context, resourceId, width, height, margin);
        builder.setSpan(span, builder.length() - SPACE.length(),
                builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
    }

    /**
     * 将图片及文字注入到SpannableStringBuilder中
     */
    public static void inject(@NonNull Context context, @NonNull SpannableStringBuilder builder, @DrawableRes int resourceId, String... strs) {
        if (strs == null || strs.length == 0) {
            return;
        }
        List<String> list = new ArrayList<>(strs.length);
        for (String str : strs) {
            if (!TextUtils.isEmpty(str)) {
                // 删除无效数据
                list.add(str);
            }
        }
        int size = list.size();
        for (int i = 0; i < size; i++) {
            builder.append(list.get(i));
            if (i != size - 1) {
                inject(context, builder, resourceId);
            }
        }
    }

    /**
     * 将图片及文字注入到SpannableStringBuilder中
     */
    public static void inject(@NonNull Context context, @NonNull SpannableStringBuilder builder, @DrawableRes int resourceId, List<String> list) {
        ListUtil.removeEmpty(list);
        if (ListUtil.isEmpty(list)) {
            return;
        }
        int size = list.size();
        for (int i = 0; i < size; i++) {
            builder.append(list.get(i));
            if (i != size - 1) {
                inject(context, builder, resourceId);
            }
        }
    }

    public static boolean isRtl(Context context) {
        if (context == null) {
            return false;
        }
        return context.getResources().getConfiguration().getLayoutDirection() == View.LAYOUT_DIRECTION_RTL;
    }
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值