低代码到底是不是行业毒瘤?一线大厂怎么做的?戳此了解>>> 了解详情
写点什么

如何简单优雅的适配 textview 行间距?

2021 年 1 月 24 日

如何简单优雅的适配textview行间距?

导读:TextView 的行间距在不同设备下的一致性表现不尽如人意,这给视觉 review 带来了不少麻烦,降低了 RD&UI 的工作效率,本文将探索出了一套低风险高兼容性的解决方案。该方案能够完全统一 TextView 的行间距,保证了 TextView 行间距在不同机型上的一致性体验,这极大程度减少了 TextView 相关的视觉联调时间,提高了大家的工作效率。

背景

视觉体验也是各种手机 APP 产品质量不能忽视的一环。目前市场上手机型号多种多样,各自屏幕的分辨率层出不穷,如何能在不同分辨率的屏幕上呈现出始终如一的显示效果,也是每一位同学想要了解的。


Android 的屏幕碎片化严重,各种屏幕分辨率层出不穷,而在不同分辨率的屏幕上显示出一致的效果,是百度 App 的研发团队和视觉团队共同追求的目标。在百度 App 的 Android 开发中,TextView 的行间距屏幕适配问题在研发和视觉之间纠缠已久。



该图为热议页面的图文模板在三款设备上的显示效果。可以看到 TextView 的行间距在三款设备下的一致性表现不尽如人意,而这已成为日常 UI 开发以及视觉 review 过程中的一大痛点,降低了大家的工作效率。


下面将探索一种简单优雅的的 TextView 行间距适配方案。

分析

先来分析下 TextView 在不同设备上行间距表现不一致的原因。百度 App 的 UI 团队使用 Sketch 工具来进行 UI 设计以及 UI review,因此本文接下来字体尺寸的测量都借助 Sketch 工具完成。


先看下面一个简单的 xml 布局:


<TextView        android:id="@+id/title"        android:layout_width="match_parent"        android:layout_height="wrap_content"        android:text="虽然此视图的实际布局取决于其父视图和任何同级视图中的其他属性。虽然。。。"        android:textSize="16dp"/>
复制代码


将这段代码运行在不同分辨率的机型上,借助 Sketch 工具测量出各机型的行间距如下:



从图中可看出,同样的字号大小,在分辨率为 720 设备上,行间距测量结果为 5px;在分辨率为 1080 设备上,行间距测量结果为 9px。


接下来修改下字号,将 textSize 改成 24dp,并且看一下 Mate20 的效果:


<TextView        android:id="@+id/title"        android:layout_width="match_parent"        android:layout_height="wrap_content"        android:text="虽然此视图的实际布局取决于其父视图和任何同级视图中的其他属性。虽然。。。"        android:textSize="24dp"/>
复制代码


在同一款设备(Mate 20)上,不同的字号,行间距的测量结果,如下图所示:



从图中可看到,在同样的设备上,不同的字号,行间距的测量结果也不一样。


具体表现为:字号越大,行间距越大。这就让人非常苦恼了,因为一旦字号发生了变化,行间距就受到影响,行间距必须得跟随字号重新调整,无形之中就增加了额外的工作量。


读到这大家可能会有疑问:XML 布局中并没设置 lineSpacingExtra / lineSpacingMultiplier 属性,那么上面所测量的行间距是哪来的呢?


这是因为视觉对行间距的定义和 Android 系统对行间距的定义不一致导致的。视觉层面定义行间距非常简单:即使用 Sketch 工具在上下相邻的两行文字中输入大小相同的文字,同时画出文字的矩形框,矩形框的高度为文字的大小,比如在 1080P,density=3 的设计图中,文字大小为 16dp,那么矩形框的高度就设为 48px。上下两个矩形框的间距就为文字的行间距,这从上面的测量效果图也可看出。


也就是说,即使没有设置 lineSpacingExtra / lineSpacingMultiplier 属性,但从视觉的角度来讲,仍存在一定的行间距。


那么在没有设置 lineSpacingExtra / lineSpacingMultiplier 属性的情况下,视觉所测量出来的行间距是什么原因导致的?


下面结合 TextView 源码详细分析下,首先看下图:



