当年第一次在某同城App里看到那个"加载中"的逐字跳跃效果时,我第一反应是:这玩意儿做个动画不就完事了吗?后来真到自己项目里要复刻,才发现没那么简单——三个字一个接一个往上跳,节奏、插值、循环周期只要差一点,出来就是另一个感觉,要么跳得僵硬,要么循环有顿挫,要么在列表里一滚动就穿帮。这篇文章就把我从拆解到落地的完整过程讲清楚,包含两种常见实现方案、参数怎么调、以及上线前会踩的坑。
1. 拆解加载动画的三个核心变量
1.1 看起来花哨,底层其实只有三个参数
先把那个经典的"跳跃文字"效果慢放:一排文字里,第一个字忽然向上弹起,到达最高点后落回原位,与此同时第二个字接着跳,第三个字再跟上,循环往复。单个字的行为就是"上跳-落回-等待",多个字之间靠"延迟启动"拉开先后顺序。
做这个效果只需要三件事:
| 参数 | 作用 | 初始参考值 |
|---|---|---|
| 跳跃高度 | 每个字向上位移的距离,一般 12dp~20dp 方向感最明显 | 16dp |
| 单字动画时长 | 一个字完成"上跳+落回"的时间,太短像抽搐,太长像卡顿 | 300ms~400ms |
| 相邻字启动间隔 | 第 n 个字比第 n-1 个字晚多少毫秒开始跳,决定波浪错落感 | 60ms~100ms |
间隔和时长共同决定了一个完整视觉节奏。比如"加载中"三个字,单字起落 300ms,间隔 80ms,整个周期大约就是 460ms 加上短暂的停留,视觉上会形成一个很明显的"排队跳动"。
1.2 动画的本质是"位置随时间变化"
如果把这个效果落到技术层面,它其实只改了一个属性:每个字的Y 坐标偏移量。静止时所有字基线对齐,动画开始后,每个字根据自己所在的时间位置,从 0 向上偏移到 -jumpHeight,再回到 0。
用数学表达就是:某个字在时间 t 的偏移量 = -sin(该字当前局部进度 x π) x jumpHeight。这样设计的好处是,进度从 0 到 1 的过程中,sin 曲线会自然形成"0 → 1 → 0"的抛物线运动,对应"起跳 → 最高点 → 落回",中间没有生硬的折线。
想验证这个结论,可以在纸上画一个 sin 曲线,横轴是时间进度,纵轴是位移量,曲线平滑得像倒扣的碗。任何弹性跳法、过冲效果,都是在这条基本曲线上改出来的。理解了这条曲线,后面两种实现方案就都围绕它做文章。
2. 快速出稿:TextView 数组与 ObjectAnimator 组合
2.1 一个自带语义的容器布局
如果项目里就是想尽快看到一个能用的版本,不需要一上来就写自定义 View,用最基础的 TextView 数组完全够。核心思路非常简单:把"加载中"拆成单个字符,每个字符用一个 TextView 展示,排成水平一行,然后给每个 TextView 创建独立的位移动画。
我通常会写一个继承自 LinearLayout 的小容器,把这些细节封装起来,方便项目里任何页面直接复用。布局部分不需要写 XML,动态生成反而更灵活:
class JumpTextLoadingView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : LinearLayout(context, attrs, defStyleAttr) { private val charViews = mutableListOf<TextView>() private val animators = mutableListOf<ObjectAnimator>() init { orientation = HORIZONTAL gravity = Gravity.CENTER_VERTICAL } fun setLoadingText(text: String) { removeAllViews() charViews.clear() animators.clear() text.forEach { char -> val tv = TextView(context) tv.text = char.toString() tv.setTextColor(Color.parseColor("#666666")) tv.textSize = 16f addView(tv) charViews.add(tv) } } }这里用 LinearLayout 而不是 FrameLayout,是因为每个字符天然有自己的位置,LinearLayout 会自动帮我们把它们排开,不用自己计算坐标。如果字符之间想加大间距,给每个 TextView 加个 margin 就行了。
2.2 给每个字挂上自己的位移动画
接下来是核心代码。用 ObjectAnimator 操作View.TRANSLATION_Y属性,让每个字独立播放"上跳-回落"的位移动画:
fun startJump() { val duration = 300L val stagger = 80L val jumpHeight = dp(16f) charViews.forEachIndexed { index, tv -> val animator = ObjectAnimator.ofFloat(tv, View.TRANSLATION_Y, 0f, -jumpHeight).apply { this.duration = duration this.startDelay = index * stagger interpolator = AccelerateDecelerateInterpolator() repeatCount = ValueAnimator.INFINITE repeatMode = ValueAnimator.REVERSE } animators.add(animator) animator.start() } } fun stopJump() { animators.forEach { animator -> animator.cancel() (animator.target as? View)?.translationY = 0f } animators.clear() } private fun dp(value: Float): Float { return value * resources.displayMetrics.density }关键点在于repeatMode = ValueAnimator.REVERSE。它的语义是:动画播完一遍后,沿原路反向播回起点。配合startDelay,每个字在第一次启动时错开间隔,之后每 300ms 的动画正播一次、反播一次,形成刚好一个完整的"上跳-落回"循环。
2.3 为什么用 TRANSLATION_Y 而不是手动改坐标
这里有个重要的性能认知:translationY是 View 的一个渲染属性,修改它不会触发重新测量和重新布局,底层只是改了 RenderNode 的变换矩阵,动画过程中不需要走 measure/layout 流程,性能开销非常小。
如果用自定义属性或者直接改layoutParams.topMargin,每帧都要触发一次 measure 和 layout,在复杂页面里很容易掉帧。所以只要做这种"临时移动"的动效,优先考虑translationX/translationY。
在动手之前可以先用这个方案跑通效果,感受一下参数区间。实测下来,duration=300ms、stagger=80ms、jumpHeight=14dp~18dp是比较舒服的组合,如果文案有五六个字,stagger 可以适当调低到 60ms,否则整个循环会被拉长,用户等 loading 会焦虑。
3. 进阶方案:自定义 View 绘制逐字跳跃
3.1 为什么值得写一个自定义 View
TextView 数组方案虽然上手快,但有几个不好绕过的限制:
- 每个字符都是一个独立 View,一次动画要创建 N 个 ObjectAnimator,管理生命周期比较繁琐。
- 如果想把跳跃和透明度、颜色渐变联动,N 个动画之间的同步会变得复杂。
- 在某些对 View 层级敏感的场景(比如列表 item),多一个 View 就多一分渲染负担。
自定义 View 方案把所有字符绘制都收拢到一个 View 内部,外面看起来就是一个普通的加载组件,性能好,能力边界也更清楚。
3.2 onMeasure:先学会正确地"量"宽高
自定义 View 的第一步是把尺寸量准。文字组件的测量原则是:宽高等于文字本身的宽高加上 padding。代码里要同时处理wrap_content、match_parent和dp三种情况:
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val textWidth = (paddingLeft + paddingRight + textPaint.measureText(text)).toInt() val textHeight = (paddingTop + paddingBottom + ceil((fontMetrics.descent - fontMetrics.ascent).toDouble()).toInt()) val measuredWidth = when (MeasureSpec.getMode(widthMeasureSpec)) { MeasureSpec.EXACTLY -> MeasureSpec.getSize(widthMeasureSpec) MeasureSpec.AT_MOST -> min(textWidth, MeasureSpec.getSize(widthMeasureSpec)) else -> textWidth } val measuredHeight = when (MeasureSpec.getMode(heightMeasureSpec)) { MeasureSpec.EXACTLY -> MeasureSpec.getSize(heightMeasureSpec) MeasureSpec.AT_MOST -> min(textHeight, MeasureSpec.getSize(heightMeasureSpec)) else -> textHeight } setMeasuredDimension(measuredWidth, measuredHeight) }很多新手写自定义文字 View 时只在onDraw里画文字,wrap_content却变成了match_parent的效果,就是因为没重写onMeasure。测量代码看起来繁琐,但它决定了组件在布局里到底占多大空间,属于"不写不行"的部分。
3.3 onDraw:逐字计算坐标与跳跃偏移
绘制阶段的核心是:把每个字符的进度算出来,再根据进度计算 Y 偏移。整个绘制循环只用了一个 ValueAnimator 驱动,动画的当前进度换算成时间轴,再按索引错开:
class CharJumpView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : View(context, attrs, defStyleAttr) { private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL color = Color.parseColor("#666666") textSize = dp(16f) } private val fontMetrics = textPaint.fontMetrics private var text = "加载中" private var jumpHeight = dp(16f) private var charDuration = 300L private var staggerTime = 80L private var totalDuration = 0L private val animator = ValueAnimator.ofFloat(0f, 1f).apply { addUpdateListener { invalidate() } repeatCount = ValueAnimator.INFINITE } fun bindData(text: String, jumpHeight: Float, charDuration: Long, staggerTime: Long) { this.text = text this.jumpHeight = jumpHeight this.charDuration = charDuration this.staggerTime = staggerTime totalDuration = charDuration + (text.length - 1) * staggerTime animator.duration = totalDuration requestLayout() } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) val chars = text.toCharArray() if (chars.isEmpty()) return val baseline = (height - fontMetrics.ascent - fontMetrics.descent) / 2f var startX = (width - textPaint.measureText(text)) / 2f val currentTime = (animator.animatedValue as Float) * totalDuration for (i in chars.indices) { val charStr = chars[i].toString() val charWidth = textPaint.measureText(charStr) val progress = (currentTime - i * staggerTime) / charDuration val fraction = progress.coerceIn(0f, 1f) val offsetY = -sin(fraction * PI.toFloat()) * jumpHeight canvas.drawText(charStr, startX, baseline + offsetY, textPaint) startX += charWidth } } }3.4 需要重点解释的两行代码
第一处是 baseline 的计算。drawText的 y 参数是文本的基线位置,不是文字中心。如果直接把文字中心当作 y 传入,画出来的文字整体会往下偏。正确做法是用fontMetrics的ascent和descent算出基线,让文字上下居中。
第二处是 offsetY 的公式。sin(fraction * PI)在 fraction 从 0 到 1 的过程中,值先从 0 上升到 1,再回落到 0,取负号就变成了先向上偏移再落回原位,正好对应"跳起-落下"。
这个方案里整个动画只有一个 ValueAnimator 在跑,每帧回调一次invalidate(),触发onDraw重绘。相比 N 个 ObjectAnimator,这里省去了大量动画对象的管理成本,性能也更好。
4. 两个方案的选型边界与性能对比
4.1 一张表说清楚差异
很多刚接触这个效果的读者会纠结"到底用哪种方案",其实不用纠结,场景决定答案:
| 维度 | TextView 数组 + ObjectAnimator | 自定义 View |
|---|---|---|
| 实现速度 | 快,半小时内能跑起来 | 稍慢,涉及测量、绘制、动画驱动 |
| View 层级 | 每字一个 View,层级偏重 | 单一 View,无额外层级 |
| 动画个数 | N 个 ObjectAnimator | 1 个 ValueAnimator |
| 生命周期管理 | 需要逐个 cancel | 只需 cancel 一个 |
| 支持字符类型 | 中文、英文、emoji 都稳 | 普通文本没问题,emoji 需额外处理 |
| 定制扩展 | 改动分散,联动复杂 | 集中在 onDraw,方便扩展 |
| 适用场景 | 快速验证、低频率页面 | 列表复用、长时间 loading、复杂动效 |
4.2 列表和页面复用的场景选择
如果这个加载文字会出现在复用型的 UI 容器里,比如列表的 header、底部加载提示,甚至被 RecyclerView item 引用,我强烈建议直接上自定义 View。原因很简单:一个 View 的创建和回收成本,永远比 N 个 View 低。
另一个值得考虑的点是 emoji。TextView 数组方案天然继承了对 emoji 的兼容能力,文本拆成单字后每个 TextView 自己搞定渲染;自定义 View 方案里如果直接用canvas.drawText画复合 emoji,会遇到字体回退、组合字符绘制不全等问题。所以你的 loading 文案如果确定是纯中文或纯英文,自定义 View 没问题;如果有娱乐化表达需求,可能还是得多留一手。
4.3 设置不可复用时的建议
实际开发里,一个页面可能同时出现好几处加载状态。我一般会把自定义 View 版本抽成一个独立组件,把文案、字号、跳跃高度、节奏全部做成可配置的属性,这样设计稿改一次,业务层不用动代码。TextView 数组方案更适合你只想快速看效果、或者用于一次性接线的场景。
5. 上线前必须避开的四个坑
5.1 动画停不下来的生命周期问题
这是最常见的坑。Activity 退到后台后,动画如果不主动停,View 还在被反复invalidate,CPU 和电量都会白白消耗。自定义 View 方案里要监听生命周期:
override fun onVisibilityChanged(changedView: View, visibility: Int) { super.onVisibilityChanged(changedView, visibility) if (visibility != VISIBLE) { animator.cancel() } else if (!animator.isStarted) { animator.start() } }TextView 数组方案更麻烦一点,需要在stopJump()里逐个 cancel 并且把所有 View 的translationY复位为 0。别只 cancel 不复位,否则下次启动时会发现文字从奇怪的位置开始跳。
5.2 字体基线与文字上移错位的坑
自定义 View 绘制时,一个特别隐蔽的问题是用错了drawText的 y 坐标。有些开发者在onDraw里直接用height / 2作为 y,结果中文文字整体偏下,动画跳跃的时候错位感特别明显。
正确做法是计算 baseline,并注意中文和英文的 fontMetrics 不同。如果一个 loading 文案混排了"加载中"和英文字母,建议测量时统一用Paint.textSize对齐,不要给不同字符设置不同字号,否则视觉效果会参差不齐。
5.3 循环动画顿挫感:总周期不等于单字时长
很多人在自定义 View 里会图省事,让 ValueAnimator 的时长直接等于单字动画时长,结果整个循环看起来像"卡了一拍"。
原因在于:总周期必须覆盖所有字的错开启动时间。比如单字 duration=300ms,stagger=80ms,5 个字时最后一个字在 320ms 才开始跳,如果整个 animator 只跑了 300ms,最后一个字永远没有机会跳起来。
正确的总周期公式是:
总周期 = 单字动画时长 + (字符数 - 1) × 相邻字启动间隔这段周期跑完,最后一个字刚好落回原位,无缝衔接下一轮循环,视觉上才不会有"顿挫"。
5.4 列表复用场景下的 translationY 残留问题
如果用 TextView 数组方案且动画没有正确停止,当 View 被 RecyclerView 复用时,新的内容绑定上来时translationY还停留在某个位置,就会出现文字"错位闪现"。
解决思路有两个:要么在onViewRecycled里强制将所有 TextView 的translationY归零,要么干脆用自定义 View 方案,从根上避免多 View 动画状态泄露。我在项目中两种方案都用过,涉及列表时无一例外选择自定义 View。
6. 从跳跃到波浪:让加载动画更耐看
6.1 从中间向两侧扩散的波浪入场
经典的逐字左到右跳跃看久了会有点单调。一个马上能用的变化是"从中间向两侧扩散",效果是先跳中间的字,再往两边依次扩散,看起来像是水面泛起的涟漪。
实现上只需调整每个字的启动间隔,不再按索引顺序线性延迟,而是按字符到中间位置的距离计算:
val midIndex = text.length / 2 val delay = abs(i - midIndex) * staggerTime val progress = (currentTime - delay) / charDuration配合这个延时公式,在onDraw里绘制的代码几乎不用改,视觉风格却完全不一样。实测在"加载中"这类 3~7 字的文案上,这种扩散式动画比线性跳跃更耐看,适合作为页面主 loading 展示。
6.2 加入透明度与颜色渐变
如果想让动画的层次感更强,可以在保持 Y 偏移逻辑不变的基础上,叠加两个辅助效果。一个是透明度跟随跳跃进度变化,起跳瞬间略微变淡,最高点恢复,落回时再略微变淡;另一个是颜色从灰色渐变为主题色,让跳动的字有一点"强调"的感觉。
val alphaFactor = 1f - 0.3f * sin(fraction * PI.toFloat()) textPaint.alpha = (255 * alphaFactor).toInt() textPaint.color = ArgbEvaluator().evaluate( sin(fraction * PI.toFloat()).coerceIn(0f, 1f), normalColor, highlightColor ) as Int注意textPaint.alpha每次绘制后要恢复成 255,否则下一个字会被上一个字的 alpha 污染,越画越淡。
6.3 封装一个可配置的 WaveTextLoadingView
最终我在项目里沉淀下来的版本,是一个可配置的自定义 View,支持通过自定义属性设置文案、字号、颜色、跳跃高度、单字时长、字间隔、扩散方向。使用方只要在布局里写一行app:loadingText="加载中",再调start()和stop()就行。
这样做的好处在于,当设计稿把 loading 从 3 个字改成 4 个字,或者把跳跃高度调大、颜色换成品牌色时,业务代码完全不用动,只改布局参数,测试成本大幅降低。组件内部仍然只有一个 ValueAnimator,一个 onDraw,一套计算逻辑,维护起来非常轻松。
如果你手头正好有类似需求,我比较推荐先跑通 TextView 数组方案感受节奏,然后把效果图确认下来,最后迁到自定义 View 版本。节奏参数记住一个经验范围:中文字体下跳跃高度 14dp~18dp,单字时长 300ms 左右,相邻间隔 60ms~90ms,基本不会翻车。