Android实现加载动画(附带源码)

一、项目介绍

1. 背景与动机

在现代移动应用中,加载动画不仅能向用户传达“正在努力加载中”的信号,缓解等待焦虑,还能通过视觉效果提升品牌识别度与用户体验。无论是网络请求、复杂计算、还是页面过渡,都离不开加载状态的优雅表达。本项目旨在演示如何在 Android 中从零实现多种常见的加载动画:

  • 系统原生 ProgressBar:基于 indeterminateDrawable 的自定义风格

  • 属性动画:通过 ObjectAnimatorValueAnimator 实现自定义 View 动画

  • 帧动画:基于 AnimationDrawable 的帧序列加载

  • AnimatedVectorDrawable:矢量动画,兼容性好、文件轻量

  • Lottie 动画:基于 Airbnb Lottie 库,支持 After Effects 导出 JSON 动画

并封装为易用的组件与工具类,使业务层仅需一行调用,即可实现高质感加载动画。

2. 功能需求

  1. 多种加载样式:圆形旋转、淡入淡出、波浪、心跳、渐变环

  2. 可配置:颜色、尺寸、速度、插值器、循环次数

  3. 组件化:封装为 LoadingView,支持 XML 属性与代码动态配置

  4. 简易集成:只需在布局中添加 <com.example.loading.LoadingView/>

  5. Lifecycle 感知:自动在 onAttachedToWindow 开启动画,在 onDetachedFromWindow 停止

  6. 性能优化:硬件加速、资源复用、避免过度重绘


二、相关知识

  1. View 动画 vs 属性动画 vs 帧动画

    • View Animation(补间动画):旧版ScaleAnimation/RotateAnimation,仅改 UI 表现,不改变属性

    • Property Animation(属性动画):API 11+,通过 ObjectAnimator.ofFloat(view,"rotation",0,360) 直接修改属性

    • Frame AnimationAnimationDrawable 逐帧切换图片,适合复杂帧序列

  2. AnimatedVectorDrawable

    • 基于矢量资源(vector.xml),用 <animated-vector> 配合 <objectAnimator> 对路径或属性做动画

    • 文件小、可缩放,无像素损失

  3. Lottie

    • Airbnb 开源,支持将 After Effects 动画导出为 JSON,在原生端渲染

    • 支持无缝循环、动态替换颜色、图层控制

  4. Canvas 绘制与自定义 View

    • 通过重写 onDraw(Canvas),结合 ValueAnimator 驱动 invalidate() 实现自绘动画

    • 可绘制圆环、波浪、心形等自定义形状

  5. 插值器(Interpolator)

    • LinearInterpolator:匀速

    • AccelerateDecelerateInterpolator:先加速再减速

    • BounceInterpolatorOvershootInterpolator:弹跳、回弹


三、实现思路

本文将按照由浅入深的顺序,依次实现以下几种加载动画,并最终封装:

  1. 系统 ProgressBar

    • 自定义 indeterminateDrawable:一个旋转的矢量圆环

  2. 属性动画 + 自定义 View

    • LoadingView1:一个手写的圆环旋转动画

  3. AnimatedVectorDrawable

    • LoadingView2:在 XML 中定义 animated-vector,在 View 中直接调用 setImageDrawable()

  4. Lottie

    • LoadingView3:集成 Lottie 库,加载 *.json 动画文件

  5. 统一封装

    • LoadingView:通过 XML 属性 app:loadingType="progress|custom|vector|lottie" 选择实现

    • 动画生命周期管理、API 暴露 start(), stop()


四、环境与依赖

// app/build.gradle
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'

android {
  compileSdkVersion 34
  defaultConfig {
    applicationId "com.example.loading"
    minSdkVersion 21
    targetSdkVersion 34
  }
  buildFeatures { viewBinding true }
  kotlinOptions { jvmTarget="1.8" }
}

dependencies {
  implementation 'androidx.appcompat:appcompat:1.6.1'
  implementation 'androidx.core:core-ktx:1.10.1'
  implementation 'com.airbnb.android:lottie:6.0.0'
}

五、整合代码

// =======================================================
// 文件: res/values/attrs.xml
// 描述: LoadingView 的自定义属性,type、color、size、speed
// =======================================================
<resources>
  <declare-styleable name="LoadingView">
    <!-- loading 类型: 0=ProgressBar,1=CustomView,2=Vector,3=Lottie -->
    <attr name="lv_type" format="integer"/>
    <!-- 颜色 -->
    <attr name="lv_color" format="color"/>
    <!-- 大小 dp -->
    <attr name="lv_size" format="dimension"/>
    <!-- 速度: ms per cycle -->
    <attr name="lv_duration" format="integer"/>
    <!-- Lottie 文件名 -->
    <attr name="lv_lottieFile" format="string"/>
  </declare-styleable>
</resources>

