Android自定义View实现文字跳跃Loading动画:原理与实战 不知道大家还记不记得早年那批同城信息AppApp启动或刷新的时候底部经常会有一行红色小字不是静态的“加载中”而是一个字一个字轮流往上蹦像排队跳高的小人起起落落还带颜色深浅变化。当时觉得平平无奇等自己动手在Android里复刻这个效果才发现一个看似简单的Loading动画背后其实藏着不少值得琢磨的细节。这个效果说白了就是文字跳跃加载动画核心是一行完整文案每个字符在时间轴上错开相位依次跳起、落下形成一种接力波浪感。和转圈菊花、横向进度条相比它有几个实际优势不用额外切图、不依赖Gif或Lottie、纯自定义View就能实现并且文字可以随时换既能表达“加载中”的通用状态也能承载业务提示比如“正在刷新房源信息”“正在同步数据”。这篇文章不聊什么玄学架构就老老实实拆一下效果原理、动手写一个可跑的View再把我在真机上踩过的几个坑都清一遍。适用人群很明确已经能独立写自定义View、但对动画相位和文字绘制细节还不够熟的Android开发者以及被产品丢来“就要这种文字跳一跳效果”需求、正在找现成方案的兄弟。看完这篇你不仅能把代码直接拿去改还能明白为什么相位差这么设置、为什么字母跳到一半会发虚、为什么动画停不下来。1. 先把效果看清楚这个Loading到底长什么样1.1 效果拆解先把这个效果从直觉层面拆开看它不是一个整体在动而是把一行文字拆成一个个独立的字符然后让每个字符按自己的节奏做一次竖直方向的往复运动。拿“正在加载中”这五个字举例。理想状态下看到的是第1个“正”字先跳起来还没完全落下的时候第2个“在”字开始跟着起跳接着第3个、第4个依次接力。任何一瞬间屏幕上既有正在上升的字符也有正在下降的字符还有停在原地的字符。这样才不会变成整行文字一起上下震动——那是另一种效果叫整体抖动质感差很多。第二个容易被忽略的细节是透明度。原版同城风格的效果里文字并不是全程满透明度。字符跳得越高颜色越浅落回基线附近颜色越实。这个细节非常关键它给动画增加了“轻盈感”。如果没有透明度变化纯位移跳高看起来会比较生硬像纸片被拽着上下移动。第三个细节是文字基线的位置。跳起时字符底部不能低于原始基线所有字符的底部应该始终对齐在同一条水平线上跳起来只是往上走。如果实现的时候把字符往各个方向乱移或者跳起时向下偏移视觉效果会很乱。所以我们真正要做的就是三件事让每个字符独立位移让字符之间按固定时间差依次触发让字符在跳起过程中颜色渐变。只要抓住这三件事不管用哪种方案做都不会跑偏。1.2 三种实现思路的取舍针对这个效果常见的做法有三类我单独对比一下。第一类是最笨的帧动画。把整个动画过程用录屏或者代码渲染成一帧帧图片然后用AnimationDrawable循环播放。优点是实现简单、效果可以做得非常精细包括阴影、残影都能预渲染进去。缺点是文件体积大、文字不能换、不同分辨率适配麻烦一个“加载中”和一个“正在同步数据”就得做两套图。这种方案在早期业务里见过后来基本被淘汰了。第二类是用View动画组合比如给每个TextView单独设置TranslateAnimation和AlphaAnimation设置不同的startOffset错开启动。这种方案适合文字数量固定、不打算动态改文案的场景。但它有个很尴尬的问题如果文字中间插入了标点、英文字母不同字符视觉宽度不一致用等宽偏移排布会很丑而且每个字符都得是一个独立TextView布局层级瞬间变深管理动画状态也麻烦。第三类是自定义View一个View里用Paint按字符逐个绘制用ValueAnimator作为统一时钟驱动。这是我最推荐的做法。优点非常突出没有额外布局节点性能最好文字内容、颜色、跳高、速度全部可以通过自定义属性配置相位逻辑在代码里完全可控想改成从左到右、从右到左、中间扩散都只是改一个公式的事。三种方案对比下来自定义View的唯一门槛是需要对文本测量、基线、Paint绘制比较熟。但这些东西其实不难后面我会一步步展开真正写一遍之后就通了。2. 核心原理相位差与正弦跳跃2.1 从“每个字弹一下”到“接力波浪”动画的底层驱动其实就是一个从0到1不断循环的数值我用ValueAnimator生成。在动画更新回调里拿到这个数值把它映射成每个字符的即时位移。关键问题是怎么让5个字符错开起跳答案是用相位差。我先定义一个大周期整个动画进度progress从0增长到1对应角度就是0到2π。对于第i个字符它的角度为angle progress * 2π i * phaseGap其中phaseGap是字符之间的相位间隔。当第0个字符走完一个完整周期时第1个字符才刚开始它周期里的后半段以此类推。这就形成了接力错位。相位间隔不是越大越好。我实测下来phaseGap在π/2左右比较合适相当于一个完整跳跃周期里大概只有两到三个字符同时处于明显运动中其余字符要么在顶点附近、要么在底部附近。如果phaseGap太小所有字符动作太接近看起来就是整体抖动如果phaseGap太大比如超过π每个字符都单独跳完中间会有明显的停顿空隙流畅感就没了。2.2 跳起高度、透明度与速度的协调拿到角度之后字符跳起高度我用正弦函数的绝对值来计算jumpOffset maxJumpHeight * |sin(angle)|用绝对值的原因很简单如果直接取sin(angle)字符会在正负之间切换也就是会往基线以下钻看起来像漏了气的弹簧。加绝对值之后sin永远在0到1之间字符只往上走然后落回原地正好是我们要的“跳起回落”。透明度变化不能和位移用同一个函数否则就会出现“字符在最高处颜色最浅、在最低处颜色最深”的单调节奏。我实际使用的是余弦函数让透明度变化和位移变化有一个相位互补的关系alpha 120 135 * |cos(angle)|这样当字符跳起时颜色开始变浅落到一半时颜色恢复落到最低点时颜色最实。参考原版同城App的观感这套映射最贴近那种“红字跳一下、亮一下”的质感。速度方面单个周期时长我建议放在700ms到900ms之间。太短了低于300ms人眼会看到明显的闪烁感太长了超过1.5s又会觉得加载变慢了。ValueAnimator的整体duration就是单个字符完成一次完整跳起回落的时间而不是整行文字表演一遍的时间这点很多新手容易搞错。如果duration设成所有字符接力一遍的总时间每个字符的跳起速度会慢得让人着急。2.3 为什么用正弦而不是自定义插值器可能有人会问直接在ValueAnimator里用LinearInterpolator然后用sin函数算偏移为什么不给每个字符单独设置OvershootInterpolator之类的插值器原因有两点。第一给多个字符分别设置动画和插值器你很难保证它们之间的节奏是连续衔接的。用正弦函数的好处是整个效果是一个统一的数学表达式改变任何一个参数所有字符的运动会同步、平滑地发生变化不存在多动画并发时状态不同步的问题。第二正弦函数天然模拟了“弹性跳起、缓缓落下”的视觉特征。跳起阶段加速度先大后小落到最低点时速度为零短暂“停顿”后再次起跳。这个节奏和真实物理中的弹跳接近但又不需要引入物理引擎或者复杂的贝塞尔曲线一行代码就搞定。顺带说一句如果想要更夸张的弹跳感比如跳起时带一点横向拉伸可以在高位移处把字符宽度略微放大、高度略微缩小。这个效果可以叠加在正弦函数上但会让代码复杂一些。基础版先不加我放在最后的扩展心得里讲。3. 手写一个TextJumpLoadingView完整代码可抄3.1 自定义属性定义先把可配置项列出来这样后面接入布局时不用改代码。在res/values/attrs.xml里加一组自定义属性resources declare-styleable nameTextJumpLoadingView attr namejumpText formatstring / attr namejumpTextSize formatdimension / attr namejumpTextColor formatcolor / attr namejumpHeight formatdimension / attr namephaseGap formatfloat / attr namejumpDuration formatinteger / attr nameautoStart formatboolean / /declare-styleable /resources每个属性的含义说一下jumpText是显示的文案比如“正在加载中”jumpTextSize是字号jumpTextColor是颜色jumpHeight是单个字符跳起的最大高度phaseGap是字符间相位间隔单位是弧度jumpDuration是单个字符一次跳起回落的毫秒数autoStart控制View挂载后是否自动开始动画。这些属性看起来简单但设计时有一个原则凡是业务里可能要调整的都做成可配置。我之前见过一些自定义View把所有参数写死在构造器里结果每次换文案、换颜色都得改源码非常难受。3.2 核心View实现下面给出完整的Kotlin实现。代码我按可读性优先来写没有做极端精简方便直接拿去改造。package com.example.widget import android.content.Context import android.graphics.Canvas import android.graphics.Paint import android.graphics.TextPaint import android.util.AttributeSet import android.view.View import android.view.animation.LinearInterpolator import android.animation.ValueAnimator import androidx.core.content.ContextCompat import kotlin.math.PI import kotlin.math.abs import kotlin.math.ceil import kotlin.math.cos import kotlin.math.sin class TextJumpLoadingView JvmOverloads constructor( context: Context, attrs: AttributeSet? null, defStyleAttr: Int 0 ) : View(context, attrs, defStyleAttr) { private var jumpText: String 正在加载中 private var jumpHeightPx: Float dp2px(24f) private var phaseGap: Float (PI / 2f).toFloat() private var jumpDurationMs: Long 800L private var autoStart: Boolean true private var textSizePx: Float sp2px(14f) private var textColor: Int ContextCompat.getColor(context, android.R.color.holo_red_dark) private val textPaint TextPaint(Paint.ANTI_ALIAS_FLAG).apply { textAlign Paint.Align.LEFT } private val characters mutableListOfChar() private val charPositions mutableListOfFloat() private var totalTextWidth 0f private var currentProgress 0f private val animator ValueAnimator.ofFloat(0f, 1f).apply { duration jumpDurationMs repeatCount ValueAnimator.INFINITE interpolator LinearInterpolator() addUpdateListener { currentProgress it.animatedValue as Float postInvalidateOnAnimation() } } init { if (attrs ! null) { val ta context.obtainStyledAttributes(attrs, R.styleable.TextJumpLoadingView) jumpText ta.getString(R.styleable.TextJumpLoadingView_jumpText) ?: jumpText textSizePx ta.getDimension(R.styleable.TextJumpLoadingView_jumpTextSize, textSizePx) textColor ta.getColor(R.styleable.TextJumpLoadingView_jumpTextColor, textColor) jumpHeightPx ta.getDimension(R.styleable.TextJumpLoadingView_jumpHeight, jumpHeightPx) phaseGap ta.getFloat(R.styleable.TextJumpLoadingView_phaseGap, phaseGap) jumpDurationMs ta.getInt(R.styleable.TextJumpLoadingView_jumpDuration, jumpDurationMs.toInt()).toLong() autoStart ta.getBoolean(R.styleable.TextJumpLoadingView_autoStart, autoStart) ta.recycle() } textPaint.color textColor textPaint.textSize textSizePx updateCharacterInfo() } private fun updateCharacterInfo() { characters.clear() charPositions.clear() for (ch in jumpText) { characters.add(ch) } textPaint.textSize textSizePx var cursorX 0f for (ch in characters) { charPositions.add(cursorX) val charWidth textPaint.measureText(ch.toString()) cursorX charWidth dp2px(2f) } totalTextWidth cursorX - dp2px(2f) requestLayout() } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val textMetrics textPaint.fontMetrics val textHeight textMetrics.descent - textMetrics.ascent val desiredWidth ceil(totalTextWidth paddingLeft paddingRight).toInt() val desiredHeight ceil(textHeight jumpHeightPx paddingTop paddingBottom).toInt() val resolvedWidth resolveSize(desiredWidth, widthMeasureSpec) val resolvedHeight resolveSize(desiredHeight, heightMeasureSpec) setMeasuredDimension(resolvedWidth, resolvedHeight) } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) if (characters.isEmpty()) return val startX (width - totalTextWidth) / 2f val baseY height - paddingBottom - textPaint.fontMetrics.descent for (i in characters.indices) { val angle currentProgress * 2f * PI.toFloat() i * phaseGap val jumpOffset jumpHeightPx * abs(sin(angle)) val alpha (120 135 * abs(cos(angle))).toInt().coerceIn(0, 255) textPaint.alpha alpha canvas.save() canvas.translate(startX charPositions[i], baseY - jumpOffset) canvas.drawText(characters[i].toString(), 0f, 0f, textPaint) canvas.restore() } textPaint.alpha 255 } override fun onAttachedToWindow() { super.onAttachedToWindow() if (autoStart) { start() } } override fun onDetachedFromWindow() { super.onDetachedFromWindow() stop() } override fun onVisibilityChanged(changedView: View, visibility: Int) { super.onVisibilityChanged(changedView, visibility) if (visibility View.VISIBLE) { if (autoStart) start() } else { stop() } } fun start() { if (!animator.isRunning) { animator.duration jumpDurationMs animator.start() } } fun stop() { if (animator.isRunning) { animator.cancel() currentProgress 0f invalidate() } } fun setJumpText(text: String) { if (text ! jumpText) { jumpText text updateCharacterInfo() invalidate() } } fun setJumpTextColor(color: Int) { textColor color textPaint.color color invalidate() } private fun dp2px(value: Float): Float { return value * resources.displayMetrics.density } private fun sp2px(value: Float): Float { return value * resources.displayMetrics.scaledDensity } }这里有几个实现细节值得单独说明。第一drawText的时候用的是translate坐标把画笔的原点移动到了每个字符的绘制起点然后drawText的第3、4个参数传0。这是因为TextPaint默认的textAlign是LEFTdrawText的x是字符左边界y是字符基线。translate到startX charPositions[i]作为左边界translate到baseY - jumpOffset作为基线这样字符跳高多少基线就上移多少逻辑非常干净。第二字符之间的间距我手动加了2dp。中文字符本身没有连笔和字距设计如果不加间距一排字会比较挤跳起来之后视觉上会碰在一起。英文字母和数字天然有字距但和中文混排时中文字符间距仍然偏小统一加一个2dp的间隔是实测比较舒服的值。第三onMeasure里高度做了预留。textHeight计算的是文字实际占用的高度jumpHeightPx是跳起高度两者相加再加上padding确保跳起时文字顶部不会被View边界裁切掉。如果忘了在高度里加jumpHeightPx动画跑起来就会发现跳高的字“缺了脑袋”这个问题在后文排查表里还要再提。第四字母透明度计算用cos绝对值映射到120到255之间。最低alpha选择120而不是0是因为完全透明会出现明显的“消失感”和整体动画风格不搭。120-255这个区间是我对比了几个方案后的结果颜色最浅时还能看清字形落回原位时恢复正红色。3.3 接入布局与启动控制布局文件里直接这样引用com.example.widget.TextJumpLoadingView android:idid/jumpLoading android:layout_widthwrap_content android:layout_heightwrap_content android:padding16dp app:jumpText正在加载中请稍候 app:jumpTextSize16sp app:jumpTextColor#E64340 app:jumpHeight20dp app:phaseGap1.57 app:jumpDuration800 app:autoStarttrue android:visibilityvisible /启动和停止的用法最稳妥的方式是把它和页面生命周期绑定。因为View的onDetachedFromWindow不一定能覆盖到Activity所有退出场景建议在Activity的onStart里调start、onStop里调stop或者在Fragment的onResume/onPause里做同样的事。class SampleActivity : AppCompatActivity() { private lateinit var jumpLoading: TextJumpLoadingView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_sample) jumpLoading findViewById(R.id.jumpLoading) // 动态修改文案的场景 jumpLoading.setJumpText(正在为你刷新数据) } override fun onResume() { super.onResume() jumpLoading.start() } override fun onPause() { super.onPause() jumpLoading.stop() } }如果你不想在业务代码里手动管理启停可以把autoStart设为trueView内部会自动处理onAttachedToWindow和onVisibilityChanged。但有一点必须清楚autoStart无法覆盖Activity退到后台但View没有detach的情况比如启动了新的全屏透明Activity、或者页面被Dialog盖住但View仍然可见这时候动画还在跑。所以页面级生命周期的启停还是建议手动接一下。4. 踩坑记录与性能调优这些坑我基本都踩过4.1 五个最常见的坑第一文字顶部被裁切。现象是字符跳到最高点时上半部分或整个字符不见了只能看到下半截。原因很直接View自身的高度没有预留跳高空间系统在onDraw时对Canvas的裁剪区域是View的bounds超出部分直接不渲染。解决办法就是我在onMeasure里做的那样高度要等于文字高度加跳高高度而不是只量文字本身的高度。想省事的话也可以在布局里给View的layout_height写死一个偏大的值但这样做不够通用。第二动画停不下来导致内存泄漏。ValueAnimator在无限循环状态下会一直持有View引用如果View已经Detach但animator还在运行这个View就永远无法被回收。尤其是Fragment替换场景旧Fragment的View被移除了但animator还在跑泄漏一次就是几MB起步。我处理的办法是在onDetachedFromWindow里强制cancel同时业务代码里onPause也调一次stop双保险。第三所有字符动画完全同步变成上下齐跳。这个基本就是phaseGap设置成了0。检查一下attrs配置和init里的赋值逻辑phaseGap建议在1.2到2.0之间对应弧度约70度到115度。配置成了30度也会看起来太齐这段我在真机上对比过1.57左右也就是90度时接力感最明显。第四中英文混排时视觉宽度错乱。中文每个字等宽但英文字母比如“W”和“I”宽度能差出3倍。如果实现的时候用index直接乘固定宽度跳起来的位置就会对不上。我代码里用的方式是预遍历每个char用measureText精确量取每个字符宽度并记录在charPositions里的这样无论中英文混排还是带标点位置都是准的。第五动画结束后透明度没有复原。这个坑特别隐蔽。如果onDraw里对每个字符设置了textPaint.alpha但最后没有恢复255那么下次绘制时淡色会被延续到不该淡的字符上。颜色越来越淡直到最后整个文字几乎看不清。我在代码最后强制textPaint.alpha 255就是为了避免这个状态污染。4.2 性能细节保证低端机也流畅这个效果本质上是逐帧绘制文字性能压力不算大但如果不注意细节也会在低端设备上出现肉眼可见的掉帧。不要在onDraw里创建任何对象。我见过有人每次绘制都新建一个Rect、新建一个Paint虽然后续帧会被GC回收但频繁创建对象会带来额外的分配和回收压力。自定义View的onDraw每次vsync都会调用如果动画是60帧每秒就是60次对象分配多了GC就会卡。我在代码里把Paint、字符列表、位置列表全部做成了成员变量只在初始化时创建一次。用postInvalidateOnAnimation代替invalidate。invalidate是立即请求重绘而postInvalidateOnAnimation会等到下一帧vsync信号统一刷新。虽然自定义View里两者在视觉上差别不大但postInvalidateOnAnimation能减少无效绘制让动画与系统帧率保持同步。这个方法是API 16引入的现在最低支持版本普遍高于16放心用。不要在跳高动画之外叠加太复杂的绘制逻辑。有的设计会希望Loading文字底下加一个半透明圆角背景这个背景如果在onDraw里用drawRoundRect绘制没问题但如果再加位图模糊、渐变MaskFilter低端机就会吃力。我的建议是圆角背景这种静态元素交给XML布局里的背景层去画View只负责动态文字分工明确。4.3 一句话排查速查表现象根本原因解决方案文字跳到一半被截断View高度没有预留跳高空间onMeasure中把jumpHeightPx计入高度退出页面后动画还在跑没有在onDetachedFromWindow取消动画重写onDetachedFromWindow并调stop所有字符同时上下phaseGap太小或为0设为1.2到2.0之间的弧度值文字颜色越来越淡onDraw里没有恢复Paint的alpha绘制结束后强制重置alpha为255动画卡顿、掉帧明显频繁在onDraw里创建对象把测量结果缓存到成员变量中英文混排位置错乱用索引等分宽度用measureText逐个精确测量字符宽度跳完后文字偏移回不到原始位置translate和restore没有配对每次save/restore配合坐标统一计算这个表看起来短但每一条都是我实际调试过的。尤其是第一行和第四行出错频率最高真的建议做之前先在心里过一遍。再说一个扩展方向如果你想要的效果不是文字跳跃而是文字逐个淡入、或者左右波浪摆动其实只需要改动onDraw里的jumpOffset和alpha这两个计算公式就行。跳跃改成scaleX变形就是按压弹跳改成横向位移就是扫光效果。整个View框架不用动变的只是运动学公式。这个自定义View的可复用性比单独堆动画代码强很多。最后聊聊我个人用下来的体会。第一次做这个效果时我陷在“怎么让动画看起来更花哨”的误区里加残影、加渐变、加缩放结果低端机卡得不行。后来回到最朴素的方案位移加透明度反而最接近当年那个经典Loading的味道。做这种小效果克制比炫技重要。文字跳跃Loading本身只是一个状态提示它不该抢业务内容的风头用户瞄一眼知道“在加载”就够了。所以核心逻辑越简单越好真正花心思的地方应该放在相位节奏和细节手感上。这个View我后来一直留着业务里换文案、换颜色、换速度都很方便算是投入产出比很高的一段自定义View代码。