如何看懂 Plaid 的线程化评论区:ThreadedCommentDrawable 自定义绘制原理全解
【免费下载链接】plaidAn Android app which provides design news & inspiration as well as being an example of implementing material design.项目地址: https://gitcode.com/gh_mirrors/pl/plaid
Plaid 是一款开源的 Android 设计资讯应用,也是 Material Design 的经典实现范例。它的评论区里有一个不起眼却非常巧妙的设计:每条评论左侧的竖向细线,直观地表达出回复的嵌套层级——回复几层,就画几条线。这就是 Plaid 线程化评论区的核心。本文将带你从零看懂这个效果的实现:从 UI 布局到ThreadedCommentDrawable这个自定义 Drawable 的完整绘制原理,全程不需要深厚基础,跟着源码走一遍即可。
为什么用"线条"来表示回复层级?
打开一篇 Designer News 文章,你会看到评论呈阶梯状排列:
- 一级评论靠左;
- 二级回复缩进一级,左侧多出一条细线;
- 三级回复再缩进,细线变成两条……
这比传统的"整块缩进"更省空间,也比"连接线"更轻快。整个方案只依赖三个文件,非常值得学习:
| 文件 | 职责 |
|---|---|
| ThreadedCommentDrawable.kt | 自定义 Drawable,按层级画出 N 条竖线 |
| CommentViewHolder.kt | 把 Drawable 绑定到每条评论上 |
| designer_news_comment.xml | 评论布局,承载"深度指示器"的 ImageView |
ThreadedCommentDrawable:不到 100 行的绘制核心
ThreadedCommentDrawable继承自 Android 的Drawable,构造时只需要两个参数:
threadWidth:单条线的宽度(像素)gap:相邻线之间的间距(像素)
这两个值来自全局尺寸资源 core/src/main/res/values/dimens.xml:
<dimen name="comment_thread_width">4dp</dimen> <dimen name="comment_thread_gap">4dp</dimen>第一条线:画笔只画竖线
内部用一个Paint,样式为STROKE(描边),strokeWidth等于线宽,颜色初始是一个占位色(0xffff00ff亮粉色)——真正的显示颜色后面会靠 tint 覆盖:
private val paint = Paint().apply { style = Paint.Style.STROKE strokeWidth = threadWidth.toFloat() color = THREAD_COLOR }第二条线:setDepth 决定画几条线
fun setDepth(depth: Int) { this.threads = depth + 1 invalidateSelf() }注意这里threads = depth + 1:根评论(depth 为 0)也画 1 条线,回复层级每加 1,线就加 1 条。invalidateSelf()通知系统重绘,这是自定义 Drawable 中"状态变了要重画"的标准姿势。
第三条线:draw() 里的关键一行
override fun draw(canvas: Canvas) { for (thread in 0 until threads) { val left = halfThreadWidth + thread * (threadWidth + gap) canvas.drawLine(left.toFloat(), 0f, left.toFloat(), bounds.bottom.toFloat(), paint) } }逐层拆解:
halfThreadWidth + thread * (threadWidth + gap):第thread条线的横坐标 = 半个线宽(让第一条线完整落在画布内)+ 递增的偏移。于是线与线之间恰好相隔threadWidth + gap,均匀排开;canvas.drawLine(left, 0f, left, bounds.bottom, paint):从顶部画到bounds.bottom,即整条评论的高度。线会自动"长"到和评论一样高,无需额外计算文本高度——这是用 Drawable 而非在 Adapter 里手动测量的好处。
第四条线:getIntrinsicWidth 让 ImageView 自适应
override fun getIntrinsicWidth(): Int { return threads * threadWidth + (threads - 1) * gap }这个返回值就是布局中 ImageView 的实际宽度:线越多,指示器越宽。也就是说,评论的缩进量是由 Drawable 自己算出来的,布局侧只需要wrap_content,完全不用关心层级。
深色/浅色模式:tint 的正确打开方式
你可能注意到了,源码里的线是亮粉色,实际界面上却是淡淡的灰。秘密在于布局 designer_news_comment.xml 中 ImageView 的这一行:
android:tint="@color/thread_depth"而 thread_depth.xml 定义的是colorOnSurface主题色 + 8% 透明度:
<item android:color="?attr/colorOnSurface" android:alpha="0.08" />Drawable 一侧则重写了setTintList,用SRC_IN模式把占位色替换为主题色:
override fun setTintList(tint: ColorStateList?) { if (tint != null) { setColorFilter(tint.defaultColor, SRC_IN) } else { clearColorFilter() } }这样白天是浅灰线、夜间是深色下的微亮线,颜色跟随 Material 主题自动切换,Drawable 本身一行颜色逻辑都不用改。
装配现场:CommentViewHolder 如何绑定
CommentViewHolder.kt 的bind()方法是整个链条的收尾:
fun bind(model: CommentUiModel) { ... threadDepth.setImageDrawable( ThreadedCommentDrawable(threadWidth, threadGap, model.depth) ) }每次绑定都用评论模型 CommentUiModel.kt 里的depth新建一个 Drawable。这里有一个值得新手注意的取舍:直接 new 而不是复用旧 Drawable。因为bind()只在可见项上执行,数量可控,换来的是逻辑简单、没有"忘记 setDepth"导致的层级错乱风险。
另外,这个 ViewHolder 还承担了一个有趣的交互:长按某条评论进入"聚焦模式"时,setExpanded()会利用drawable.intrinsicWidth把线滑出屏幕、把文字左移填满宽度——也就是说,同一个 Drawable 的固有宽度,既用于日常布局,也用于动画位移量的计算,一举两得。
自己动手:把这套方案搬进你的项目
如果你的 App 也有楼中楼评论,可以按这个清单照抄思路:
- 写一个继承
Drawable的类,draw()里按"层级"循环画竖线,关键是把线的数量抽象为setDepth(); getIntrinsicWidth()返回"线数 × 线宽 + 间距",让wrap_content的 ImageView 自动变宽;- 布局里给 ImageView 设置
android:tint指向一个带透明度的主题色资源,实现昼夜自适应; - ViewHolder 绑定时用模型的
depth字段重建 Drawable,保持"数据 → 宽度 → 缩进"的单源。
小结
Plaid 的线程化评论区看似只是一排细线,实际上把自定义 Drawable、intrinsic 尺寸、tint 主题色、ViewHolder 复用四个知识点串成了一条完整链路:
draw()中一行等差数列算出每条线的位置;getIntrinsicWidth()让层级数自动变成缩进量;setTintList()+ 主题色让细线在深浅色模式下都恰到好处。
源码总共不到 200 行(含注释),是学习"自定义 Drawable 怎么做才优雅"的极佳样本。建议对照 designernews/src/main/java/io/plaidapp/ui/drawable/ 目录边读边改,几分钟就能在自己的项目里画出一模一样的效果。
【免费下载链接】plaidAn Android app which provides design news & inspiration as well as being an example of implementing material design.项目地址: https://gitcode.com/gh_mirrors/pl/plaid
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考