// =======================================================
// 文件: res/drawable/progress_ring.xml
// 描述: 矢量圆环,用于 ProgressBar drawable
// =======================================================
<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="48dp" android:height="48dp"
    android:viewportWidth="100" android:viewportHeight="100">
  <group android:pivotX="50" android:pivotY="50">
    <path
      android:name="ring"
      android:fillColor="@android:color/transparent"
      android:strokeColor="?attr/colorPrimary"
      android:strokeWidth="10"
      android:pathData="M50,10 A40,40 0 1,1 49.99,10"/>
  </group>
</vector>

// =======================================================
// 文件: res/drawable/anim_progress.xml
// 描述: ProgressBar 的 indeterminateAnimationList
// =======================================================
<animated-rotate xmlns:android="http://schemas.android.com/apk/res/android"
    android:drawable="@drawable/progress_ring"
    android:pivotX="50%" android:pivotY="50%"/>

// =======================================================
// 文件: LoadingView.kt
// 描述: 统一封装 LoadingView,根据 type 加载不同实现
// =======================================================
package com.example.loading

import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.util.AttributeSet
import android.view.View
import android.widget.ImageView
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.content.withStyledAttributes
import com.airbnb.lottie.LottieAnimationView
import com.airbnb.lottie.LottieDrawable

class LoadingView @JvmOverloads constructor(
  context: Context, attrs: AttributeSet? = null
) : View(context, attrs) {

  companion object {
    const val TYPE_PROGRESS = 0
    const val TYPE_CUSTOM   = 1
    const val TYPE_VECTOR   = 2
    const val TYPE_LOTTIE   = 3
  }

  private var type = TYPE_PROGRESS
  private var color = 0xFF000000.toInt()
  private var sizePx = 100
  private var duration = 1000
  private var lottieFile: String? = null

  // 子 View
  private var progressBar: ImageView? = null
  private var customView: CustomLoadingView? = null
  private var vectorView: ImageView? = null
  private var lottieView: LottieAnimationView? = null

  init {
    context.withStyledAttributes(attrs, R.styleable.LoadingView) {
      type       = getInt(R.styleable.LoadingView_lv_type, TYPE_PROGRESS)
      color      = getColor(R.styleable.LoadingView_lv_color, color)
      sizePx     = getDimensionPixelSize(R.styleable.LoadingView_lv_size, sizePx)
      duration   = getInt(R.styleable.LoadingView_lv_duration, duration)
      lottieFile = getString(R.styleable.LoadingView_lv_lottieFile)
    }
    when (type) {
      TYPE_PROGRESS -> initProgressBar()
      TYPE_CUSTOM   -> initCustom()
      TYPE_VECTOR   -> initVector()
      TYPE_LOTTIE   -> initLottie()
    }
  }

  private fun initProgressBar() {
    progressBar = AppCompatImageView(context).apply {
      setImageResource(R.drawable.anim_progress)
      (drawable as? android.graphics.drawable.AnimatedRotateDrawable)?.start()
    }
    addView(progressBar, LayoutParams(sizePx, sizePx))
  }

  private fun initCustom() {
    customView = CustomLoadingView(context).apply {
      setColor(color); setDuration(duration)
      start()
    }
    addView(customView, LayoutParams(sizePx, sizePx))
  }

  private fun initVector() {
    vectorView = AppCompatImageView(context).apply {
      setImageResource(R.drawable.progress_ring)
      val avd = drawable as android.graphics.drawable.AnimatedVectorDrawable
      avd.registerAnimationCallback(object: android.graphics.drawable.AnimatedVectorDrawable.AnimationCallback(){
        override fun onAnimationEnd(drawable: android.graphics.drawable.Drawable?) { avd.start() }
      })
      avd.start()
    }
    addView(vectorView, LayoutParams(sizePx, sizePx))
  }

  private fun initLottie() {
    lottieView = LottieAnimationView(context).apply {
      lottieFile?.let { setAnimation(it) }
      repeatCount = LottieDrawable.INFINITE
      playAnimation()
    }
    addView(lottieView, LayoutParams(sizePx, sizePx))
  }

  override fun onDetachedFromWindow() {
    super.onDetachedFromWindow()
    customView?.stop()
    (vectorView?.drawable as? android.graphics.drawable.AnimatedVectorDrawable)?.stop()
    (progressBar?.drawable as? android.graphics.drawable.AnimatedRotateDrawable)?.stop()
    lottieView?.cancelAnimation()
  }
}

// =======================================================
// 文件: CustomLoadingView.kt
// 描述: 自定义 Property Animation 圆环旋转 View
// =======================================================
package com.example.loading

import android.animation.ValueAnimator
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.View
import android.view.animation.LinearInterpolator

class CustomLoadingView @JvmOverloads constructor(
  ctx: Context, attrs: AttributeSet? = null
) : View(ctx, attrs) {

  private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
    style = Paint.Style.STROKE; strokeCap = Paint.Cap.ROUND; strokeWidth = 8f
  }
  private val rectF = RectF()
  private var sweepAngle = 0f
  private var startAngle = 0f
  private var duration = 1000
  private val animator = ValueAnimator.ofFloat(0f, 360f).apply {
    interpolator = LinearInterpolator()
    repeatCount = ValueAnimator.INFINITE
    addUpdateListener {
      startAngle = it.animatedValue as Float
      sweepAngle = 90f
      invalidate()
    }
  }

  fun setColor(c: Int) { paint.color = c }
  fun setDuration(d: Int) { duration = d; animator.duration = d.toLong() }

  fun start() { animator.start() }
  fun stop()  { animator.cancel() }

  override fun onSizeChanged(w: Int, h: Int, ow: Int, oh: Int) {
    super.onSizeChanged(w, h, ow, oh)
    val pad = paint.strokeWidth/2
    rectF.set(pad, pad, w-pad, h-pad)
  }

  override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)
    canvas.drawArc(rectF, startAngle, sweepAngle, false, paint)
  }
}

