TextView drawable尺寸控制:从官方机制到自定义包装方案
1. 先从需求说起什么时候需要控制TextView的drawable大小做Android开发的人应该都遇到过这种需求TextView左边或者右边要放一个小图标但资源里切出来的图尺寸总是不合适。最常见的就是消息列表里的状态图标、订单详情里的步骤小标志、文章列表里的分类前缀图——UI在标注稿上写“图标24x24dp”但给你的切图往往是48px甚至72px的原图直接塞进TextView里就会出现图标大得压住文字的情况。这个需求说起来很小但处理起来非常容易翻车。因为TextView对compound drawable的尺寸控制逻辑和ImageView完全不一样。ImageView有scaleType可以随便缩放TextView从官方API层面就没有给你一个setDrawableWidth或者setDrawableHeight方法。系统提供的setCompoundDrawablesWithIntrinsicBounds名字里带“IntrinsicBounds”但它读的是图片自身的固有尺寸不是你想设置的显示尺寸。这个标题做了这么多年在我看来一直是一个“经典小困扰”。我在好几个项目里都踩过同样的坑需求方说“图标大一点”如果你直接给TextView换一张更大的切图文字间距、行高、padding全乱了如果你手动setBounds绘制的时候又被系统重置。这篇文章就是把这件事彻底讲透沿着“官方机制→方案一→方案二→踩坑记录”这条线走一遍帮你在下次遇到这种需求的时候直接找到能落地的写法。1.1 三个典型场景第一个场景是IM类的消息列表。很多时候每条消息前面要放一个小图标表示消息已读、未读、发送失败等等这个图标要精确控制大小而且左边缘要和文案对齐。图标如果太大消息行的视觉重心会偏滑动的时候也能明显感觉到图标在“顶”文字。第二个场景是电商或生活服务类的订单列表。订单状态会用“待付款”“待发货”“已完成”这种带前置小圆点的文字标签来展示小圆点的尺寸往往是设计稿里指定的不能按照切图原尺寸来。这类标签通常复用在多个列表页任何一个地方尺寸不正确都很显眼。第三个场景是混合型列表比如文章列表或视频列表每个item需要显示分类标签或者来源标识。这些标识为了兼容不同语言字符经常用文字图标的组合样式图标尺寸是否合适直接影响整个cell的排版高度。在复用型Adapter里这种尺寸问题还会被放大一个item的图标大了整列表的高度可能都不均匀。这三个场景背后其实都是同一个技术困境TextView没有暴露“显示大小”的控制入口你只能绕过系统默认逻辑。1.2 默认行为背后的机制要讲清楚怎么绕过先要理解TextView默认是怎么排布drawable的。调用setCompoundDrawables或者setCompoundDrawablesWithIntrinsicBounds之后TextView在计算文本可用区域时会读取每个drawable的getIntrinsicWidth和getIntrinsicHeight把它们当作版式的一部分来预留空间。你可以把TextView想成一个拼版师傅文字是一块积木compound drawable是旁边的另一块积木。这个师傅在排版时完全信任每块积木报上来的“固有尺寸”不会自己去做缩放。所以问题就变简单了你只需要控制这个drawable报上去的固有尺寸就能控制TextView为它留的空间。但“控制固有尺寸”这件事不同方案有不同的做法有些是表面糊弄有些是从根上解决。1.3 为什么平台不直接开放尺寸接口有人可能会问既然如此Google为什么不在TextView上加一个带尺寸参数的API我个人的理解是TextView的定位是文字容器四边的drawable更像装饰附件它的排版核心始终是文字。你想想如果加了尺寸参数接着就会有人想要scaleType、gravity、旋转角度……这个类会被无限扩充而且衍生出各种排版语义冲突。平台的设计选择了“谁提供drawable谁负责尺寸”。于是我们只能通过改变drawable的行为来影响TextView。这其实就是整个解决方案的核心思路不是去TextView要一个设置尺寸的方法而是把drawable本身包装成一个“会报目标尺寸的新drawable”。2. 官方API的边界setCompoundDrawables背后的排版机制在动手写代码之前先梳理一下系统提供的几种常见写法和它们各自的局限性。很多人直接在布局文件里写android:drawableLeft或者代码里setCompoundDrawablesWithIntrinsicBounds遇到尺寸问题时才开始加各种奇怪的代码其实是不清楚这些API各自默认的前提条件。最基本的setCompoundDrawables(Drawable left, Drawable top, Drawable right, Drawable bottom)要求传入的drawable已经手动setBounds否则绘制区域为0图标不显示。setCompoundDrawablesWithIntrinsicBounds则是直接读取drawable的intrinsicWidth / intrinsicHeight自动帮你setBounds。它更省事但也正是“自动读取固有尺寸”导致无法自定义大小。无论哪一种底层的排版规则都一样drawable报多少intrinsicTextView就按多少预留空间。2.1 IntrinsicBounds与LayoutBounds的区别这里的核心概念是intrinsic固有尺寸和layout布局位置的区别。一个BitmapDrawable的intrinsic尺寸来自Bitmap的像素尺寸一个ShapeDrawable的intrinsic尺寸来自XML或者构造函数里指定的宽高一个LevelListDrawable的intrinsic尺寸取决于当前level对应的具体drawable。TextView所有排版动作都基于这些值。绘制的时候TextView会把四个drawable逐个setBounds到计算好的区域里然后调用draw。也就是说TextView给drawable布置的LayoutBounds是根据intrinsic算出来的。如果你只是手动改LayoutBounds下一次布局依旧会被算回去如果你让intrinsic本身变成目标大小LayoutBounds自然会跟着变成目标大小。这是后面所有方案的底层判断依据。2.2 一个验证实验setBounds为什么会被重置我见过不少人尝试这样做拿到compound drawable调用setBounds(0, 0, width, height)然后以为完事了。我们来验证一下为什么这个写法不生效。在给TextView调用了setCompoundDrawables之后去设置它的bounds然后立刻getBounds你会发现bounds确实改变了看起来一切正常。但是只要触发一次重新布局比如修改文字、调用requestLayout或者滚动列表的时候TextView就会重新走到绘制流程。此时TextView会重新对齐并重新计算每一个compound drawable的bounds你手动设置的值就被覆盖了。我在某项目里还专门验证过一次在滚动监听里断点查看发现每次滚出屏幕再滚回来那个drawable的bounds都会恢复成原始尺寸。结论非常简单手动setBounds是治标不治本因为TextView对drawable的“布局管理权”在它自己手里。2.3 系统三种写法的能力对比方式是否设置bounds是否受intrinsic影响能否控制显示尺寸setCompoundDrawables不设置需手动setBounds是不属于这个层次setCompoundDrawablesWithIntrinsicBounds自动设置为intrinsic尺寸是不能自定义drawable自报intrinsic由TextView按intrinsic分配是但值可控能从表格里能看出前两种本质上都是把尺寸决策权交给了原始drawable。只有第三种把“报尺寸”的行为拦截下来才能真正掌握控制权。3. 方案一ScaleDrawable做表面缩放适合应急第一种实际可用的方案是系统自带的ScaleDrawable。它不需要写任何自定义类几行代码就能把compound drawable缩放成目标比例适合快速验证尺寸效果。它的用法是把原始drawable包一层ScaleDrawable然后让TextView加载这个包装对象。由于ScaleDrawable本身继承自Drawable它可以直接被setCompoundDrawables接受。3.1 ScaleDrawable的构造参数与Level陷阱先看构造方法new ScaleDrawable(Drawable drawable, int gravity, float scaleWidth, float scaleHeight)。第一个参数是原图drawable第二个参数是缩放之后的对齐方向通常用Gravity.CENTER。第三和第四个参数是水平、垂直方向上的缩放比例0到1之间意味着从0%到100%。但这里有一个很容易踩的大坑ScaleDrawable的绘制效果受到Level控制。官方定义里Level取值范围是0到10000ScaleDrawable会根据当前的level和10000的比例关系决定实际绘制时生效的缩放程度。如果你new完ScaleDrawable直接setCompoundDrawables经常会出现图标消失的情况。因为默认Level是0意味着缩放程度是0%绘制区域虽然存在但内容完全不可见。必须手动调用scaleDrawable.setLevel(10000)让Level达到最大值绘制的缩放比例才会完整生效。3.2 换算目标尺寸的两种方式假设原图是48x48dp你想显示成20x20dp那么scaleWidth和scaleHeight就应该是20/48约等于0.4167。注意这个比例是“显示尺寸除以原图固有尺寸”不是除以屏幕密度之类的其它值。代码写法如下Drawable original getResources().getDrawable(R.drawable.ic_status, null); ScaleDrawable scaleDrawable new ScaleDrawable(original, Gravity.CENTER, 0.4167f, 0.4167f); scaleDrawable.setLevel(10000); TextView textView findViewById(R.id.text_view); textView.setCompoundDrawables(scaleDrawable, null, null, null);如果不想手算比例可以反推一下先拿到原始drawable的当前尺寸再计算缩放比例int originalWidth original.getIntrinsicWidth(); float targetWidth dp2px(20); float scale targetWidth / originalWidth;这样的好处是换一张不同尺寸的切图进去也能自动适配缺点是每次都要专门算一遍比例。3.3 ScaleDrawable方案的局限这个方案最大的问题是排版空间不会跟着变。ScaleDrawable的getIntrinsicWidth和getIntrinsicHeight返回值仍然来自原始drawable。TextView在计算布局时还是按照原始48dp预留了一大块空隙但绘制的时候你只画了20dp的内容。所以实际效果往往是文字旁边空出一大块透明区域。左侧/右侧drawable还好因为文字默认从可用边界开始排空白会在图标自身那边但如果你在文字上方或者下方放置缩放后的图标行高会被撑得很夸张。适合的场景是图标与目标尺寸差距不大或者你只是在调试阶段想快速预览一下视觉效果。如果把这个方案放到生产环境的列表里用户会很容易看到排版间距不均匀的问题。我在早期项目里用过一次之后基本就不再把它作为正式方案了。4. 方案二自定义Drawable包装从根上控制尺寸最核心、也是最推荐长期使用的是自定义一个Drawable子类把原始drawable包起来然后重写getIntrinsicWidth/getIntrinsicHeight和draw方法。这个方案能同时解决两个问题TextView排版时预留的空间是目标尺寸绘制时实际画出来的内容也是目标尺寸。这相当于你自己充当了TextView和原始drawable之间的“翻译员”让TextView以为这个图标本来就应该是20dp。4.1 核心代码实现我这里给出一个通用版FixedSizeDrawable。它接收一个源drawable和一个目标宽高然后向外暴露目标尺寸。public class FixedSizeDrawable extends Drawable { private final Drawable source; private final int targetWidthPx; private final int targetHeightPx; public FixedSizeDrawable(Drawable source, int targetWidthPx, int targetHeightPx) { this.source source; this.targetWidthPx targetWidthPx; this.targetHeightPx targetHeightPx; } Override public void draw(NonNull Canvas canvas) { int left getBounds().left; int top getBounds().top; float scaleX targetWidthPx / (float) source.getIntrinsicWidth(); float scaleY targetHeightPx / (float) source.getIntrinsicHeight(); canvas.save(); canvas.translate(left, top); canvas.scale(scaleX, scaleY); source.setBounds(0, 0, source.getIntrinsicWidth(), source.getIntrinsicHeight()); source.draw(canvas); canvas.restore(); } Override public int getIntrinsicWidth() { return targetWidthPx; } Override public int getIntrinsicHeight() { return targetHeightPx; } Override public int getMinimumWidth() { return targetWidthPx; } Override public int getMinimumHeight() { return targetHeightPx; } Override public void setAlpha(int alpha) { source.setAlpha(alpha); } Override public void setColorFilter(Nullable ColorFilter colorFilter) { source.setColorFilter(colorFilter); } Override public int getOpacity() { return PixelFormat.TRANSLUCENT; } }用起来也很简单Drawable original getResources().getDrawable(R.drawable.ic_status, null); FixedSizeDrawable fixed new FixedSizeDrawable( original, dp2px(20), dp2px(20) ); textView.setCompoundDrawables(fixed, null, null, null);注意这里对source也要做一次mutate处理。如果你拿到了一个被多个View共享的drawable实例直接setBounds会影响到其它还在使用原始尺寸的控件。建议在使用前调用original.mutate()得到一个独立的可变副本。4.2 draw()方法里的Canvas变换逻辑很多人在自定义drawable的时候卡在draw方法里不知道怎么把源drawable缩放到目标大小。关键点在于Canvas的几何变换顺序。我上面代码的顺序是先save把坐标系原点移动到setBounds产生的left和top位置再做scale最后让source在(0, 0)到(intrinsicWidth, intrinsicHeight)的原始范围绘制。由于已经缩放了Canvas源drawable画出的效果正好落在目标尺寸的区域里。这个方法有个额外的优点不需要在setBounds的时候偷偷修改source的bounds。TextView每次重新布局都会固定调用setBounds它会把这个FixedSizeDrawable的bounds设置到正确的位置。因为getIntrinsicWidth和getIntrinsicHeight已经返回了目标尺寸TextView计算出来的bounds区域自然等于目标尺寸内部的translate逻辑就永远是对的。如果你的源drawable和目标尺寸宽高比不一致比如你想把一张48x72的竖向图塞进20x20的方块里代码会把它拉伸变形。这种情况下需要根据比例做contain或者cover处理在scale时取两个比例中较小的那个然后额外偏移。4.3 为什么这个方案能直接投生产它解决了前面几个方案的所有痛点。首先TextView为drawable预留的排版空间是20dp而不是原图的48dp所以文字间距、线条高度都不会被撑大。其次绘制时实际可见内容也是20dp不会出现“占了位置但画小了”的透明缝隙。再次它不依赖反射、不依赖私有API兼容性非常稳。这个方案在旧版Android的ListView复用场景里也不会有问题。只要你在每个item绑定数据时重新new一个FixedSizeDrawable或者确保传入的source是mutate过的独立实例就不会出现drawable状态被互相污染的情况。我在某项目里用它统一了三个不同列表页的小标签图标效果很稳定。后来这个项目经历过多次的UI改版从24dp改到20dp再改回22dp只需要改一个工具方法里的常量所有列表的间距和图标大小同步变化。4.4 扩展封装成一个建议工具方法项目里为了统一维护可以把创建逻辑封装成静态工具类方法public static Drawable resizedDrawable(Drawable original, int sizePx) { return new FixedSizeDrawable(original, sizePx, sizePx); }像这样把尺寸计算、dp转px都收拢到一个地方后续要支持圆角、圆形裁剪、灰度化等附加效果也可以基于这个Wrapper模式继续扩展。比如再包一层CircleDrawable、RoundedDrawable这种组合方式非常灵活。5. 方案三setBounds与onMeasure的误区很多人卡在这条路上愿意折腾的人还会尝试另外两条路手动setBounds或者自定义TextView重写onMeasure。这两条路我都走过老实说事倍功半而且很容易在源码层面绕进去半天出不来。把这个过程记录在这里帮你避开。5.1 手改bounds为什么会被系统重新覆盖前面实验已经说过手动setBounds会被TextView重新计算。这里补充一下源码层面的观察TextView在重新布局和绘制时会调用一个更新compound drawable bounds的内部逻辑它会基于文字的高度、方向、padding和drawable的intrinsic尺寸重新计算。关键就是这个“重新计算”一定会执行。你手动setBounds只影响当前这一次绘制影响不了下一次排版。只要任何东西触发了布局更新比如setText、setPadding、宽度变化TextView都会把drawable bounds“格式化”回按intrinsic计算出的结果。这个坑在滚动列表里尤其明显。因为item回收复用的时候TextView会经历多次requestLayout和draw你会发现之前设置好的bounds全没了。5.2 自定义TextView重写onMeasure的尝试既然TextView是按intrinsic分配空间那另一种思路是自定义TextView在onMeasure阶段把自己测量的尺寸额外扩大让系统认为TextView体积更大从而间接给drawable腾出更大的绘制区域。这个思路听上去可行实际很棘手。你需要区分左右drawable和上下drawable分别计算宽度和高度方向的增量还要处理Gravity和layout_gravity引起的对齐问题如果文字换行还要考虑换行后的布局高度变化。整套逻辑下来代码量和维护成本远超方案二。我试过写一个MiniTextView自定义类思路是对每个compound drawable计算“目标尺寸与intrinsic尺寸的差值”然后加进onMeasure的结果里。代码如下Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); Drawable[] drawables getCompoundDrawables(); int extraWidth 0; int extraHeight 0; for (int i 0; i drawables.length; i) { Drawable d drawables[i]; if (d null) continue; if (i 0 || i 2) { extraWidth Math.max(0, targetDrawableWidth - d.getIntrinsicWidth()); } else { extraHeight Math.max(0, targetDrawableHeight - d.getIntrinsicHeight()); } } setMeasuredDimension(getMeasuredWidth() extraWidth, getMeasuredHeight() extraHeight); }这个写法在简单场景里确实能看到图标变大但很快就暴露出问题。TextView内部其它布局计算并没有感知到这套“增量”文字区域的测量结果依然是基于原intrinsic算出来的整体尺寸扩大的同时文字位置可能不对齐。更麻烦的是左右两侧同时有图标时计算很容易出偏差某一边的实际可用宽度和预期不一致出现文字被挤掉或者换行异常的情况。5.3 这几条路线的投入产出比手动setBounds没有解决根本问题自定义onMeasure则把简单需求搞复杂了。除非你的目标是研究源码否则在业务代码里维护这种自定义View是得不偿失的。方案二的做法之所以更合理是因为它是在“drawable报告固有尺寸”这一最原始节点动手。TextView不需要知道任何改动所有排版逻辑自动正确。我们想要的只是“让TextView以为这个图标的固有尺寸就是目标尺寸”这比“改变TextView对尺寸的感知方式”简单得多。6. 避坑清单处理drawable尺寸时最容易翻车的五个细节即使选择了方案二实际落地时还是会遇到各种小问题。我整理了一下近几年在多个项目里发生的真实情况每一个都对应一个具体的教训。6.1 尺寸变了padding和drawablePadding的关系也要检查很多人改完图标大小之后发现图标和文字之间的距离不对了。这是因为间距由TextView的android:drawablePadding控制它和drawable本身的尺寸是两套独立的值。图标变小时如果你想保持视觉间距一致drawablePadding可能要跟着调。建议把drawablePadding和图标尺寸一起放进统一的常量配置里不要在每个布局xml里写死数字。这样改尺寸的时候只需要改一处配置不会出现“图标改了间距没改”这种隐形问题。6.2 方向问题start/end方向在RTL布局下会翻转如果你用setCompoundDrawablesRelative或者setCompoundDrawablesRelativeWithIntrinsicBounds来设置start/end方向的图标它们分别对应API 17之后的RTL支持。自定义Drawable方案不涉及方向问题因为方向由TextView决定但你在处理图标自身内容时会面临镜像翻转等问题。比如某些带箭头方向的图标在RTL语言环境下可能需要镜像翻转。如果只是包了一层缩放箭头方向不会自动翻转。我曾经在处理阿拉伯语列表时遇到这个问题。建议给FixedSizeDrawable增加一个mirror开关在需要时对源drawable做水平翻转。6.3 高分辨率切图在低密度设备上容易被BitmapFactory再次缩放资源文件夹里的图如果放在drawable-xxhdpi下在低密度设备上加载时BitmapFactory会自动按密度比缩放intrinsicWidth/Height可能不是你预期的那一档。而FixedSizeDrawable的缩放比例是基于当前的intrinsic算的不同设备上真实结果依然一致因为最终显示尺寸由targetWidthPx决定。但如果你拿到的原始drawable是BitmapDrawable并且它内部有颜色状态、九宫格拉伸等特性使用前也要确认一下。6.4 Adapter复用与Drawable状态污染列表页的item复用时同一个drawable实例可能被多个TextView使用。如果源drawable是有状态的比如SelectorDrawable不同位置的按压状态会互相传递。我之前遇到的问题是给某个列表添加了选中效果后其它没选中的item的图标也显示了选中状态。这是因为drawable的ConstantState被共享了。解决办法是在包装之前针对源drawable做mutate()Drawable original context.getDrawable(resId).mutate(); FixedSizeDrawable framed new FixedSizeDrawable(original, targetWidth, targetHeight);6.5 反射修改TextView内部drawable的做法尽量不要碰网上还有一种很野的路子通过反射拿到TextView里的mDrawables然后逐项改变内部Drawable对象。这种方法在一些老版本上能生效但它依赖TextView内部字段名字段名一旦变更就会崩掉。更深层的问题是TextView内部对compound drawable有很多预处理逻辑反射绕过这些逻辑会破坏状态管理。即使反射成功改了大小后续drawable的状态切换、点击反馈、动画都会变得不可控。生产环境不建议用任何依赖内部字段名的方案。个人经验是遇到drawable尺寸需求就老老实实走方案二这个Wrapper模式不但能解决TextView的iconsize问题还可以原样移植到ImageView、自定义复合控件的场景中。后续我会再讲一讲WrapperDrawable在异步加载场景里如何配合占位图和状态恢复那又是一个新的话题了。