One, foreword

We can see the animation effect of “like” on Toutiao APP, which feels very good. It happens that the company has a demand for “like” animation, so we have explored the realization of this function in the following.

Second, demand separation

A closer look at the likes interaction reveals the following steps:

1: The “like” control needs to be customized to handle its touch events.

2: The implementation of thumbs-up animation.

3: Have a container for animation.

Iii. Implementation scheme

1, like control touch event processing

The “like” control is to distinguish between long press and click processing, and we found that the animation is executed after the finger is pressed, including the movement of the finger until the finger is lifted. Because the clicking area of “like” may include the number of “like” times, we define the “like” control here, and process onTouchEvent(Event: MotionEvent) event. The long press and click are distinguished by the interval between “click” and “finger lift”. The pseudo-code is as follows:

override fun onTouchEvent(event: MotionEvent): Boolean { var onTouch: ACTION_DOWN -> {isRefreshing = false isDowning = true // Click lastDownTime = System.currentTimeMillis() postDelayed(autoPollTask, CLICK_INTERVAL_TIME) onTouch = true} motionEvent.action_UP -> {isDowning = false // Lift if (system.currentTimemillis () - LastDownTime < CLICK_INTERVAL_TIME) {// Less than interval processing onFingerDowningListener as per click? .onDown(this)} else {// Greater than or equal to the interval Handle onFingerDowningListener according to long press? .onUp() } removeCallbacks(autoPollTask) onTouch = true } MotionEvent.ACTION_CANCEL ->{ isDowning = false removeCallbacks(autoPollTask) onTouch = false } else -> onTouch = false } return onTouch }Copy the code

Use Runnable’s postDelayed(Runnable Action, long delayMillis) method for continuous execution of animation, pseudo code:

private inner class AutoPollTask : Runnable { override fun run() { onFingerDowningListener? .onLongPress(this@LikeView) if(! canLongPress){ removeCallbacks(autoPollTask) }else{ postDelayed(autoPollTask, CLICK_INTERVAL_TIME) } } }Copy the code

2, the implementation of the like animation

Thumbs-up effect elements are divided into: thumbs-up expression icon, thumbs-up number and thumbs-up copy

2.1. Acquisition and storage management of thumbs-up effect pictures

Here, referring to the practice of SuperLike, the image is cached, the code is as follows:

object BitmapProviderFactory {
    fun getProvider(context: Context): BitmapProvider.Provider {
        return BitmapProvider.Builder(context)
            .setDrawableArray(
                intArrayOf(
                        R.mipmap.emoji_1, R.mipmap.emoji_2, R.mipmap.emoji_3,
                        R.mipmap.emoji_4, R.mipmap.emoji_5, R.mipmap.emoji_6,
                        R.mipmap.emoji_7, R.mipmap.emoji_8, R.mipmap.emoji_9, R.mipmap.emoji_10,
                        R.mipmap.emoji_11, R.mipmap.emoji_12, R.mipmap.emoji_13,
                        R.mipmap.emoji_14
                )
            )
            .setNumberDrawableArray(
                intArrayOf(
                        R.mipmap.multi_digg_num_0, R.mipmap.multi_digg_num_1,
                        R.mipmap.multi_digg_num_2, R.mipmap.multi_digg_num_3,
                        R.mipmap.multi_digg_num_4, R.mipmap.multi_digg_num_5,
                        R.mipmap.multi_digg_num_6, R.mipmap.multi_digg_num_7,
                        R.mipmap.multi_digg_num_8, R.mipmap.multi_digg_num_9
                )
            )
            .setLevelDrawableArray(
                intArrayOf(
                        R.mipmap.multi_digg_word_level_1, R.mipmap.multi_digg_word_level_2,
                        R.mipmap.multi_digg_word_level_3
                )
            )
            .build()
    }
}
Copy the code
object BitmapProvider { class Default( private val context: Context, cacheSize: Int, @DrawableRes private val drawableArray: IntArray, @DrawableRes private val numberDrawableArray: IntArray? , @DrawableRes private val levelDrawableArray: IntArray? , private val levelStringArray: Array<String>? , private val textSize: Float ) : Provider { private val bitmapLruCache: LruCache<Int, Bitmap> = LruCache(cacheSize) private val NUMBER_PREFIX = 0x70000000 Private val LEVEL_PREFIX = -0x80000000 /** * Obtain the digital image  * @param number * @return */ override fun getNumberBitmap(number: Int): Bitmap? { var bitmap: Bitmap? if (numberDrawableArray ! = null && numberDrawableArray.isNotEmpty()) { val index = number % numberDrawableArray.size bitmap = bitmapLruCache[NUMBER_PREFIX or numberDrawableArray[index]] if (bitmap == null) { bitmap = BitmapFactory.decodeResource(context.resources, numberDrawableArray[index]) bitmapLruCache.put(NUMBER_PREFIX or numberDrawableArray[index], bitmap) } } else { bitmap = bitmapLruCache[NUMBER_PREFIX or number] if (bitmap == null) { bitmap = createBitmapByText(textSize, number.toString()) bitmapLruCache.put(NUMBER_PREFIX or number, * @param level * @return */ Override fun getLevelBitmap(level: Int): Bitmap? { var bitmap: Bitmap? if (levelDrawableArray ! = null && levelDrawableArray.isNotEmpty()) { val index = level.coerceAtMost(levelDrawableArray.size) bitmap = bitmapLruCache[LEVEL_PREFIX or levelDrawableArray[index]] if (bitmap == null) { bitmap = BitmapFactory.decodeResource(context.resources, levelDrawableArray[index]) bitmapLruCache.put(LEVEL_PREFIX or levelDrawableArray[index], bitmap) } } else { bitmap = bitmapLruCache[LEVEL_PREFIX or level] if (bitmap == null && ! levelStringArray.isNullOrEmpty()) { val index = level.coerceAtMost(levelStringArray.size) bitmap = createBitmapByText(textSize, levelStringArray[index]) bitmapLruCache.put(LEVEL_PREFIX or level, Return (int int) {return int int (int int); Bitmap get() { val index = (Math.random() * drawableArray.size).toInt() var bitmap = bitmapLruCache[drawableArray[index]] if (bitmap == null) { bitmap = BitmapFactory.decodeResource(context.resources, drawableArray[index]) bitmapLruCache.put(drawableArray[index], bitmap) } return bitmap } private fun createBitmapByText(textSize: Float, text: String): Bitmap { val textPaint = TextPaint() textPaint.color = Color.BLACK textPaint.textSize = textSize val bitmap = Bitmap.createBitmap( textPaint.measureText(text).toInt(), textSize.toInt(), Bitmap.Config.ARGB_4444 ) val canvas = Canvas(bitmap) canvas.drawColor(Color.TRANSPARENT) canvas.drawText(text, 0f, textSize, textPaint) return bitmap } } class Builder(var context: Context) { private var cacheSize = 0 @DrawableRes private var drawableArray: IntArray? = null @DrawableRes private var numberDrawableArray: IntArray? = null @DrawableRes private var levelDrawableArray: IntArray? = null private var levelStringArray: Array<String>? = null private var textSize = 0f fun setCacheSize(cacheSize: Int): Builder {this.cacheSize = cacheSize return this} /** * Set up an image * @param drawableArray * @return */ fun setDrawableArray(@DrawableRes drawableArray: IntArray?): Builder {this.drawableArray = drawableArray return this} /** * Set number of pictures * @param numberAblearray * @return */ fun setNumberDrawableArray(@DrawableRes numberDrawableArray: IntArray): Builder {this.numberDrawableArray = numberDrawableArray return this} @param levelDrawableArray @return */ fun setLevelDrawableArray(@DrawableRes levelDrawableArray: IntArray?): Builder { this.levelDrawableArray = levelDrawableArray return this } fun setLevelStringArray(levelStringArray: Array<String>?): Builder { this.levelStringArray = levelStringArray return this } fun setTextSize(textSize: Float): Builder { this.textSize = textSize return this } fun build(): Provider { if (cacheSize == 0) { cacheSize = 32 } if (drawableArray == null || drawableArray?.isEmpty() == true) { drawableArray = intArrayOf(R.mipmap.emoji_1) } if (levelDrawableArray == null && levelStringArray.isNullOrEmpty()) { LevelStringArray = arrayOf(" Thumbs up! ", "Great!!" ", "I agree!!" ) } return Default( context, cacheSize, drawableArray!! , numberDrawableArray, levelDrawableArray, levelStringArray, TextSize)}} interface Provider {** ** get a random expression */ val randomBitmap: Bitmap(number: Int): Bitmap? Fun getLevelBitmap(level: Int): Bitmap? }}Copy the code

2.2. Animation of “Like” emoji icon

The implementation here refers to toutiaothumb, and the animation of expression ICONS can be roughly divided into: ascending animation is performed at the same time as icon size change animation and icon transparency change, and descending animation is performed when ascending animation is completed. The code is as follows:

class EmojiAnimationView @JvmOverloads constructor( context: Context, private val provider: BitmapProvider.Provider? , attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : View(context, attrs, defStyleAttr) { private var mThumbImage: Bitmap? = null private var mBitmapPaint: Paint? = null private var mAnimatorListener: AnimatorListener? Private var emojiHeight = 0 private var emojiHeight = 0 private var emojiHeight = 0 private fun init() { // Initialize the image and fetch the random icon mThumbImage = provider? .randombitMap} init {// Initialize mBitmapPaint = paint () mBitmapPaint? .isantiAlias = true} private fun showAnimation() {val imageWidth = mThumbImage? .width ? :0 val imageHeight = mThumbImage? .height ? :0 val topX = -1080 + (1400 * math.random ()).tofloat () val topY = -300 + (-700 * math.random ()).tofloat () // Raise animation val translateAnimationX = ObjectAnimator.ofFloat(this, "translationX", 0f, topX) translateAnimationX.duration = DURATION.toLong() translateAnimationX.interpolator = LinearInterpolator() val translateAnimationY = ObjectAnimator.ofFloat(this, "translationY", 0f, topY) translateAnimationY.duration = DURATION.toLong() translateAnimationY.interpolator = DecelerateInterpolator() / / images that change the size of the val translateAnimationRightLength = ObjectAnimator. OfInt (this, "emojiWith", 0,imageWidth,imageWidth,imageWidth,imageWidth, imageWidth, imageWidth, imageWidth, imageWidth, imageWidth ) translateAnimationRightLength.duration = DURATION.toLong() val translateAnimationBottomLength = ObjectAnimator.ofInt( this, "emojiHeight", 0,imageHeight,imageHeight,imageHeight,imageHeight,imageHeight, imageHeight, imageHeight, imageHeight, imageHeight ) translateAnimationBottomLength.duration = DURATION.toLong() TranslateAnimationRightLength. AddUpdateListener {invalidate ()} / / transparency change val alphaAnimation. = ObjectAnimator ofFloat ( This, "alpha", 0.8 f, f 1.0, 1.0 f, f 1.0, 0.9 f, f 0.8, 0.8 f, f 0.7, 0.6, f 0f) alphaanimation.duration = duration.tolong () val animatorSet = animatorSet () animatorSet.play(translateAnimationX).with(translateAnimationY) With (translateAnimationRightLength) with (translateAnimationBottomLength) with val (alphaAnimation) / / decline in animation translateAnimationXDown = ObjectAnimator.ofFloat(this, "translationX", topX, TopX * 1.2 f) translateAnimationXDown. Duration = (duration / 5) toLong () translateAnimationXDown. Interpolator = LinearInterpolator() val translateAnimationYDown = ObjectAnimator.ofFloat(this, "translationY", topY, TopY * 0.8 f) translateAnimationYDown. Duration = (duration / 5) toLong () translateAnimationYDown. Interpolator = AccelerateInterpolator() // Sets the playing order of animation val animatorSetDown = AnimatorSet() animatorset.start () animatorSet.addListener(object : Animator.AnimatorListener { override fun onAnimationStart(animation: Animator) {} override fun onAnimationEnd(animation: Animator) { animatorSetDown.play(translateAnimationXDown).with(translateAnimationYDown) animatorSetDown.start() } override fun onAnimationCancel(animation: Animator) {} override fun onAnimationRepeat(animation: Animator) {} }) animatorSetDown.addListener(object : Animator.AnimatorListener { override fun onAnimationStart(animation: Animator) {} override fun onAnimationEnd(animation: View mAnimatorListener?. OnAnimationEmojiEnd ()} Override fun onAnimationCancel(animation: Animator) {} override fun onAnimationRepeat(animation: Animator) {} }) } override fun onDraw(canvas: Canvas) {super.ondraw (Canvas) drawEmojiImage(Canvas)} / private fun drawEmojiImage(Canvas: Canvas) { mThumbImage?.let{ val dst = Rect() dst.left = 0 dst.top = 0 dst.right = emojiWith dst.bottom = emojiHeight Canvas. DrawBitmap (it, NULL, DST, mBitmapPaint)}} Int { return emojiWith } fun setEmojiWith(emojiWith: Int) { this.emojiWith = emojiWith } fun getEmojiHeight(): Int { return emojiHeight } fun setEmojiHeight(emojiHeight: Int) { this.emojiHeight = emojiHeight } fun setEmojiAnimation() { showAnimation() } fun setAnimatorListener(animatorListener: AnimatorListener?) {mAnimatorListener = AnimatorListener} interface AnimatorListener {/** * End of animation */ fun OnAnimationEmojiEnd ()} fun setEmoji() {init()} companion object {// animationEmojiend ()}Copy the code

2.3. The number of likes and the drawing of likes

The likes count here is processed from 1 to 999 and displays different likes in different likes ranges. The code is as follows:

class NumberLevelView @JvmOverloads constructor( context: Context, private val provider: BitmapProvider.Provider? , private val x: Int, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : View(context, attrs, defStyleAttr) { private var textPaint: Paint = Paint()/private var mNumber = 0 / private var mNumber = 0 / private var mNumber = 0 = null /** * level */ private var level = 0 /** * number of imagewidth = 0 /** * number of imagewidth */ private var level = 0 /** * Number of imagewidth = 0 /** * Number of imagewidth */ private var */ private var initialValue = 0 */ private var initialValue = 0 textPaint.isAntiAlias = true initialValue = x - PublicMethod.dp2px(context, 120f) numberImageWidth = provider? .getNumberBitmap(1)? .width ? : 0 spacing = PublicMethod.dp2px(context, 10f) } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) val levelBitmap = provider? .getLevelBitmap(level) ? Val levelBitmapWidth = LevelBitmap. width val DST = Rect() when (mNumber) {in 0.. 9 -> { initialValue = x - levelBitmapWidth dst.left = initialValue dst.right = initialValue + levelBitmapWidth } in 10.. 99 -> { initialValue = x - PublicMethod.dp2px(context, 100f) dst.left = initialValue + numberImageWidth + spacing dst.right = initialValue+ numberImageWidth + spacing+ levelBitmapWidth } else -> { initialValue = x - PublicMethod.dp2px(context, 120f) dst.left = initialValue + 2*numberImageWidth + spacing dst.right = initialValue+ 2*numberImageWidth + spacing + Top = 0 dst.bottom = levelBitmap.height canvas. DrawBitmap (levelBitmap, null, DST, textPaint) while (mNumber > 0) { val number = mNumber % 10 val bitmap = provider.getNumberBitmap(number)? Val rect = rect () rect.top = 0 when {mNumber/ 10 < 1 -> {rect.left = initialValue - bitmap.width rect.right = initialValue } mNumber/ 10 in 1.. 9 -> { rect.left = initialValue rect.right = initialValue + bitmap.width } else -> { rect.left = initialValue + Bitmap. width rect.right = initialValue +2* bitmap.width}} Rect. bottom = bitmap.height  null, rect, textPaint) mNumber /= 10 } } fun setNumber(number: Int) { this.mNumber = number if (mNumber >999){ mNumber = 999 } level = when (mNumber) { in 1.. 20 -> { 0 } in 21.. 80 -> {1} else -> {2}} .getLevelBitmap(level) invalidate() } }Copy the code

3. A container for storing thumbs-up animations

We need to customize a view to hold the animation, as well as start the animation and recycle the animation view. The code is as follows:

class LikeAnimationLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : FrameLayout(context, attrs, defStyleAttr) { private var lastClickTime: Long = 0 private var currentNumber = 1 private var mNumberLevelView: NumberLevelView? / / private var hasextAnimation = false / / private var hasextAnimation = false / / private var hasextAnimation = False /** * Whether to hold down, */ private var maxAngle = 0 private var minAngle = 0 */ private var maxAngle = 0  private var pointX = 0 private var pointY = 0 var provider: BitmapProvider.Provider? = null get() { if (field == null) { field = BitmapProvider.Builder(context) .build() } return field } private fun init(context: Context, attrs: AttributeSet? , defStyleAttr: Int) { val typedArray = context.obtainStyledAttributes( attrs, R.styleable.LikeAnimationLayout, defStyleAttr, 0 ) maxAngle = typedArray.getInteger(R.styleable.LikeAnimationLayout_max_angle, MAX_ANGLE) minAngle = typedArray.getInteger(R.styleable.LikeAnimationLayout_min_angle, MIN_ANGLE) hasEruptionAnimation = typedArray.getBoolean( R.styleable.LikeAnimationLayout_show_emoji, true ) hasTextAnimation = typedArray.getBoolean(R.styleable.LikeAnimationLayout_show_text, True) TypeDarray.recycle ()} / private Fun addEmojiView(context: context? , x: Int, y: Int ) { for (i in 0 .. ERUPTION_ELEMENT_AMOUNT) { val layoutParams = RelativeLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT) layoutParams.setMargins(x, y, 0, 0) val articleThumb = context? .let { EmojiAnimationView( it, provider ) } articleThumb? .let { it.setEmoji() this.addView(it, -1, layoutParams) it.setAnimatorListener(object : EmojiAnimationView.AnimatorListener { override fun onAnimationEmojiEnd() { removeView(it) val handler = Handler() handler.postDelayed({ if (mNumberLevelView ! = null && System.currentTimeMillis() - lastClickTime >= SPACING_TIME) { removeView(mNumberLevelView) mNumberLevelView = Null}}, SPACING_TIME)}}) it.setemojiAnimation ()}} /** * Launch (x: Int, y: Int) {if (system.currentTimemillis () -lastClickTime >= SPACING_TIME) {pointX = x pointY = y // Single click addEmojiView(context, x, y-50) lastClickTime = System.currentTimeMillis() currentNumber = 1 if (mNumberLevelView ! = null) {removeView(mNumberLevelView) mNumberLevelView = null}} else {// click if (pointX! = x || pointY ! = y){return} lastClickTime = system.currentTimemillis () log. I (TAG, "currently animating ") addEmojiView(context, x, Y) / / add digital combo view val layoutParams = RelativeLayout. LayoutParams (ViewGroup. LayoutParams. MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT ) layoutParams.setMargins(0, y - PublicMethod.dp2px(context, 60f), 0, 0) if (mNumberLevelView == null) { mNumberLevelView = NumberLevelView(context,provider,x) addView(mNumberLevelView, layoutParams) } currentNumber++ mNumberLevelView? .setNumber(currentNumber)}} Companion object {private const val TAG = "LikeAnimationLayout" /** * */ private const val ERUPTION_ELEMENT_AMOUNT = 8 private const val MAX_ANGLE = 180 private const val MIN_ANGLE = 70 private const val SPACING_TIME = 400L } init { init(context, attrs, defStyleAttr) } }Copy the code

Note: Be sure to clear the view after the animation is complete.

4. Problems encountered

Because the SmartRefreshLayout drop-down refresh control is used in the stream list, if you animate the first few items in the list, the touch event will be intercepted by SmartRefreshLayout to perform the drop-down refresh, and the like control will not respond when the finger is raised, and the animation will continue. The solution I think of at present is to check whether SmartRefreshLayout is the parent layout by clicking the like control when the finger is pressed down. If there is a reflection to disable the pull-down refresh function, the finger is lifted or cancelled to reset the operation. The code is as follows:

override fun dispatchTouchEvent(event: MotionEvent?) : Boolean { parent? .requestDisallowInterceptTouchEvent(true) return super.dispatchTouchEvent(event) } override fun onTouchEvent(event: MotionEvent): Boolean { var onTouch: ACTION_DOWN -> {isRefreshing = false isDowning = true // Click lastDownTime = System.currenttimemillis () findSmartRefreshLayout(false) if (isRefreshing) {return false} postDelayed(autoPollTask, CLICK_INTERVAL_TIME) onTouch = true} motionEvent.action_UP -> {isDowning = false // Lift if (system.currentTimemillis () - LastDownTime < CLICK_INTERVAL_TIME) {// Less than interval processing onFingerDowningListener as per click? .onDown(this)} else {// Greater than or equal to the interval Handle onFingerDowningListener according to long press? .onUp() } findSmartRefreshLayout(true) removeCallbacks(autoPollTask) onTouch = true } MotionEvent.ACTION_CANCEL ->{ isDowning = false findSmartRefreshLayout(true) removeCallbacks(autoPollTask) onTouch = false } else -> onTouch = false } Return onTouch} /** * If the parent layout has SmartRefreshLayout control, set whether the control is available */ private fun findSmartRefreshLayout(enable: Boolean) { var parent = parent while (parent ! = null && parent ! is ContentFrameLayout) { if (parent is SmartRefreshLayout) { isRefreshing = parent.state == RefreshState.Refreshing if (isRefreshing){break} if (! enable && firstClick){ try { firstClick = false val field: Field = parent. JavaClass. GetDeclaredField (" mEnableRefresh ") Field. IsAccessible = true / / by reflection can get drop-down refresh the initial value of the first enableRefresh = field.getBoolean(parent) }catch (e: Exception) {e.printStackTrace()}} if (enableRefresh){// Do not set the pull-down state if the initial value is not pull-down parent.setEnableLoadMore(enable) break } else { parent = parent.parent } } }Copy the code

Five, to achieve the effect

Vi. Complete code acquisition

Click to get the source code

Vii. Reference and thanks

Thank you again for

1, SuperLike

2, toutiaothumb