// =======================================================
// 文件: res/layout/activity_main.xml
// 描述: 示例页面,使用四种 LoadingView
// =======================================================
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:orientation="vertical" android:padding="24dp"
    android:gravity="center"
    android:layout_width="match_parent" android:layout_height="match_parent">

  <com.example.loading.LoadingView
      android:layout_margin="8dp"
      app:lv_type="0" app:lv_color="#FF4081" app:lv_size="64dp"/>

  <com.example.loading.LoadingView
      android:layout_margin="8dp"
      app:lv_type="1" app:lv_color="#3F51B5"
      app:lv_size="64dp" app:lv_duration="800"/>

  <com.example.loading.LoadingView
      android:layout_margin="8dp"
      app:lv_type="2" app:lv_size="64dp"/>

  <com.example.loading.LoadingView
      android:layout_margin="8dp"
      app:lv_type="3" app:lv_size="80dp"
      app:lv_lottieFile="lottie/loading.json"/>
</LinearLayout>

六、代码解读

  1. 自定义属性 (attrs.xml)

    • lv_type 决定使用哪种加载方式:系统 ProgressBar、自定义 View、Animated Vector、Lottie;

    • 其它属性如 lv_colorlv_sizelv_durationlv_lottieFile 支持在 XML 中灵活配置。

  2. 系统 ProgressBar 方案

    • progress_ring.xml 定义了一个半开的圆环矢量;

    • anim_progress.xml 将其包装成 AnimatedRotateDrawable 自动旋转;

    • LoadingView 中用 ImageView 展示并启动旋转动画。

  3. 自定义 Property Animation 方案

    • CustomLoadingView 重写 onDraw,用 drawArc 绘制一个固定角度的弧段;

    • 通过 ValueAnimator 不断更新 startAngle,实现旋转效果;

  4. AnimatedVectorDrawable 方案

    • 直接在代码中将 progress_ring 当作 AnimatedVectorDrawable 使用;

    • 注册 AnimationCallback 循环播放;

  5. Lottie 方案

    • 依赖 Lottie 库,加载指定 JSON 动画文件;

    • 设置 repeatCount = INFINITEplayAnimation()

  6. 统一封装 LoadingView

    • 构造时读取属性并创建对应子 View;

    • onDetachedFromWindow 中停止动画,防止内存泄露;

    • 业务层仅需在布局中添加一行即可使用四种动画。


七、性能与优化

  1. 硬件加速

    • 矢量和属性动画在硬件加速下渲染效率高,无需额外设置;

  2. 资源复用

    • AnimatedRotateDrawableAnimatedVectorDrawable 均可复用动画对象;

    • ValueAnimatorCustomLoadingView 中作为成员变量,仅创建一次;

  3. 局部重绘

    • 自定义 View 调用 invalidate() 时默认全屏重绘,可考虑 invalidate(rect) 限定区域;

  4. Lottie 最优化

    • 合理拆分 JSON 动画,移除无用图层,减少渲染开销;

    • 只在可见时播放,离屏时 cancelAnimation()


八、项目总结与拓展

本文深入讲解了 Android 中加载动画的多种实现方式,并通过 LoadingView 组件将它们统一封装,极大降低业务层集成成本。可根据产品需求,在不同场景下快速切换加载样式。

拓展方向

  1. 更多动画:比如波浪、心跳、圆点脉冲等;

  2. 交互引导:结合加载动画与指引气泡,引导用户首次使用;

  3. Compose 重构:使用 Canvasanimate*AsState 迁移到 Jetpack Compose;

  4. 主题适配:支持夜间模式、品牌色切换;

  5. 性能监测:结合 Systrace 或 GPU Overdraw Checker 监测动画性能瓶颈。


九、FAQ

  1. Q:AnimatedVectorDrawable 支持哪些 API?
    A:Android 5.0+ 原生支持,低版本可用支持库 androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat

  2. Q:Lottie JSON 文件从哪儿来?
    A:可从 LottieFiles 下载,或在 After Effects 中用 Bodymovin 插件导出。

  3. Q:如何在列表中复用 LoadingView?
    A:在 RecyclerView Adapter 中直接在 item 布局里引入 LoadingView,自动启动/停止动画。

  4. Q:自定义 View 性能卡顿怎么办?
    A:减少绘制复杂度,使用 PathMeasure 预计算,启用硬件加速。

  5. Q:如何动态切换加载类型?
    A:在 LoadingView 中提供 setType() 方法,销毁旧子 View 并重新 initXXX()

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值