一个简单的view,用来实现Android开发中,礼物喷发(粒子喷发),随机位移到终点的效果.代码设置了view中心点为起点,随机位移到距离中心点100dp的点.

data class AnimationItem(
val pathMeasure: PathMeasure,
val animator: ValueAnimator,
var currentX: Float,
var currentY: Float,
var currentAlpha: Int,
var totalDistance: Float,
val bitmap: Bitmap
)

class RandomCurveView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

private val IMAGE_IDS = listOf(
R.drawable.cat1,
R.drawable.cat2,
R.drawable.cat3,
R.drawable.cat4,
R.drawable.cat5,
R.drawable.cat6,
R.drawable.cat7,
R.drawable.cat8,
R.drawable.cat9,
R.drawable.cat10,
R.drawable.cat11,
R.drawable.cat12,
R.drawable.cat13,
R.drawable.cat14,
R.drawable.cat15
)

private val activeItems = CopyOnWriteArrayList<AnimationItem>()

private val ANIMATION_DURATION_MS = 800L
private val TRAVEL_DISTANCE_DP = 100f
private val RANDOM_OFFSET_DP = 30f

private val itemPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private lateinit var imageMap: Map<Int, Bitmap>
private var itemSize: Int = 0

private val travelDistancePx: Float
private val randomOffsetPx: Float

init {
isClickable = true

travelDistancePx = context.dpToPx(TRAVEL_DISTANCE_DP)
randomOffsetPx = context.dpToPx(RANDOM_OFFSET_DP)

itemSize = context.dpToPx(24f).roundToInt()
val targetSize = itemSize

val loadedImages = mutableMapOf<Int, Bitmap>()

IMAGE_IDS.forEach { id ->
try {
val resource = BitmapFactory.decodeResource(resources, id)

resource?.let { r ->
loadedImages[id] = Bitmap.createScaledBitmap(r, targetSize, targetSize, true)
}
} catch (e: Exception) {
Log.e(
"RandomCurveView",
"Resource loading failed for ID: $id. Please check if this R.drawable.xxx exists.",
e
)
}
}

imageMap = loadedImages.toMap()

if (imageMap.isEmpty()) {
throw IllegalStateException("RandomCurveView failed to initialize. No valid images were loaded from IMAGE_IDS list. Please update IMAGE_IDS with at least one existing R.drawable.xxx resource.")
}
}

override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)

val halfItemSize = itemSize / 2f

activeItems.forEach { item ->
itemPaint.alpha = item.currentAlpha

canvas.drawBitmap(
item.bitmap,
item.currentX - halfItemSize,
item.currentY - halfItemSize,
itemPaint
)
}
}

override fun performClick(): Boolean {
startCurveAnimation()
startCurveAnimation()
startCurveAnimation()
return super.performClick()
}

private fun startCurveAnimation() {
if (width == 0 || height == 0 || imageMap.isEmpty()) return

val startPoint = PointF(width / 2f, height / 2f)

val randomBitmap = imageMap.values.random()

val randomAngle = Random.nextFloat() * 360f
val endPoint = calculateEndPoint(startPoint, travelDistancePx, randomAngle)

val pMid1 = lerp(startPoint, endPoint, 0.33f)
val pMid2 = lerp(startPoint, endPoint, 0.66f)

val controlPoint1 = offsetPointRandomly(pMid1, randomAngle, randomOffsetPx)
val controlPoint2 = offsetPointRandomly(pMid2, randomAngle, randomOffsetPx)

val path = Path().apply {
moveTo(startPoint.x, startPoint.y)
cubicTo(
controlPoint1.x, controlPoint1.y,
controlPoint2.x, controlPoint2.y,
endPoint.x, endPoint.y
)
}

val pathMeasure = PathMeasure(path, false)
val totalDistance = pathMeasure.length
val pos = FloatArray(2)
val item = AnimationItem(
pathMeasure,
ValueAnimator.ofFloat(0f, 1f),
startPoint.x, startPoint.y, 255, totalDistance,
randomBitmap
)
activeItems.add(item)

item.animator.apply {
duration = ANIMATION_DURATION_MS
interpolator = LinearInterpolator()

addUpdateListener { animator ->
val progress = animator.animatedValue as Float

pathMeasure.getPosTan(totalDistance * progress, pos, null)
item.currentX = pos[0]
item.currentY = pos[1]

item.currentAlpha = if (progress >= 0.8f) {
val fadeProgress = (progress - 0.8f) / 0.2f
(255 * (1f - fadeProgress)).roundToInt().coerceIn(0, 255)
} else {
255
}

invalidate()
}

addListener(object : Animator.AnimatorListener {
override fun onAnimationStart(animator: Animator) {}
override fun onAnimationCancel(animator: Animator) {}
override fun onAnimationRepeat(animator: Animator) {}

override fun onAnimationEnd(animator: Animator) {
activeItems.remove(item)
}
})
start()
}
}
}