前言
本文参考辉哥的博客属性动画 - 58同城数据加载动画,用来学习属性动画相关知识非常合适。
最终效果
整体思路
绘制部分分析:
-
整体加载动画由三部分组成:
1.上方的正方形、圆形以及三角形,需要进行旋转以及下落动画;
2.中间的阴影部分,需要进行缩放;
3.最下方的文字; -
针对上方的形状变化我们先自定义一个
ShapeView
,主要绘制正方形、圆形、三角形,同时支持形状切换; -
针对中间阴影部分,我们就通过给View设置背景,然后进行x轴缩放即可;
动画部分分析:
- 一进来先进行下落动画,同时伴随着阴影部分缩小动画;
- 下落结束后进行上升动画,阴影部分进行放大动画,同时
ShapeView
进行旋转动画; - 上升结束后继续进行下落动画,如此反复,即可实现效果。
具体实现
将整体思路理清楚,我们便可以开始自定义我们的View
- 自定义
ShapeView
package com.crystal.view.animation
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.View
import kotlin.math.sqrt
/**
* 圆、三角形、正方形变换
* on 2022/11/9
*/
class ShapeView : View {
/**
* 画笔
*/
private var paint: Paint = Paint()
/**
* 圆形颜色
*/
private val CIRCLE_COLOR = Color.parseColor("#AA738FFE")
/**
* 正方形颜色
*/
private val SQUARE_COLOR = Color.parseColor("#AAE84E40")
/**
* 三角形颜色
*/
private val TRIANGLE_COLOR = Color.parseColor("#AA72D572")
/**
* 当前形状
*/
private var currentShape = Shape.CIRCLE
/**
* 正方形Rect
*/
private var squareRect = Rect()
/**
* 三角形path
*/
private var path = Path()
constructor(context: Context) : this(context, null)
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
context, attrs, defStyleAttr
) {
paint.isAntiAlias = true
paint.style = Paint.Style.FILL_AND_STROKE
paint.isDither = true
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
//设置宽高始终一致
var widthSize = MeasureSpec.getSize(widthMeasureSpec)
var heightSize = MeasureSpec.getSize(heightMeasureSpec)
if (widthSize > heightSize) {
widthSize = heightSize
} else {
heightSize = widthSize
}
setMeasuredDimension(widthSize, heightSize)
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
//设置正方形rect
squareRect.left = 0
squareRect.top = 0
squareRect.right = width
squareRect.bottom = height
}
override fun onDraw(canvas: Canvas) {
when (currentShape) {
Shape.SQUARE -> {
paint.color = SQUARE_COLOR
canvas.drawRect(squareRect, paint)
}
Shape.CIRCLE -> {
paint.color = CIRCLE_COLOR
canvas.drawCircle(width / 2f, height / 2f, width / 2f, paint)
}
Shape.TRIANGLE -> {
paint.color = TRIANGLE_COLOR
path.moveTo(width / 2f, 0f)
path.lineTo(0f, (sqrt(3.0) * width / 2f).toFloat())
path.lineTo(width.toFloat(), (sqrt(3.0) * width / 2f).toFloat())
path.close()
canvas.drawPath(path, paint)
}
}
}
fun exchangeShape() {
currentShape = when (currentShape) {
Shape.CIRCLE -> {
Shape.SQUARE
}
Shape.SQUARE -> {
Shape.TRIANGLE
}
Shape.TRIANGLE -> {
Shape.CIRCLE
}
}
postInvalidate()
}
/**
* 获取当前形状
*/
fun getCurrentShape(): Shape {
return currentShape
}
enum class Shape {
CIRCLE, SQUARE, TRIANGLE
}
}
- 自定义最终的LoadingView
- LoadingView布局文件
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<!--上方形状-->
<com.crystal.view.animation.ShapeView
android:id="@+id/shape_view"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_marginBottom="82dp"
app:layout_constraintBottom_toTopOf="@+id/shadow_view"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent" />
<View
android:id="@+id/shadow_view"
android:layout_width="40dp"
android:layout_height="5dp"
android:background="@drawable/shape_shadow_bg"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tv"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="玩命加载中..."
android:textSize="16sp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/shadow_view" />
</androidx.constraintlayout.widget.ConstraintLayout>
2.阴影shape文件
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#33000000" />
</shape>
3.自定义最终的LoadingView
package com.crystal.view.animation
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.content.Context
import android.util.AttributeSet
import android.util.TypedValue
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import com.crystal.view.R
/**
* 自定义加载动画
* on 2022/11/9
*/
class LoadingView : LinearLayout {
/**
* 上方形状
*/
private var shapeView: ShapeView
/**
* 下方阴影缩放
*/
private var shadowView: View
/**
* 下落高度
*/
private var translationYValue = 0f
/**
* 动画时长
*/
private val DURATION_TIME = 500L
/**
* 用于判断动画是否已停止
*/
private var isStopLoading = false
constructor(context: Context) : this(context, null)
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
context, attrs, defStyleAttr
) {
//就布局添加到当前ViewGroup中
inflate(getContext(), R.layout.layout_loading_view, this)
shapeView = findViewById(R.id.shape_view)
shadowView = findViewById(R.id.shadow_view)
translationYValue =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 80f, resources.displayMetrics)
post {
//1.进来就先下落,同时阴影进行缩放,当view绘制完成后开启动画
downAnimation()
}
}
private fun downAnimation() {
if (isStopLoading) {
return
}
//shapeView 下降
val shapeViewAnimation =
ObjectAnimator.ofFloat(shapeView, "translationY", 0f, translationYValue)
//shadowView缩小
val shadowViewAnimation = ObjectAnimator.ofFloat(shadowView, "scaleX", 1f, 0.3f)
val downAnimatorSet = AnimatorSet()
downAnimatorSet.playTogether(shapeViewAnimation, shadowViewAnimation)
downAnimatorSet.duration = DURATION_TIME
downAnimatorSet.start()
downAnimatorSet.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
//监听动画结束后,进行上升操作,同时伴随着shapeView旋转
upAnimation()
}
})
}
/**
* 向上动画
*/
private fun upAnimation() {
//shapeView上升
val shapeViewAnimation =
ObjectAnimator.ofFloat(shapeView, "translationY", translationYValue, 0f)
//shadowView放大
val shadowViewAnimation = ObjectAnimator.ofFloat(shadowView, "scaleX", 0.3f, 1f)
shapeView.exchangeShape()
val upAnimatorSet = AnimatorSet()
upAnimatorSet.playTogether(shapeViewAnimation, shadowViewAnimation, remoteShapeView())
upAnimatorSet.duration = DURATION_TIME
upAnimatorSet.start()
upAnimatorSet.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
//监听动画结束后,进行下降操作
downAnimation()
}
})
}
/**
* shapeView旋转动画
*/
private fun remoteShapeView(): ObjectAnimator {
return when (shapeView.getCurrentShape()) {
ShapeView.Shape.CIRCLE, ShapeView.Shape.SQUARE -> {
//旋转180度
ObjectAnimator.ofFloat(shapeView, "rotation", 0f, 180f)
}
else -> {
//旋转120度
ObjectAnimator.ofFloat(shapeView, "rotation", 0f, 120f)
}
}
}
fun stopAnimation() {
isStopLoading = true
//清除动画
shapeView.clearAnimation()
shadowView.clearAnimation()
if (parent != null) {
//移除当前View
(parent as ViewGroup).removeView(this)
removeAllViews()
}
}
}
总结
自定义View看似很难,其实将View一点点进行拆分成每一步的实现,就会变的十分容易,同时通过这个例子体会到属性动画的重要性,它真的是无所不能,无处不在。
结语
如果以上文章对您有一点点帮助,希望您不要吝啬的点个赞加个关注,您每一次小小的举动都是我坚持写作的不懈动力!ღ( ´・ᴗ・` )