☰
Android自定义View实现文字跳跃Loading动画从原理到封装
2026/10/14 16:55:12 网站建设 项目流程

当年某分类信息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()的警告。这个问题我调了一整个下午才意识到是页面重复触发导致的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询