测试数据如下:
val txt = if (pos == 0){
"这是一个测试一个abc测试条目在测试"
}else{
"this is a test for textview display text"
}
原生显示效果:

明显可以看到没显示全,数据被截断了;
自已实现了一个textview,显示效果如下

实现思路如下:
整体思路:在 setText 时拦截原始文本 → 判断是否超出 2 行 → 用二分查找从中间截断 → 插入省略号。
1. 入口:重写 setText()
override fun setText(text: CharSequence?, type: BufferType?) {
originalText = text ?: "" // 保存原始文本
super.setText(processText(text), type) // 传入处理后的文本
}
每次调用 text = "xxx" 时都会走到这里。先存原始文本(onSizeChanged 时需要重新处理),再把加工后的文本传给父类。
2. 核心:processText() --- 文本加工
flowchart TD
A原始文本 --> B{为空?}
B -->|是| C直接返回
B -->|否| D{maxLines ≤ 1?}
D -->|是| E返回原文\
走原生 ellipsize
D -->|否| F{View 已布局?}
F -->|否| G返回原文\
等 onSizeChanged 再处理
F -->|是| H{原文能放下?}
H -->|能| I返回原文\
无需截断
H -->|不能| J二分查找截断点\
首尾各保留 halfLen \> 中间加 ...
J --> K返回截断后文本
截断算法
原文: "abcdefghijklmnopqrstuvwxyz"(26个字符,假设放不下)
二分查找 halfLen(首尾各保留几个字符):
halfLen=6: "abcdef...uvwxyz" → StaticLayout 测量 → 放不下 → 减半
halfLen=3: "abc...xyz" → 放得下!→ 尝试更多
halfLen=4: "abcd...wxyz" → 放得下!→ 尝试更多
halfLen=5: "abcde...vwxyz" → 放不下 → 取 4
结果: "abcd...wxyz"
关键点:
-
left 从 0 开始,right 从 总长度/2 开始(前后对称,两边保留的字符数相同)
-
每一步用 StaticLayout 真实测量渲染后是否在 maxLines 行之内
-
bestHalfLen 记录最大的可行截断点,保证尽可能多显示字符
3. 测量:fitsInLines() --- 判断能否放得下
val layout = StaticLayout.Builder.obtain(text, 0, text.length, paint, width)
.build()
return layout.lineCount <= maxLines
用 StaticLayout 模拟真实排版(字号、字体、行宽都一样),算出实际会占几行,和 maxLines 比较。
4. 回调:onSizeChanged() --- 宽度变化时重新处理
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
if (w != oldw) {
super.setText(processText(originalText), BufferType.NORMAL)
}
}
View 第一次布局完成或宽度变化时,用 originalText 重新计算截断。这覆盖了 processText 第 40 行因宽度为 0 而跳过的场景。
关键是StaticLayout方法的调用
StaticLayout 是整个实现的核心。
Android 原生 ellipsize="middle" 不支持多行,根本原因就是框架内部没有对多行文本做中间截断的测量。我们的实现就是补上这个能力,而 StaticLayout 做了最关键的一件事:
用真实排版参数"试排",告诉你文字会占几行
val layout = StaticLayout.Builder.obtain(text, 0, text.length, paint, width)
.build()
return layout.lineCount // ← 就这行,是整段逻辑的基石
它拿的是你当前 TextView 的 paint(字号、字体、行距全一样)和实际可用宽度,做一次真实排版。然后 lineCount
告诉你:这段文字在当前条件下会渲染成几行。
没有它只能瞎猜
|---------------------|--------------------|
| 方案 | 问题 |
| 数字符个数 | 中文/英文/符号宽度不一样,完全不准 |
| Paint.measureText() | 只能测单行宽度,算不出换行和行数 |
二分查找只是"壳"
每次试一种截断方案 → StaticLayout 真实排版 → lineCount ≤ maxLines ?
│
是 → 保留更多 否 → 多截一些
StaticLayout 提供准确的 lineCount,二分查找才能一步步逼近最佳截断点。它负责"裁判",二分负责"试"。
源码如下所示:
import android.content.Context
import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
import android.text.TextUtils
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatTextView
/**
* 支持多行中间省略的 TextView。
* 当文本在 [maxLines] 行内放不下时,从中间截断并插入省略号(...)。
*/
class MiddleEllipsisTextView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : AppCompatTextView(context, attrs, defStyleAttr) {
/** 原始文本,用于宽度变化时重新计算截断。 */
private var originalText: CharSequence = ""
override fun setText(text: CharSequence?, type: BufferType?) {
originalText = text ?: ""
super.setText(processText(text), type)
}
/** 对文本进行中间省略处理。 */
private fun processText(text: CharSequence?): CharSequence? {
if (text.isNullOrEmpty()) return text
val maxLines = maxLines
// 单行交给原生 ellipsize 处理,本类只处理多行场景
if (maxLines <= 1) return text
val paint = TextPaint(paint)
val availableWidth = (width - compoundPaddingLeft - compoundPaddingRight).coerceAtLeast(1)
// 尚未布局(宽度为 0),先返回原文,等 onSizeChanged 回调后重新处理
if (availableWidth <= 1) return text
val textStr = text.toString()
val ellipsis = "..." // 省略号
// 原文能放下就直接返回,无需截断
if (fitsInLines(textStr, paint, availableWidth, maxLines)) {
return text
}
// 二分查找最佳截断点:首尾各保留 halfLen 个字符,中间插入省略号
val totalLen = textStr.length
var left = 0
var right = totalLen / 2
var bestHalfLen = 0
while (left <= right) {
val halfLen = (left + right) / 2
val truncated = textStr.substring(0, halfLen) +
ellipsis +
textStr.substring(totalLen - halfLen)
if (fitsInLines(truncated, paint, availableWidth, maxLines)) {
bestHalfLen = halfLen
left = halfLen + 1 // 放得下,尝试保留更多字符
} else {
right = halfLen - 1 // 放不下,减少保留字符
}
}
if (bestHalfLen == 0) {
// 连最短形式都放不下,只显示省略号
return ellipsis
}
return textStr.substring(0, bestHalfLen) +
ellipsis +
textStr.substring(totalLen - bestHalfLen)
}
/** 用 StaticLayout 真实排版,判断文本是否在 maxLines 行以内。 */
private fun fitsInLines(
text: String,
paint: TextPaint,
width: Int,
maxLines: Int
): Boolean {
val layout = StaticLayout.Builder.obtain(text, 0, text.length, paint, width)
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
.build()
return layout.lineCount <= maxLines
}
/** 宽度变化时用原始文本重新计算截断。 */
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
if (w != oldw) {
super.setText(processText(originalText), BufferType.NORMAL)
}
}
}
package com.example.myapplication
import android.util.Log
/**
* 轻量级日志工具,封装 [android.util.Log]。
*
* 用法:
* - 扩展函数:"hello".logD() → tag = 调用者的类名
* - 静态方法:LogUtil.d("myTag", "hello")
*
* 所有方法的 lambda 版本都支持惰性求值 --- 仅在日志真正需要输出时才会执行。
*/
object LogUtil {
/** 全局开关:设为 false 可关闭所有日志(如 release 版本)。 */
var enabled = true
// ── 静态方法(指定 tag) ───────────────────────────────────────
@JvmStatic fun v(tag: String, msg: () -> String) { if (enabled && Log.isLoggable(tag, Log.VERBOSE)) Log.v(tag, msg()) }
@JvmStatic fun d(tag: String, msg: () -> String) { if (enabled) Log.d(tag, msg()) }
@JvmStatic fun i(tag: String, msg: () -> String) { if (enabled) Log.i(tag, msg()) }
@JvmStatic fun w(tag: String, msg: () -> String) { if (enabled) Log.w(tag, msg()) }
@JvmStatic fun w(tag: String, msg: () -> String, tr: Throwable) { if (enabled) Log.w(tag, msg(), tr) }
@JvmStatic fun e(tag: String, msg: () -> String) { if (enabled) Log.e(tag, msg()) }
@JvmStatic fun e(tag: String, msg: () -> String, tr: Throwable) { if (enabled) Log.e(tag, msg(), tr) }
// String 重载
@JvmStatic fun v(tag: String, msg: String) = v(tag) { msg }
@JvmStatic fun d(tag: String, msg: String) = d(tag) { msg }
@JvmStatic fun i(tag: String, msg: String) = i(tag) { msg }
@JvmStatic fun w(tag: String, msg: String) = w(tag) { msg }
@JvmStatic fun e(tag: String, msg: String) = e(tag) { msg }
}
// ── 扩展函数 --- tag 自动取调用者类名 ──────────────────────────────
/** tag 默认为当前类的简称。 */
private fun Any.logTag(): String = (this as? Class<*>)?.simpleName
?: this.javaClass.simpleName
?: "LogUtil"
fun Any.logV(msg: () -> String) = LogUtil.v(logTag(), msg)
fun Any.logD(msg: () -> String) = LogUtil.d(logTag(), msg)
fun Any.logI(msg: () -> String) = LogUtil.i(logTag(), msg)
fun Any.logW(msg: () -> String) = LogUtil.w(logTag(), msg)
fun Any.logW(msg: () -> String, tr: Throwable) = LogUtil.w(logTag(), msg, tr)
fun Any.logE(msg: () -> String) = LogUtil.e(logTag(), msg)
fun Any.logE(msg: () -> String, tr: Throwable) = LogUtil.e(logTag(), msg, tr)
// String 重载
fun Any.logV(msg: String) = logV { msg }
fun Any.logD(msg: String) = logD { msg }
fun Any.logI(msg: String) = logI { msg }
fun Any.logW(msg: String) = logW { msg }
fun Any.logE(msg: String) = logE { msg }