解决原生textview不支持多行文字中间省略号问题

测试数据如下:

复制代码
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 }