Android 红包雨效果自定义控件

WX20201231-181616@2x.png

思路:利用Path绘制动画轨迹,再使用PathMeasure获取轨迹中的坐标位置实时改变view的坐标完成红包动画。

封装一个红包容器view用于管理大量红包view的显示、动画、消失、回收利用

package com.cj.customwidget.widget

import android.content.Context
import android.graphics.*
import android.os.Handler
import android.util.AttributeSet
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.Animation
import android.widget.FrameLayout
import androidx.annotation.LayoutRes
import androidx.core.view.children

/**
 * File FallingView.kt
 * Date 12/25/20
 * Author lucas
 * Introduction 飘落物件控件
 *              规则:通过适配器实现
 */
class FallingView : FrameLayout, Runnable {
    private val TAG = FallingView::class.java.simpleName
    private var handlerTask = Handler()
    private var iFallingAdapter: IFallingAdapter<*>? = null
    private var position = 0//当前item
    private var fallingListener: OnFallingListener? = null
    private var lastStartTime = 0L//最后一个item开始显示的延迟时间

    private val cacheHolder = HashSet<Holder>()//缓存holder,用于复用,减少item view创建的个数

    constructor(context: Context) : super(context) {
        initView(context, null)
    }

    constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
        initView(context, attrs)
    }

    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
        initView(context, attrs)
    }

    private fun initView(context: Context, attrs: AttributeSet?) {
//        setWillNotDraw(false)//放开注释可显示辅助线
    }

    //开始飘落
    fun startFalling() {
        if (iFallingAdapter == null) {
            Log.e(TAG, "iFallingAdapter not be null.")
            return
        }
        position = 0
        handlerTask.post(this)
    }

    //停止飘落
    fun stopFalling() {
        handlerTask.removeCallbacks(this)
        //停止所有动画
        children.forEach {
            it.clearAnimation()
        }
        removeAllViews()
    }

    override fun run() {
        iFallingAdapter?.also { adapter ->
            if (adapter.datas.isNullOrEmpty() || position > adapter.datas!!.size - 1) return
//            "position:$position".p()
            showItem(adapter)
            invalidate()
        }
    }

    private fun showItem(adapter: IFallingAdapter<*>) {
        if (position == 0) {
            fallingListener?.onStart()
        }
        var holder: Holder
        if (cacheHolder.isEmpty()) {
            val inflate = LayoutInflater.from(context).inflate(adapter.layoutId, this, false)
            holder = Holder(inflate)
        } else {//从缓存中获取holder
            val iterator = cacheHolder.iterator()
            holder = iterator.next()
            iterator.remove()
        }
        holder.position = position
        addView(holder.view)
        adapter.convert(this, holder)
        holder.config.anim = adapter.convertAnim(this, holder)
        holder.config.anim?.setAnimationListener(object : Animation.AnimationListener {
            override fun onAnimationRepeat(animation: Animation?) {
            }

            override fun onAnimationEnd(animation: Animation?) {
                //将item加入缓存以复用
                cacheHolder.add(holder)
                removeView(holder.view)
                if (childCount == 0 && adapter.datas?.size == position + 1) {
                    fallingListener?.onStop()
                }
//                "cacheHolder:${cacheHolder.size}".p()
            }

            override fun onAnimationStart(animation: Animation?) {
            }
        })
        holder.view.startAnimation(holder.config.anim)
        //显示完一个item后准备显示下一个item
        handlerTask.postDelayed(this, holder.config.startTime - lastStartTime)
        lastStartTime = holder.config.startTime
        position++
    }

    //设置适配器
    fun <T> setAdapter(adapter: IFallingAdapter<T>) {
        iFallingAdapter = adapter
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        //辅助线
        cacheHolder.forEach { enty ->
            enty.config.path?.also { assistLine(it, canvas) }
        }
    }


    private val paint = Paint().apply {
        style = Paint.Style.STROKE
        color = Color.RED
        strokeWidth = 4f
    }

    //辅助线
    private fun assistLine(path: Path, canvas: Canvas) {
        canvas.drawPath(path, paint)
    }


    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()
        stopFalling()
    }

    class Holder(val view: View) {
        var config: Config = Config()
        var position: Int = 0
    }

    //适配器
    abstract class IFallingAdapter<T>(@LayoutRes val layoutId: Int) {
        var datas: List<T>? = null

        //复用
        abstract fun convert(parent: ViewGroup, holder: Holder)

        //创建动画轨迹
        abstract fun convertAnim(parent: ViewGroup, holder: Holder): Animation

    }

    //初始化配置
    class Config {
        var startTime = 0L//开始发射时间
        var anim: Animation? = null
        var path: Path? = null
    }

    fun setOnFallingListener(onFallingListener: OnFallingListener) {
        fallingListener = onFallingListener
    }

    interface OnFallingListener {
        fun onStart()

        fun onStop()
    }

}

单个红包view动画轨迹设置

package com.cj.customwidget.page.falling