该图展示了一行文字的绘制所需要的关键坐标信息,图中的几根线表示字体的度量信息,在源码中与其相对应的类为 FontMetrics.java,代码如下:


/** * Class that describes the various metrics for a font at a given text size. * Remember, Y values increase going down, so those values will be positive, * and values that measure distances going up will be negative. This class * is returned by getFontMetrics(). */public static class FontMetrics {    /**     * The maximum distance above the baseline for the tallest glyph in     * the font at a given text size.     */    public float   top;    /**     * The recommended distance above the baseline for singled spaced text.     */    public float   ascent;    /**     * The recommended distance below the baseline for singled spaced text.     */    public float   descent;    /**     * The maximum distance below the baseline for the lowest glyph in     * the font at a given text size.     */    public float   bottom;    /**     * The recommended additional space to add between lines of text.     */    public float   leading;}
复制代码


代码中对字体度量信息的每个字段含义的解释非常详细,大家看注释即可,就不再过多解释。


TextView 对每行文字坐标信息的计算细节是在 StaticLayout.java 类中的 out()方法完成的,代码如下:


private int out(final CharSequence text, final int start, final int end, int above, int below,        int top, int bottom, int v, final float spacingmult, final float spacingadd,        final LineHeightSpan[] chooseHt, final int[] chooseHtv, final Paint.FontMetricsInt fm,        final boolean hasTab, final int hyphenEdit, final boolean needMultiply,        @NonNull final MeasuredParagraph measured,        final int bufEnd, final boolean includePad, final boolean trackPad,        final boolean addLastLineLineSpacing, final char[] chs,        final int widthStart, final TextUtils.TruncateAt ellipsize, final float ellipsisWidth,        final float textWidth, final TextPaint paint, final boolean moreChars) {    final int j = mLineCount;    // 偏移量,标识当前的行号    final int off = j * mColumns;    final int want = off + mColumns + TOP;    // 一维数组,保存了TextView各行文字的计算出来的坐标信息。    int[] lines = mLines;    final int dir = measured.getParagraphDir();      // 将所有的字体的度量信息存入fm变量中,然后通过LineHeightSpan接口将fm变量传递出去.    // 这就给外部提供了一个接口去修改字体的度量信息。    if (chooseHt != null) {        fm.ascent = above;        fm.descent = below;        fm.top = top;        fm.bottom = bottom;
for (int i = 0; i < chooseHt.length; i++) { if (chooseHt[i] instanceof LineHeightSpan.WithDensity) { ((LineHeightSpan.WithDensity) chooseHt[i]) .chooseHeight(text, start, end, chooseHtv[i], v, fm, paint); } else { chooseHt[i].chooseHeight(text, start, end, chooseHtv[i], v, fm); } } // 获取修改后的字体度量属性 above = fm.ascent; below = fm.descent; top = fm.top; bottom = fm.bottom; } if (firstLine) { if (trackPad) { mTopPadding = top - above; }
if (includePad) { // 如果当前行是TextView的第一行文字,above(ascent)值使用top替代。 above = top; } }
int extra;
if (lastLine) { if (trackPad) { mBottomPadding = bottom - below; }
if (includePad) { // 如果当前行是TextView的最后一行文字,below(descent)值使用bottom替代。 below = bottom; } }
if (needMultiply && (addLastLineLineSpacing || !lastLine)) { // 计算行间距 // spacingmult变量对应lineSpacingMultiplier属性配置的值 // spacingadd变量对应lineSpacingExtra属性配置的值。 double ex = (below - above) * (spacingmult - 1) + spacingadd;
if (ex >= 0) { extra = (int)(ex + EXTRA_ROUNDING); } else { extra = -(int)(-ex + EXTRA_ROUNDING); } } else { extra = 0; }
// 将当前行的坐标信息存入mLines[]数组中 lines[off + START] = start; lines[off + TOP] = v; lines[off + DESCENT] = below + extra; lines[off + EXTRA] = extra;
// 计算下一行的的top值 v += (below - above) + extra; mLineCount++; return v;}
复制代码


由于篇幅原因,省略了一些无关代码。上面对关键代码都给出了详细的注释,这里就不过多解释。通过第 87 行,可得出如下两个公式:


  • top 坐标计算:下一行 Top 值 = 本行 Top 值 + 行高

  • 行高计算(排除第一行和最后一行):行高 = descent - ascent + 行间距 (descent 值为正,ascent 值为负)


为了方便大家理解行高,我把每行文字的 baseline 和 top 这两根线画了出来,红色的线是 baseline 基线,绿色的线是 top 线,相邻两条绿线之间的距离即为行高,如下图所示:



到这里,基本能够解释,在没有设置 lineSpacingExtra / lineSpacingMultiplier 属性的情况下,Sketch 工具量出的行间距原因:我们知道每行文字以 baseline 作为基线来绘制,在 ascent 范围内绘制基线以上的部分,在 descent 范围内绘制基线以下部分。


由于汉字不会像英文那样高低不一,是非常整齐的方块字。


而汉字在 descent 范围内绘制基线以下部分时,并没有占满 descent 所有空间,会空出一部分距离,在 ascent 范围内绘制基线以上部分时,也是同样的道理。所以,Sketch 测量出来的行间距就是上一行汉字占据的 descent 范围后的剩余空间加上下一行汉字占据的 ascent 范围后的剩余空间。

适配方案

经过上面分析,了解到 TextView 的自带行间距是由于绘制的汉字没有占满 descent 和 ascent 的空间引起的,且该行间距在不同的字号以及分辨率下表现不一。


若能够去除掉这部分行间距,就能达到适配目的。怎么去除呢?我们再看一下系统 TextView 和视觉对一行文字行高的定义:


  • TextView:行高 = descent - ascent (descent 值为正,ascent 值为负)

  • 视觉:行高 = 字体大小 (比如 16dp 的文字,行高=48px


只要能够修改 TextView 的默认行高,让其和视觉定义的行高保持统一,就能去除掉这部分行间距。


怎么修改 TextView 的默认行高呢?


其实 TextView 在设计的时候,提供了一个接口去修改 TextView 的行高。


回到上面对 TextView 的源码分析,第 20 行-第 39 行,将字体的度量信息存入 fm 变量中,然后通过 LineHeightSpan 接口将 fm 变量传递出去,我们借助这个 LineHeightSpan 就可以修改 TextView 的行高。


最终适配方案如下:


public class ExcludeInnerLineSpaceSpan implements LineHeightSpan {    // TextView行高    private final  int mHeight;
public ExcludeInnerPaddingSpan(int height) { mHeight = height; }
@Override public void chooseHeight(CharSequence text, int start, int end, int spanstartv, int lineHeight, Paint.FontMetricsInt fm) { // 原始行高 final int originHeight = fm.descent - fm.ascent; if (originHeight <= 0) { return; } // 计算比例值 final float ratio = mHeight * 1.0f / originHeight; // 根据最新行高,修改descent fm.descent = Math.round(fm.descent * ratio); // 根据最新行高,修改ascent fm.ascent = fm.descent - mHeight; }}
复制代码


类 ExcludeInnerLineSpaceSpan 实现 LineHeightSpan 接口,这个类用于去除 TextView 的自带行间距。


第 5 行,构造函数,以最新的行高作为参数传入。


第 14 行,计算出原始行高。


第 19 行,计算出新行高和原始行高的比例值。


第 21 行-第 23 行,根据比例值修改字体度量的 ascent 参数和 descent 参数。


接下来自定义个 TextView 出来,提供一个 setCustomText()方法出来,供使用方调用。代码如下:


public class ETextView extends TextView {    /**     * 排除每行文字间的padding     *     * @param text     */    public void setCustomText(CharSequence text) {        if (text == null) {            return;        }
// 获得视觉定义的每行文字的行高 int lineHeight = (int) getTextSize();
SpannableStringBuilder ssb ; if (text instanceof SpannableStringBuilder) { ssb = (SpannableStringBuilder) text; // 设置LineHeightSpan ssb.setSpan(new ExcludeInnerLineSpaceSpan(lineHeight), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } else { ssb = new SpannableStringBuilder(text); // 设置LineHeightSpan ssb.setSpan(new ExcludeInnerLineSpaceSpan(lineHeight), 0, text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); }
// 调用系统setText()方法 setText(ssb); }}
复制代码


ShowCase

该方案使用系统公开 API,简单,侵入性低。并已在百度 App 热议页面上线,适配效果前后对比,如下图所示:




带给用户优雅简单的视觉体验,也是同学们不懈追求产品质量的体现。看完文章后,你是不是对手机 TextView 行间距适配方案有了新的启发?欢迎大家与作者探讨心得~


本文转载自:百度架构师(ID:gh_38e0c590bf34)


原文链接:

https://mp.weixin.qq.com/s/E3J-SJEHacBqOqlwqFZu9Q

2021 年 1 月 24 日 15:141250

评论

发布
暂无评论
发现更多内容

薇娅携手中国航天基金会与我们的太空 带你“益起探月,共舞九天“

Geek_459987

「面试必备」最新整理出的腾讯C++后台开发面试笔记

linux大本营

c++ Linux 后台开发 架构师

一份知识点全面又能不断更新与时俱进的《Java面试宝典》,有人已成功靠它拿到阿里、京东、字节跳动等大厂offer,

Java成神之路

Java 程序员 架构 面试 编程语言

重点人员管控系统开发,智慧公安系统搭建方案

WX13823153201

重点人员管控系统开发

不懂源码?来看看阿里P8亲自手码的Spring源码解析整套笔记,高薪offer唾手可得!

比伯

Java 编程 架构 面试 计算机

Spring Boot 2.4.0正式发布,全新的配置文件加载机制(不向下兼容)

YourBatman

云原生 Spring Boot 新特性

使用resilio实现多集群的k8s pod数据双向非实时同步

东风微鸣

Kubernetes 探索与实践 openshift

Mac下Docker Desktop配置阿里云镜像加速器

jiangling500

Docker 阿里云镜像加速器

CAP理论

DL

Kubernetes初体验--用Kubernetes部署一个Web服务

网管

go Kubernetes k8s Web 服务

一点就透的二分查找算法

比伯

Java 编程 程序员 面试 计算机

接口测试如何在json中引用mock变量

测试人生路

json 接口测试 Mock

深度剖析,为何C语言在开发领域的地位如此稳固

Philips

Python go .net rust C语言

《我想进大厂》之Spring夺命连环10问

艾小仙

Java spring 程序员 面试 大厂

Linux 笔记(三): 软件安装

Leo

Linux 学习 前端进阶训练营

“摸爬滚打”多年,从月薪3K到30Kjava大神,我是怎么蜕变的?

比伯

Java 编程 架构 面试 计算机

使用 Jira Service Management 管理资产,您需要知道的5件事

Atlassian

数字化转型 Atlassian Jira ITSM ITIL

产业新基建,撬动数字经济发展新机遇

京东科技开发者

人工智能 新基建 京东

排查指南 | mPaaS 小程序提示“网络不给力”时该如何排查?

蚂蚁集团移动开发平台 mPaaS

小程序 网络 小程序生态 mPaaS

C++语言中std::array的神奇用法总结,你需要知道!

华为云开发者社区

容器 数组 函数

架构师第一期作业(第 11 周)

Cheer

作业

揭秘11.11监控排障利器 京东高稳定日志服务深度解析

京东科技开发者

云计算 DevOps 日志监控

架构师训练营第十周作业

文智

极客大学架构师训练营

什么是物联网?常见IoT 物联网协议最全讲解

华章IT

物联网 IoT

那些年我们一起追过的高深术语

北游学Java

编程 程序员 程序人生 C/C++

Canal 组件简介与 vivo 帐号实践

vivo互联网技术

数据库 分布式 数据存储

一线大厂欺负程序员?京东单方面辞退38岁P7员工三次败诉

Java架构师迁哥

什么是低代码(Low-Code)?

阿里巴巴云原生

程序员 云原生 代码

华为云MVP高浩:打破AI开发瓶颈,解决数据、算法、算力三大难题

华为云开发者社区

人工智能 数据 华为云

微服务已然成为Java开发的面试门槛,你连SpringCloud都不会还想跳槽涨薪?

Java成神之路

Java 程序员 架构 面试 编程语言

Mysql数据备份与恢复

张攀钦

MySQL

2021 ThoughtWorks 技术雷达峰会

2021 ThoughtWorks 技术雷达峰会

如何简单优雅的适配textview行间距?-InfoQ