Android 实现动态折叠文本的ExpandTextView控件开发
1. 为什么需要动态折叠文本控件在移动应用开发中文本内容展示是个看似简单却暗藏玄机的问题。相信大家都有这样的体验刷朋友圈时看到一段被截断的文字末尾带着全文按钮或者阅读新闻App时遇到长段落自动折叠点击展开才能看到完整内容。这种交互设计已经成为移动端UI的标配但Android系统自带的TextView并不原生支持这个功能。我做过一个社交类App的项目当时产品经理拿着竞品截图说我们的动态列表也要实现这种文字折叠效果。最初尝试用系统TextView的ellipsize属性发现只能简单显示省略号既无法控制折叠行数也不能添加可点击的展开按钮。更头疼的是当文本中包含emoji或混合字体时简单的字符截取会导致排版错乱。动态折叠文本控件的核心价值在于空间利用率优化在信息密集的列表页面合理折叠长文本可以提升屏幕空间利用率用户体验提升给予用户内容预览和自主选择权避免信息过载性能考虑避免一次性渲染超长文本造成的UI卡顿2. 控件设计思路剖析2.1 关键技术方案选型实现动态折叠效果主要有三种技术路线纯测量方案通过TextPaint测量文本宽度结合行数计算进行截断StaticLayout方案利用Android的布局引擎进行精确行数控制混合方案结合测量和布局引擎的优势处理复杂文本情况经过实际项目验证我推荐使用StaticLayout方案。虽然看起来代码量稍大但能完美处理以下场景中英文混排时的自动换行不同字体大小的混合文本包含emoji等特殊字符的情况2.2 核心交互逻辑设计控件需要维护三种状态测量状态初始化时计算文本实际需要的行数折叠状态显示指定行数的文本展开按钮展开状态显示完整文本收起按钮这里有个容易踩坑的地方按钮文本的测量。如果简单地在原始文本后追加展开可能导致按钮被挤到下一行浪费空间截断位置不合理破坏语义完整性我们的解决方案是先计算原始文本的截断位置再动态调整确保按钮能完整显示在同一行。3. 完整代码实现详解3.1 控件基础结构首先创建ExpandTextView继承自TextViewpublic class ExpandTextView extends TextView { private String originText; // 原始文本内容 private int initWidth; // 控件可用宽度 private int mMaxLines 3; // 默认最大行数 // 展开/收起按钮的样式文本 private SpannableString SPAN_CLOSE; private SpannableString SPAN_EXPAND; private final String TEXT_EXPAND 全文; private final String TEXT_CLOSE 收起; // 构造方法 public ExpandTextView(Context context) { super(context); initButtonSpans(); } // 其他构造方法... }3.2 文本测量与截断核心方法是createWorkingLayout它创建一个不可见的布局用于精确测量private Layout createWorkingLayout(String text) { int availableWidth initWidth - getPaddingLeft() - getPaddingRight(); return new StaticLayout( text, getPaint(), availableWidth, Layout.Alignment.ALIGN_NORMAL, getLineSpacingMultiplier(), getLineSpacingExtra(), false); }折叠状态设置的关键逻辑public void setCloseText(CharSequence text) { originText text.toString(); Layout layout createWorkingLayout(originText); if (layout.getLineCount() mMaxLines) { // 截取到最大行数-1的位置 String workingText originText.substring(0, layout.getLineEnd(mMaxLines - 1)).trim(); // 调整截取位置确保按钮能完整显示 String tempText workingText ... TEXT_EXPAND; while (createWorkingLayout(tempText).getLineCount() mMaxLines) { workingText workingText.substring(0, workingText.length() - 1); tempText workingText ... TEXT_EXPAND; } setText(workingText ...); append(SPAN_CLOSE); } else { setText(originText); } }3.3 按钮点击交互使用ClickableSpan实现可点击文本private void initButtonSpans() { // 展开按钮 SPAN_CLOSE new SpannableString(TEXT_EXPAND); ButtonSpan expandSpan new ButtonSpan(getContext(), v - { setMaxLines(Integer.MAX_VALUE); setExpandText(originText); }, R.color.colorAccent); SPAN_CLOSE.setSpan(expandSpan, 0, TEXT_EXPAND.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE); // 收起按钮 SPAN_EXPAND new SpannableString(TEXT_CLOSE); ButtonSpan collapseSpan new ButtonSpan(getContext(), v - { setMaxLines(mMaxLines); setCloseText(originText); }, R.color.colorAccent); SPAN_EXPAND.setSpan(collapseSpan, 0, TEXT_CLOSE.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE); }4. 性能优化实战技巧4.1 避免重复测量在ListView或RecyclerView中使用时每次滚动都可能导致重新测量。我们可以通过缓存优化private Layout mLayoutCache; private Layout getCachedLayout(String text) { if (mLayoutCache null || !mLayoutCache.getText().equals(text)) { mLayoutCache createWorkingLayout(text); } return mLayoutCache; }4.2 异步布局计算对于超长文本如超过500字符测量操作可以放到子线程private void asyncSetText(final String text) { final int width getWidth(); if (width 0) { post(() - asyncSetText(text)); return; } new AsyncTaskVoid, Void, Layout() { Override protected Layout doInBackground(Void... params) { return createWorkingLayout(text); } Override protected void onPostExecute(Layout result) { // 更新UI... } }.execute(); }4.3 内存优化建议避免在自定义View中创建大量临时对象重用SpannableString对象对超长文本考虑分段加载5. 高级功能扩展5.1 动画过渡效果为展开/收起添加平滑动画private void animateExpansion(final boolean expand) { final int startHeight getHeight(); final int endHeight expand ? getFullTextHeight() : getCollapsedHeight(); ValueAnimator animator ValueAnimator.ofInt(startHeight, endHeight); animator.addUpdateListener(animation - { getLayoutParams().height (int) animation.getAnimatedValue(); requestLayout(); }); animator.start(); }5.2 自定义按钮样式通过XML属性支持样式自定义declare-styleable nameExpandTextView attr nameexpandText formatstring/ attr nameexpandColor formatcolor/ attr nameanimationDuration formatinteger/ /declare-styleable5.3 多语言适配正确处理RTL从右到左语言Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { super.onLayout(changed, left, top, right, bottom); if (Build.VERSION.SDK_INT Build.VERSION_CODES.JELLY_BEAN_MR1) { if (getLayoutDirection() LAYOUT_DIRECTION_RTL) { // 调整RTL布局下的按钮位置 } } }在实现这些功能时我遇到过一个棘手问题当文本中包含URL时点击事件会与展开按钮冲突。最终解决方案是通过自定义MovementMethod来区分点击区域。自定义控件的开发就是这样看似简单的需求背后往往隐藏着各种边界情况需要处理。