当年某分类信息App的Loading动画,一排字在屏幕里蹦蹦跳跳,还带点错开的时间差,看着特别魔性。虽然现在很多App都换成了转圈或者logo动画,但那种“文字跳跃”的效果放在今天依然不过时,尤其是配合“加载中”“努力中”这类短文案,比一张静态图有味道多了。
这个效果在Android上实现起来并不复杂,核心就一个自定义View,加上一段属性动画。麻烦的地方在于细节:文字怎么居中不抖、相邻字的节奏怎么错开、动画停了之后怎么恢复、退到后台会不会一直空转。这些坑我调的时候都踩过一遍,这篇就把整个实现思路和代码梳理完整,从原理到封装一步步来,照着写基本能直接放进项目里。
适合看这篇的,一是项目里缺一个不引入第三方库的轻量Loading,二是想练手自定义View和ValueAnimator配合的人。不需要太多前置知识,理解Canvas怎么画字,就能跟得下来。
1. 动画原理拆解:文字跳跃到底是哪几个效果叠出来的
1.1 一个“跳”字,拆开看就是位移
先把效果拆到最小单元。屏幕上一共有N个字,每个字单独看,做的事情特别简单:从原始位置往上跳一下,再落回来,循环往复。这个“往上跳一下再落回来”,在Android的代码层面就是一个关于时间t的正弦波函数。
[ \text{dy}(t) = \text{jumpHeight} \times \sin(\text{progress} \times 2\pi) ]
progress从0走到1,sin值从0到正峰值再回到0再到负峰值再回0。由于我们只关心“往上跳”,所以通常不让sin低于0,或者直接用绝对值处理,但实际做出来效果你会发现,纯正弦也挺自然。看个人喜好,后面进阶部分会提几种变体。
所以每一帧要做的事,就是把每个字符的y坐标偏移量算出来,然后调用Canvas.drawText画上去。看起来像动画,本质是每帧重新绘制。
1.2 为什么是“波浪”而不是“齐步走”:错峰相位
如果所有字都同时往上升再同时落下来,那画面就很傻,像军训齐步走。原版那个Loading好看,关键在于相邻字之间有时间差,第一个字跳上去,第二个字才刚开始动,第三个字再晚一点,形成一种波浪推进的感觉。
这个“时间差”在代码里就是给每个字符加一个初始偏移:
[ \text{phase}_i = \text{progress} + i \times \text{phaseOffset} ]
i是字符下标,phaseOffset是每个字符错开的比例,通常取0.2到0.4之间。phaseOffset越大,波浪感越强,但也可能让整体看起来乱,需要实际调节。后面参数设计那节会细说。
1.3 方案对比:一串TextView还是一个自定义View
很多人第一反应是放三个TextView,每个start一个postDelayed,循环执行一个位移动画。这样做能出效果,但问题也明显:
- 每个TextView要单独管理动画,代码碎片化。
- 动画过程中回调要自己算偏移量,稍不注意就会出现跳变。
- 横竖屏切换、生命周期停止恢复,每个TextView都要同步状态,很麻烦。
用自定义View方案呢?只需要一个View,所有字符统一绘制,动画统一用一个ValueAnimator驱动。不管是起始、暂停、停止还是修改文字,都只需要一个入口。而且代码写完之后是一个独立控件,复制到别的项目也能直接用。
| 维度 | 多个TextView方案 | 自定义View方案 |
|---|---|---|
| 动画同步 | 需要手动协调多个动画 | 单动画驱动,天然同步 |
| 布局管理 | 需要包裹容器 | 一个View自带测量 |
| 性能开销 | 多个View实例 | 单View绘制 |
| 可复用性 | 需要重复配置 | 封装成独立类 |
从工程角度看,自定义View是这个场景更合适的选择。
2. 参数设计:写代码前先把这4个值定下来
2.1 核心参数:基准线、跳跃高度、相位偏移
动手写代码之前,先把参数定清楚。我实现时用到了这几个:
- text:要显示的文本,比如“加载中”。
- textSize:字体大小。建议用sp,方便跟随系统字体缩放。
- jumpHeight:跳跃高度,单位建议用dp,体验上10dp左右最舒服,太大容易显得夸张。
- phaseOffset:相邻字符的相位差,单位是“个周期”。0.25f表示第二个字比第一个字晚四分之一个周期。
其中跳跃高度和相位偏移是最影响观感的两个参数。跳跃高度太低没动感,太高又显得浮夸。实际项目里我一般把jumpHeight控制在8dp到12dp之间,phaseOffset控制在0.2f到0.35f之间。
2.2 动画节奏:时长、循环模式和插值器怎么选
动画时长也很有讲究。900毫秒到1200毫秒是比较舒服的范围,太短了像抽搐,太长了让人觉得加载很慢。
循环模式有两种:
- RESTART:progress从0跑到1,然后瞬间回到0再跑一遍。视觉上是每个字跳完一轮重新再来,适合模拟“不断努力”的加载感。
- REVERSE:progress从0跑到1之后再从1跑回0。视觉上像弹簧来回弹,更柔和,但和“跳跃”的感觉有一点差异。
我建议用RESTART,因为这个效果本来就是模拟连续原地跳,不是往返运动。
插值器其实是个容易忽略的点。很多人直接默认用线性插值,效果也能看,但如果你想要“跳起来之后带一点减速落地”的感觉,可以考虑用DecelerateInterpolator配合分段函数。这个后文变体部分给出具体思路。
2.3 宽度和高度怎么量:measureText不是万能的
自定义View的测量阶段容易翻车。文字宽度计算不难,用Paint.measureText就能拿整段文本宽度。但要注意:我们绘制时是按单字符画的,如果中文还好,如果遇到emoji或者其他组合字符,单字符宽度和整串宽度会有偏差。
高度计算就更讲究一点。文字的物理高度不能简单拿textSize去乘,因为textSize实际只是字号的标称值。要拿Paint.FontMetrics里的ascent和descent来算:
[ \text{contentHeight} = \text{descent} - \text{ascent} ]
ascent是负值,descent是正值,两者相减得到的就是文字从最高点到最低点的真实高度。再加上跳跃高度在上下两个方向各占一份,就是整个View需要的最小高度。
3. 实操编码:从空View到一个能跑的JumpTextView
3.1 准备:attrs属性、Paint和基础字段
新建一个JumpTextView类,继承View。为了不每次在XML里写满参数,先定义一组自定义属性,方便调用方配置。
先写attrs.xml:
<declare-styleable name="JumpTextView"> <attr name="jumpText" format="string" /> <attr name="jumpTextSize" format="dimension" /> <attr name="jumpTextColor" format="color" /> <attr name="jumpHeight" format="dimension" /> <attr name="jumpDuration" format="integer" /> <attr name="jumpPhaseOffset" format="float" /> </declare-styleable>然后是类的骨架:
class JumpTextView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : View(context, attrs, defStyleAttr) { private val paint = Paint(Paint.ANTI_ALIAS_FLAG) private var text = "加载中" private var textSize = 16f.dp private var textColor = 0xFF666666.toInt() private var jumpHeight = 10f.dp private var duration = 900L private var phaseOffset = 0.25f private var charWidths = floatArrayOf() private var progress = 0f private var animator: ValueAnimator? = null init { paint.textSize = textSize paint.color = textColor paint.textAlign = Paint.Align.LEFT // 先手动给个默认尺寸,稍后在 onMeasure 里再校准 } }dp扩展示意:
private val Float.dp: Float get() = this * resources.displayMetrics.density如果要支持从XML读取属性,在构造函数里解析typArray就行:
init { val ta = context.obtainStyledAttributes(attrs, R.styleable.JumpTextView) text = ta.getString(R.styleable.JumpTextView_jumpText) ?: "加载中" textSize = ta.getDimension(R.styleable.JumpTextView_jumpTextSize, 16f.dp) textColor = ta.getColor(R.styleable.JumpTextView_jumpTextColor, 0xFF666666.toInt()) jumpHeight = ta.getDimension(R.styleable.JumpTextView_jumpHeight, 10f.dp) duration = ta.getInt(R.styleable.JumpTextView_jumpDuration, 900).toLong() phaseOffset = ta.getFloat(R.styleable.JumpTextView_jumpPhaseOffset, 0.25f) ta.recycle() ... }这个阶段没什么技术难点,但要注意属性recycle,不然会有lint警告。
3.2 onMeasure:让加载文字在布局里不大不小刚刚好
现在处理测量。View首次放进布局时,系统会问它想占多大。我们的目标是:宽度等于文本本身宽度加上左右padding,高度等于文本高度加上两倍跳跃高度再加上下padding。
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val fm = paint.fontMetrics val sumCharWidths = charWidths.sum() val contentWidth = (sumCharWidths + paddingLeft + paddingRight).toInt() val contentHeight = ceil( ((fm.descent - fm.ascent) + jumpHeight * 2 + paddingTop + paddingBottom).toDouble() ).toInt() setMeasuredDimension( resolveSize(contentWidth, widthMeasureSpec), resolveSize(contentHeight, heightMeasureSpec) ) }resolveSize的意思是:如果父布局给的是wrap_content,就采用我们算出来的值;如果父布局给的是固定的match_parent,则采用父布局的限制值。这套逻辑是所有自定义View测量阶段的标配,放心用。
这里有一处之前踩过坑的地方:charWidths的初始化。要先执行updateCharWidths()再走onMeasure,否则布局先跑,charWidths还是空数组,宽度就变成0了。
3.3 onDraw:逐字绘制的核心逻辑和基线公式
重头戏是onDraw。每一帧都要根据progress重算每个字符的y坐标偏移。
先算基准线,也就是文字静止时drawText的y参数。drawText的y不是顶部,而是基线baseline。基线位置的计算方式有很多种,我用的是下面这套:
val fm = paint.fontMetrics val baseY = paddingTop + (height - paddingTop - paddingBottom) / 2f - (fm.ascent + fm.descent) / 2f这个公式不用死记,理解就行:文字在垂直方向占用的范围是ascent到descent,把它们的中点对齐到View的垂直中点,得到的值就是基线位置。
水平方向也要居中。先算整串文本宽度,再算出起始绘制x:
val totalWidth = charWidths.sum() var drawX = paddingLeft + (width - paddingLeft - paddingRight - totalWidth) / 2f这样文字不管在wrap_content还是match_parent容器里都能水平居中。
然后是逐字符绘制:
override fun onDraw(canvas: Canvas) { super.onDraw(canvas) if (text.isEmpty()) return val fm = paint.fontMetrics val baseY = paddingTop + (height - paddingTop - paddingBottom) / 2f - (fm.ascent + fm.descent) / 2f val totalWidth = charWidths.sum() var drawX = paddingLeft + (width - paddingLeft - paddingRight - totalWidth) / 2f for (i in text.indices) { val phase = (progress + i * phaseOffset) % 1f val sinValue = sin(phase * 2f * PI.toFloat()) val dy = jumpHeight * sinValue canvas.drawText(text, i, i + 1, drawX, baseY - dy, paint) drawX += charWidths[i] } }注意这里用的是canvas.drawText(String, start, end, x, y, paint)方式绘制单个字符,比先把每个字符截成新字符串再画要省内存。
phase就是每个字符各自的进度。当progress是0,第一个字的sin值就是0,第二个字是sin(0.25*2π)也就是sin(90°),正好提前跳到最高点。这样整体看起来就像波浪从左往右推进。
3.4 动画驱动:一个ValueAnimator让整个View活过来
动画驱动用ValueAnimator就够了。把0到1反复走,每次回调更新progress并重绘。
fun start() { if (animator?.isRunning == true) return animator = ValueAnimator.ofFloat(0f, 1f).apply { duration = this@JumpTextView.duration repeatCount = ValueAnimator.INFINITE repeatMode = ValueAnimator.RESTART interpolator = LinearInterpolator() addUpdateListener { progress = it.animatedValue as Float postInvalidateOnAnimation() } start() } }这里有一个细节:刷新用postInvalidateOnAnimation()而不是invalidate()。前者会等到下一帧vsync信号到达时再重绘,避免一帧里重复调用invalidate造成不必要的重绘,对性能更友好。
停止就简单了:
fun stop() { animator?.cancel() animator = null progress = 0f postInvalidate() }还有一个更稳的兜底做法:在onDetachedFromWindow里强制取消动画,避免View被移除后动画还飞着:
override fun onDetachedFromWindow() { super.onDetachedFromWindow() animator?.cancel() animator = null }3.5 对外接口:start、stop、pause、setText
一个成熟的控件不能只有好看,还得让调用方方便操作。几个方法按需暴露:
fun start() { ... } fun stop() { animator?.cancel() animator = null progress = 0f postInvalidate() } fun pause() { animator?.pause() } fun resume() { animator?.resume() } fun setText(newText: String) { text = newText updateCharWidths() requestLayout() postInvalidate() }updateCharWidths负责在文本变更后重新计算每个字符的宽度:
private fun updateCharWidths() { charWidths = FloatArray(text.length) for (i in text.indices) { charWidths[i] = paint.measureText(text, i, i + 1) } }到这里,一个能用的JumpTextView就算写完了。放到布局里,调用start(),就能看到文字一蹦一跳的Loading效果。
4. 高频坑点与排查实录:照着避开能省一个晚上
4.1 文字垂直居中不准:baseline不是你以为的那个y
第一次实现时,我的baseline是直接用height / 2算的,结果文字整体偏上,而且不同字号偏的程度不一样。后来才反应过来,drawText的y参数是baseline位置,不是文字中心位置。中文字符的ascent、descent并不对称,baseline会在高度一半偏下的位置。
正确的公式前面已经给过:
val baseY = (height - paddingTop - paddingBottom) / 2f - (fm.ascent + fm.descent) / 2f这个公式我每次写自定义View都默认对上,不会再出偏差。
4.2 退后台动画还在跑:生命周期和View状态的约束
有一版我做完之后发现电量曲线有点明显波动,查了半天发现是动画在Activity onStop之后还在跑。ValueAnimator本身不会自动感知页面生命周期,它只会按自己的节奏持续回调。
正确的做法是:
- 在Activity的onStop里调用stopView.pause()或stop()。
- 在onStart里如果条件满足就resume。
- 更推荐的是在自定义View的onDetachedFromWindow里直接cancel,防止View被移除后动画还空转。
实际项目里我通常直接把cancel写在onDetachedFromWindow里,页面销毁时View自然detach,动画自然停,不用到处手动调。
4.3 动态setText后跳动错乱:缓存字宽缓存相位
另一个坑是setText之后,旧的动画还在跑,新文字的字符数和之前不一样,相位错位,观感突然乱跳一下。
原因很简单:进度progress没有重置,charWidths也没有更新。
解决方法就是setText里做两件事:重新计算字宽,然后把progress归零。最好也把动画重启,否则用户看到的就是从半空中开始跳。
fun setText(newText: String) { text = newText updateCharWidths() progress = 0f postInvalidate() }如果需要保持动画继续跑,就stop之后再start,顺序不要乱。
4.4 性能卡顿的隐形杀手:在onDraw里偷偷new对象
自定义View的onDraw每秒会被调用几十次,如果每次都new对象,GC压力会很大,低端机马上卡。常见几个坏习惯:
- 在onDraw里写
val fm = paint.fontMetrics没问题,因为fontMetrics是普通对象,不会分配新内存。 - 但不要在onDraw里做字符串拼接、转Float、new Paint、new Rect这些操作。
- 画单字符用的临时对象,尽量在外面缓存。
我目前的做法是:String用drawText(text, i, i + 1, ...),不产生新字符串;charWidths是预分配的FloatArray;所有绘制参数都在init阶段算好。动画回调里只更新progress这一个float,整个onDraw没有一次额外分配。
5. 玩法变体:同一套逻辑能做出四五种Loading
5.1 透明度随跳动变化:呼吸感从哪来
基础效果是纯位移,但原版Loading给我的感觉不止位移,字的透明度也在跟着动。跳得高的字更实,跳得低的字更虚,整体看起来像有呼吸感。
实现起来就是在onDraw里加一行透明度计算:
val alpha = (0.4f + 0.6f * (1f - abs(sinValue))).coerceIn(0f, 1f) paint.alpha = (alpha * 255).toInt()这个公式的意思是:sinValue为0时alpha最低,为1时alpha最高。配合位移,文字从底部升起时逐渐变实,落下时逐渐变虚,效果确实比纯位移更温和。
如果想让颜色产生渐变,也可以用HSV插值:
val hue = 210f + 30f * sinValue paint.color = Color.HSVToColor(floatArrayOf(hue, 0.6f, 1f))5.2 弹跳感更强的节奏:换个插值器就行
有时候正弦波的上下运动太圆润,缺少“砸到地面又弹起”的脆感。真正的弹跳是每轮循环里,上升阶段快而果断,落地阶段有个短暂的压缩感。要模拟这种物理特性,可以不用正弦波,而是分段定义:
- progress在0到0.5之间:字从0升到最高点,使用DecelerateInterpolator。
- progress在0.5到1之间:字从最高点落回,使用AccelerateInterpolator,并在落地前缓一下。
ValueAnimator默认不支持分段,但可以用PathInterpolator来定义一条贝塞尔曲线,或者干脆在onDraw里自己算分段函数。我这里给一个简单版本:
val raw = progress + i * phaseOffset val t = raw - floor(raw) val sinValue = if (t < 0.5f) { sin(t / 0.5f * PI.toFloat() / 2f) } else { cos((t - 0.5f) / 0.5f * PI.toFloat() / 2f) }效果接近于“快速上升,短暂滞留高点,再快速下落”,和普通正弦波手感差异明显,可以试试。
5.3 一点个人实操体会和常用参数
平时我封装这类动态View,习惯把默认参数调到“开箱即用”的程度,同时暴露一组setter,方便产品临时调风格。文本内容不要写死在代码里,尽量支持attrs配置,否则换文案还要改代码。
我实测下来最舒服的一组参数是:textSize 16sp,jumpHeight 10dp,phaseOffset 0.25f,duration 900ms,配合透明度渐变。宽度不足三个字时会显得单薄,一般文案建议四个字左右,比如“加载中”“数据加载”“努力加载中”这类。
最后再说一个容易被忽略的点:如果页面里存在多个JumpTextView,不要在Activity的onResume里直接全部start,最好统一用一个Toolkit来管理可见状态,不然切后台再切回来,动画可能重叠起停,日志里全是You must call start()后立刻调stop()的警告。这个问题我调了一整个下午才意识到是页面重复触发导致的。