/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}
本文基于一个真实 MVVM 项目(Kotlin + ViewBinding),完整讲解如何手写一个\”长得像 Toast、但完全可控、还能跨进程调用\”的消息提示组件。 全文采用 代码 + 逐行注释 的方式展开,所有代码均可直接运行。
一、为什么要自己写 Toast
系统 Toast 有几个绕不开的限制:
| 样式不可控 | 只能改文字,背景、圆角、字体都要靠反射或自定义 View 塞进去 |
| 动画不可控 | 系统固定淡入淡出,无法定制 |
| Android 11+ 限制 | 后台弹出自定义 Toast 被限制,Toast.setView() 已废弃 |
| 多进程无解 | 后台进程(推送/下载/播放)没有 Activity,根本没法往主进程页面上弹东西 |
所以我们的目标是一个同时满足下面几点的组件:
二、整体架构
┌──────────────────────── 主进程(UI 进程)────────────────────────┐
│ │
│ Activity │
│ └── DecorView(整个窗口的根 View) │
│ └── ToastView ← 自定义提示,直接 addView 挂上来 │
│ │
│ ToastManager(单例,对外统一入口) │
│ ├── ActivityLifecycleCallbacks → 记住\”当前前台是谁\” │
│ ├── pendingMessage → App 在后台时的消息缓存 │
│ ├── pendingByClass → 指定页面但页面不在前台时的缓存 │
│ └── ToastIPCClient │
│ ├── bindService(ToastService) │
│ ├── IToastService.showToast(msg) ──────────────┐ │
│ └── IToastCallback.onToastReceived(msg) ←──────┼─┐ │
└────────────────────────────────────────────────────────────┼─┼───┘
│ │
Binder(双向 IPC) │ │
│ │
┌──────────────────────── :remote 进程 ──────────────────────┼─┼───┐
│ ToastService │ │ │
│ └── RemoteCallbackList<IToastCallback> ←──────────────┘ │ │
│ └── dispatch() 把消息广播给所有注册过的客户端 ──────┘ │
└──────────────────────────────────────────────────────────────────┘
设计上刻意模仿系统的 NotificationManagerService:消息不是直接画出来的,而是先发给一个\”服务\”,再由服务回调回来渲染。这样后台进程只需 show() 一下,UI 进程自然就弹了。
三、第一层:自定义 View —— ToastView
这是最基础的\”长得像 Toast 的东西\”。
3.1 圆角背景
<!– res/drawable/bg_toast_view.xml –>
<?xml version=\”1.0\” encoding=\”utf-8\”?>
<shape xmlns:android=\”http://schemas.android.com/apk/res/android\”
android:shape=\”rectangle\”>
<!– CC = 80% 不透明度,黑底白字,和系统 Toast 观感接近 –>
<solid android:color=\”#CC000000\” />
<!– 圆角半径,让提示看起来不那么生硬 –>
<corners android:radius=\”20dp\” />
</shape>
3.2 View 本体
package com.jzh.mvvm.ui.view
import android.content.Context
import android.graphics.Color
import android.util.AttributeSet
import android.view.Gravity
import android.widget.FrameLayout
import android.widget.TextView
import com.jzh.mvvm.R
/**
* 自定义 Toast 提示 View
*
* 设计要点:
* – 纯代码构建,不依赖任何 layout.xml,可以直接 addView 到任意 ViewGroup
* – 圆角半透明黑底 + 白字,视觉与系统 Toast 接近
* – 淡入 -> 停留 -> 淡出,动画结束后通过 onDismiss 通知外部移除自己
* – 自身不消费触摸事件,不会挡住下层页面的点击
*/
class ToastView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
private val tvMessage: TextView
private var dismissRunnable: Runnable? = null
/** 淡出动画结束的回调:外部在这里把自己从父容器移除 */
var onDismiss: (() -> Unit)? = null
init {
// 关键:不拦截触摸事件。
// 否则这块区域会吃掉点击事件,用户点不到下面的列表
isClickable = false
tvMessage = TextView(context).apply {
setTextColor(Color.WHITE)
textSize = 14f
gravity = Gravity.CENTER
setBackgroundResource(R.drawable.bg_toast_view)
// 限制最大宽度:长文案会自动换行,不会撑满整屏
maxWidth = (260 * resources.displayMetrics.density).toInt()
val pad = (12 * resources.displayMetrics.density).toInt()
setPadding(pad * 2, pad, pad * 2, pad)
}
addView(
tvMessage,
LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, Gravity.CENTER)
)
// 初始透明,等 show() 时再做淡入
alpha = 0f
}
fun setMessage(text: CharSequence) {
tvMessage.text = text
}
/**
* 开始显示:淡入 -> 停留 duration 毫秒 -> 淡出
*/
fun show(duration: Long = DEFAULT_DURATION) {
// 重复调用时,先把上一条的定时消失任务取消掉,避免叠加
removeCallbacks(dismissRunnable)
animate().cancel()
// 每次都从\”透明 + 下移 40dp\”开始,保证动画起点一致
alpha = 0f
translationY = 40f
animate()
.alpha(1f)
.translationY(0f)
.setDuration(ANIM_DURATION)
.start()
// 定时执行淡出
dismissRunnable = Runnable {
dismiss() }
postDelayed(dismissRunnable, duration)
}
private fun dismiss() {
animate()
.alpha(0f)
.translationY(40f)
.setDuration(ANIM_DURATION)
// withEndAction:动画真正结束后才回调,此时移除 View 才安全
.withEndAction {
onDismiss?.invoke() }
.start()
}
override fun onDetachedFromWindow() {
// 页面销毁 / 被外部移除时,务必取消定时任务,
// 否则 Runnable 会持有本 View 引用直到触发,造成短时间泄漏
removeCallbacks(dismissRunnable)
super.onDetachedFromWindow()
}
companion object {
private const val ANIM_DURATION = 250L
private const val DEFAULT_DURATION = 2



