ExpandableTextView 源码逐行精讲:400 行代码如何实现完整展开收起交互
【免费下载链接】ExpandableTextViewAndroid TextView that can expand and collapse.项目地址: https://gitcode.com/gh_mirrors/expand/ExpandableTextView
ExpandableTextView 是一款开源的 Android 展开收起文本控件(Expandable TextView),让开发者用一行setText就能给长文本加上"展开 / 收起"按钮和流畅的高度动画。全文约 400 行的 ExpandableTextView.java 核心源码中,隐藏着几个非常实用的 Android 技巧:二次测量判断文本高度、Animation做高度动画、onInterceptTouchEvent防连点。本文将逐段拆解它的实现思路,新手也能轻松读懂。
1. 效果一览:它能做什么
从上面的演示动图可以看到它的完整交互:
- 文本超过设定行数时,自动截断并显示「展开」按钮;
- 点击后以 300ms 高度动画展开全文,按钮变为「收起」;
- 支持在
ListView列表项中使用,且每项的展开状态互不干扰。
整个控件对外暴露的能力只有两行核心 API(见 ExpandableTextView.java 第 252~269 行):
public void setText(@Nullable CharSequence text) // 带列表位置参数,用于 ListView 场景 public void setText(CharSequence text, SparseBooleanArray collapsedStatus, int position)2. 整体结构:一个 LinearLayout 装两个 TextView
控件继承自LinearLayout(强制竖直方向),内部通过<merge>布局装了两个TextView(布局文件:expandabletextview/src/main/res/layout/expandabletextview.xml):
| 成员变量 | 角色 |
|---|---|
mTv | 显示正文内容的 TextView |
mStateTv | 显示「展开 / 收起」文字 + 图标的按钮 |
// 构造时直接 inflate 内部布局,强制竖直排列 LayoutInflater.from(context).inflate(R.layout.expandabletextview, this, true); setOrientation(LinearLayout.VERTICAL); setVisibility(GONE); // 默认隐藏,setText 后才显示💡 细节:
setOrientation被重写,传入横向布局会直接抛异常——作者从 API 层面杜绝了误用。
3. 自定义属性速查:11 个 XML 属性一次配置
所有可配置项都定义在expandabletextview/src/main/res/values/attrs.xml,通过TypedArray在init()中统一读取并赋默认值:
<me.chensir.expandabletextview.ExpandableTextView app:maxCollapsedLines="4" <!-- 收起时最多显示几行(默认 8) --> app:animDuration="300" <!-- 动画时长(默认 300ms) --> app:contentTextSize="16sp" <!-- 正文字号 --> app:contentTextColor="@color/blue" <!-- 正文颜色 --> app:contentLineSpacingMultiplier="1.2" app:expandDrawable="@drawable/..." <!-- 展开按钮图标 --> app:collapseDrawable="@drawable/..." app:expandText="更多" <!-- 按钮文案 --> app:collapseText="收起" app:expandCollapseTextColor="@color/pink" app:DrawableAndTextGravity="left" <!-- 按钮位置:left/center/right --> />源码中「取属性 → 判空 → 用默认值兜底」的写法(如R.string.expand_string、R.drawable.ic_expand_more_black_12dp)是自定义 View 处理属性的标准范式,值得模仿。
4. 核心逻辑 ①:onMeasure 如何判断"文本太长"
判断是否需要收起,作者没有用字符串长度估算,而是在onMeasure中做了两次测量(源码 206~246 行):
// 第一次:乐观测量——假设文本完全放得下 mStateTv.setVisibility(View.GONE); mTv.setMaxLines(Integer.MAX_VALUE); super.onMeasure(widthMeasureSpec, heightMeasureSpec); // 行数没超过上限 → 不需要按钮,直接返回 if (mTv.getLineCount() <= mMaxCollapsedLines) { return; } // 确实超长:记下完整文本高度,再收起测量一次 mTextHeightWithMaxLines = getRealTextViewHeight(mTv); if (mCollapsed) { mTv.setMaxLines(mMaxCollapsedLines); } mStateTv.setVisibility(View.VISIBLE); super.onMeasure(widthMeasureSpec, heightMeasureSpec); mCollapsedHeight = getMeasuredHeight();这个"先放开、再收起"的测量策略保证了:
- 短文本不会出现多余的「展开」按钮;
mTextHeightWithMaxLines(完整高度)与mCollapsedHeight(收起高度)两个动画所需数值都被精确拿到。
mRelayout标志位配合setText(),确保只有文本变化时才重新执行这套测量,避免无谓开销。
5. 核心逻辑 ②:onClick 中的状态切换
点击正文或按钮都会走到onClick(源码 137~190 行),逻辑清晰分四步:
mCollapsed = !mCollapsed; // 1. 翻转状态 mStateTv.setText(mCollapsed ? mExpandString : mCollapsedString); // 2. 换文案 mStateTv.setCompoundDrawablesWithIntrinsicBounds( mCollapsed ? mExpandDrawable : mCollapseDrawable, null, null, null); // 3. 换图标 // 4. 创建动画:收起 → 目标 mCollapsedHeight;展开 → 目标 = 当前高 + 超出部分 Animation animation = mCollapsed ? new ExpandCollapseAnimation(this, getHeight(), mCollapsedHeight) : new ExpandCollapseAnimation(this, getHeight(), getHeight() + mTextHeightWithMaxLines - mTv.getHeight());动画结束后的收尾也很讲究:
public void onAnimationEnd(Animation animation) { clearAnimation(); // 清除动画,防止 applyTransformation 被反复调用 mAnimating = false; if (mListener != null) { mListener.onExpandStateChanged(mTv, !mCollapsed); // 回调通知业务层 } }通过setOnExpandStateChangeListener()注册的回调在动画结束后触发,业务侧可以安全地基于展开状态做埋点或联动。
6. 核心逻辑 ③:ExpandCollapseAnimation 的高度插值
这是全项目最精华的约 30 行——一个只改"高度"的Animation内部类(源码 364~393 行):
protected void applyTransformation(float interpolatedTime, Transformation t) { // 在 startHeight 与 endHeight 之间按插值因子线性计算高度 final int newHeight = (int) ((mEndHeight - mStartHeight) * interpolatedTime + mStartHeight); mTv.setMaxHeight(newHeight - mMarginBetweenTxtAndBottom); // 约束正文可见高度 mTargetView.getLayoutParams().height = newHeight; // 同时压住整个容器 mTargetView.requestLayout(); }两点设计巧思:
setMaxHeight与容器高度双管齐下:正文用maxHeight裁剪,容器用固定height布局,动画过程中布局不跳动、不遮挡下方控件;willChangeBounds()返回true:明确告诉系统动画会改变视图尺寸,让父布局正确响应。
注意:这里的"动画"本质是高度渐变 + 逐帧重布局,所以性能关键在
requestLayout()的开销。默认 300ms 的时长就是流畅性与开销的平衡点。
7. 实战细节三连:防连点、列表状态、空文本
源码里还有三处容易被忽略的健壮性设计:
- 动画期间拦截所有触摸——避免连点导致动画叠加错乱:
@Override public boolean onInterceptTouchEvent(MotionEvent ev) { return mAnimating; // 动画进行中:拦截全部触摸事件 }- ListView 状态保持——
setText(text, collapsedStatus, position)重载把每项的收起状态存进SparseBooleanArray,ViewHolder 复用时仍能还原上次的展开/收起状态(示例用法见sample/src/main/java/me/chensir/expandabletextview/sample/SampleTextListAdapter.java); - 空文本自动隐藏——
setText中对空文本直接setVisibility(GONE),避免空控件占据布局空间。
8. 三步快速上手:3 行代码接入
- 将
expandabletextview模块导入你的 Android 工程; - 布局 XML 中声明控件并配置属性(参考
sample/src/main/res/layout/activity_main.xml); - 在 Activity 中设置文本即可(参考
sample/src/main/java/me/chensir/expandabletextview/sample/MainActivity.java):
((ExpandableTextView) findViewById(R.id.tv)).setText(longText);如果想在自己的项目中完整研读这份源码,可以克隆仓库后打开expandabletextview/src/main/java/me/chensir/expandabletextview/ExpandableTextView.java,对照本文的四个小节顺序(结构 → 属性 → 测量 → 动画)阅读,效率最高:
git clone https://link.gitcode.com/i/c916707246c5538c1be63a6a03e32326.git总结:400 行代码的精髓
| 技巧 | 位置 | 可复用场景 |
|---|---|---|
| 二次 onMeasure 判断内容溢出 | 第 206 行 | 任何"内容自适应"控件 |
| 高度插值 Animation | 第 364 行 | 展开面板、评论区折叠 |
| onInterceptTouchEvent 防连点 | 第 193 行 | 一切带动画的按钮 |
| SparseBooleanArray 保存列表状态 | 第 258 行 | ListView 中保持子视图状态 |
ExpandableTextView 虽然已停止维护,但它的实现模式至今仍是 Android 自定义 View 教学的优质范本——读懂这 400 行,你就掌握了"展开收起交互"背后的全部核心知识。
【免费下载链接】ExpandableTextViewAndroid TextView that can expand and collapse.项目地址: https://gitcode.com/gh_mirrors/expand/ExpandableTextView
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考