import android.graphics.Path
import android.graphics.PathMeasure
import android.view.View
import android.view.animation.Animation
import android.view.animation.Transformation
import java.util.*

class RedPackAnim(val path: Path, val rotation: Float, val view: View) : Animation() {
    val pathMeasure = PathMeasure(path, false)
    val point = FloatArray(2)
    val tan = FloatArray(2)

    override fun applyTransformation(interpolatedTime: Float, t: Transformation) {
        pathMeasure.getPosTan(pathMeasure.length * interpolatedTime, point, tan)
        view.x = point[0] - view.measuredWidth / 2
        view.y = point[1]
        view.rotation = rotation * interpolatedTime
//        "point:${point.toList()}".p()
    }
}

适配器:用于定义红包view的样式、轨迹路线、动画属性、数据

package com.cj.customwidget.page.falling

import android.graphics.Path
import android.view.View
import android.view.ViewGroup
import android.view.animation.Animation
import android.widget.ImageView
import com.cj.customwidget.R
import com.cj.customwidget.widget.FallingView
import java.util.*
import kotlin.collections.ArrayList

class FallingAdapter : FallingView.IFallingAdapter<Int>(R.layout.item_redpack) {
    private val random = Random()
    private val animDuration = 6000L//物件动画时长
    private val count = 10//一屏显示物件的个数

    private val animInterval = ArrayList<Interval>()

    fun setData(data: List<Int>) {
        datas = data
    }

    private fun createPath(parent: ViewGroup, position: Int, view: View): Path =
        Path().apply {
            view.measure(0, 0)
            val width = parent.width - view.measuredWidth
            val height = parent.height
            val swing = width / 3f//x轴摆动范围
            //限制动画区间使物件分布均匀
            if (animInterval.isEmpty()) {
                animInterval.add(Interval(view.measuredWidth / 2f, swing))
                animInterval.add(Interval(swing, swing * 2))
                animInterval.add(Interval(swing * 2, parent.width - view.measuredWidth / 2f))
            }
//            "animInterval:${animInterval.size}".p()
            val interval: Interval
            if (animInterval.size == 1) {
                interval = animInterval[0]
            } else {
                interval = animInterval[random.nextInt(animInterval.size)]
            }
            animInterval.remove(interval)
            val startPointX = random.nextInt(width).toFloat()
            moveTo(startPointX, -view.measuredHeight.toFloat())

            //控制点
            var point1X = random.nextInt(interval.getLength().toInt()) + interval.start
            val point1Y = random.nextInt(height / 2).toFloat()

            var point2X = random.nextInt(interval.getLength().toInt()) +interval.start
            val point2Y = random.nextInt(height / 2).toFloat() + height / 2

            var point3X = random.nextInt(interval.getLength().toInt()) + interval.start

            cubicTo(point1X, point1Y, point2X, point2Y, point3X, height.toFloat())
        }


    override fun convert(parent: ViewGroup, holder: FallingView.Holder) {
        if (holder.position%20==0){
            (holder.view as ImageView).setImageResource(R.mipmap.ic_readpack2)
        }else{
            (holder.view as ImageView).setImageResource(R.mipmap.ic_readpack)
        }
        holder.config.startTime = holder.position * (animDuration / count)
        holder.view.setOnClickListener {//点中红包回调
//            holder.view.clearAnimation()
//            holder.view.visibility = View.GONE
        }
    }

    override fun convertAnim(parent: ViewGroup, holder: FallingView.Holder): Animation {
        val path = createPath(parent, holder.position, holder.view)
        holder.config.path = path
        //旋转方向
        val rotation:Float
        if (random.nextInt(2)==0){
            rotation = 30f*random.nextFloat()
        }else{
            rotation = -30f*random.nextFloat()
        }
        val redPackAnim = RedPackAnim(path, rotation, holder.view)
        //动画时长-下落速度
        redPackAnim.duration = (animDuration*(0.6+random.nextInt(4)*0.1)).toLong()
        return redPackAnim
    }

    //区间
    class Interval(val start: Float, val end: Float) {
        fun getLength() = end - start
    }
}

使用方式,在布局中添加view

<?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"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:orientation="vertical"
    android:layout_height="match_parent"
    tools:context=".page.falling.FallingActivity">

    <com.cj.customwidget.widget.FallingView
        android:id="@+id/v_falling"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</LinearLayout>

在界面中定义适配器,添加红包数据

package com.cj.customwidget.page.falling

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.view.animation.Animation
import android.view.animation.Transformation
import com.cj.customwidget.R
import com.cj.customwidget.ext.p
import kotlinx.android.synthetic.main.activity_falling.*

/**
 * File FallingActivity.kt
 * Date 12/25/20
 * Author lucas
 * Introduction 红包雨
 */
class FallingActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_falling)
        v_falling.setAdapter(FallingAdapter().apply { setData(List(100){it}) })
        v_falling.startFalling()
    }
}

源码地址:https://github.com/LucasDevelop/CustomView。中的(Falling)部分

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容