Android实现仿今日头条点赞动画效果实例
一、前言
我们在今日头条app上会看到点赞动画效果,感觉非常不错,正好公司有点赞动画的需求,所以有了接下来的对此功能的实现的探索。
二、需求拆分
仔细观察点赞交互,看出大概以下几个步骤:
1:点赞控件需要自定义,对其触摸事件进行处理。
2:点赞动画的实现。
3:要有一个存放动画的容器。
三、实现方案
1、点赞控件触摸事件处理
点赞控件是区分长按和点击处理的,另外我们发现在手指按下以后包括手指的移动直到手指的抬起都在执行动画。因为点赞的点击区域可能包括点赞次数,所以这里就自定义了点赞控件,并处理ontouchevent(event: motionevent)
事件,区分长按和单击是使用了点击到手指抬起的间隔时间区分的,伪代码如下:
override fun ontouchevent(event: motionevent): boolean { var ontouch: boolean when (event.action) { motionevent.action_down -> { isrefreshing = false isdowning = true //点击 lastdowntime = system.currenttimemillis() postdelayed(autopolltask, click_interval_time) ontouch = true } motionevent.action_up -> { isdowning = false //抬起 if (system.currenttimemillis() - lastdowntime < click_interval_time) { //小于间隔时间按照单击处理 onfingerdowninglistener?.ondown(this) } else { //大于等于间隔时间按照长按抬起手指处理 onfingerdowninglistener?.onup() } removecallbacks(autopolltask) ontouch = true } motionevent.action_cancel ->{ isdowning = false removecallbacks(autopolltask) ontouch = false } else -> ontouch = false } return ontouch }
长按时使用runnable的postdelayed(runnable action, long delaymillis)
方法来进行不断的执行动画,伪代码:
private inner class autopolltask : runnable { override fun run() { onfingerdowninglistener?.onlongpress(this@likeview) if(!canlongpress){ removecallbacks(autopolltask) }else{ postdelayed(autopolltask, click_interval_time) } } }
2、点赞动画的实现
点赞效果元素分为:点赞表情图标、点赞次数数字以及点赞文案
2.1、点赞效果图片的获取和存储管理
这里参考了superlike的做法,对图片进行了缓存处理,代码如下:
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() } }
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 /** * 获取数字图片 * @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, bitmap) } } return bitmap } /** * 获取等级文案图片 * @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, bitmap) } } return bitmap } /** * 获取随机表情图片 * @return */ override val randombitmap: 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 } /** * 设置表情图片 * @param drawablearray * @return */ fun setdrawablearray(@drawableres drawablearray: intarray?): builder { this.drawablearray = drawablearray return this } /** * 设置数字图片 * @param numberdrawablearray * @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("次赞!", "太棒了!!", "超赞同!!!") } return default( context, cachesize, drawablearray!!, numberdrawablearray, leveldrawablearray, levelstringarray, textsize ) } } interface provider { /** * 获取随机表情图片 */ val randombitmap: bitmap /** * 获取数字图片 * [number] 点击次数 */ fun getnumberbitmap(number: int): bitmap? /** * 获取等级文案图片 * [level] 等级 */ fun getlevelbitmap(level: int): bitmap? } }
2.2、点赞表情图标动画实现
这里的实现参考了toutiaothumb,表情图标的动画大致分为:上升动画的同时执行图标大小变化动画和图标透明度变化,在上升动画完成时进行下降动画。代码如下:
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? = null /** * 表情图标的宽度 */ private var emojiwith = 0 /** * 表情图标的高度 */ private var emojiheight = 0 private fun init() { //初始化图片,取出随机图标 mthumbimage = provider?.randombitmap } init { //初始化paint 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() //上升动画 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() //表情图片的大小变化 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() } //透明度变化 val alphaanimation = objectanimator.offloat( this, "alpha", 0.8f, 1.0f, 1.0f, 1.0f, 0.9f, 0.8f, 0.8f, 0.7f, 0.6f, 0f ) alphaanimation.duration = duration.tolong() //动画集合 val animatorset = animatorset() animatorset.play(translateanimationx).with(translateanimationy) .with(translateanimationrightlength).with(translateanimationbottomlength) .with(alphaanimation) //下降动画 val translateanimationxdown = objectanimator.offloat(this, "translationx", topx, topx * 1.2f) translateanimationxdown.duration = (duration / 5).tolong() translateanimationxdown.interpolator = linearinterpolator() val translateanimationydown = objectanimator.offloat(this, "translationy", topy, topy * 0.8f) translateanimationydown.duration = (duration / 5).tolong() translateanimationydown.interpolator = accelerateinterpolator() //设置动画播放顺序 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: animator) { //动画完成后通知移除动画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) } } /** * 这些get\set方法用于表情图标的大小动画 * 不能删除 */ fun getemojiwith(): 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 { /** * 动画结束 */ fun onanimationemojiend() } fun setemoji() { init() } companion object { //动画时长 const val duration = 500 } }
2.3、点赞次数和点赞文案的绘制
这里的点赞次数处理了从1到999,并在不同的点赞次数区间显示不同的点赞文案。代码如下:
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 bitmaptalk: bitmap? = null /** * 等级 */ private var level = 0 /** * 数字图片宽度 */ private var numberimagewidth = 0 /** * 数字图片的总宽度 */ private var offsetx = 0 /** * x 初始位置 */ private var initialvalue = 0 /** * 默认数字和等级文案图片间距 */ private var spacing = 0 init { 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) ?: return //等级图片的宽度 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 + levelbitmapwidth } } dst.top = 0 dst.bottom = levelbitmap.height //绘制等级文案图标 canvas.drawbitmap(levelbitmap, null, dst, textpaint) while (mnumber > 0) { val number = mnumber % 10 val bitmap = provider.getnumberbitmap(number)?:continue offsetx += bitmap.width //这里是数字 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 //绘制数字 canvas.drawbitmap(bitmap, 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 } } //根据等级取出等级文案图标 bitmaptalk = provider?.getlevelbitmap(level) invalidate() } }
3、存放点赞动画的容器
我们需要自定义一个view来存放动画,以及提供开始动画以及回收动画view等工作。代码如下:
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? = null /** * 有无表情动画 暂时无用 */ private var haseruptionanimation = false /** * 有无等级文字 暂时无用 */ private var hastextanimation = false /** * 是否可以长按,暂时无用 目前用时间来管理 */ private var canlongpress = false /** * 最大和最小角度暂时无用 */ private var maxangle = 0 private var minangle = 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() } /** * 点击表情动画view */ 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() } } } /** * 开启动画 */ fun launch(x: int, y: int) { if (system.currenttimemillis() - lastclicktime >= spacing_time) { pointx = x pointy = y //单次点击 addemojiview(context, x, y-50) lastclicktime = system.currenttimemillis() currentnumber = 1 if (mnumberlevelview != null) { removeview(mnumberlevelview) mnumberlevelview = null } } else { //连续点击 if (pointx != x || pointy != y){ return } lastclicktime = system.currenttimemillis() log.i(tag, "当前动画化正在执行") addemojiview(context, x, y) //添加数字连击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) } }
注意:动画完成之后一定要清除view。
4、启动动画
点赞控件的手势回调,伪代码如下:
holder.likeview.setonfingerdowninglistener(object : onfingerdowninglistener { /** * 长按回调 */ override fun onlongpress(v: view) { if (!bean.haslike) { //未点赞 if (!fistlongpress) { //这里同步点赞接口等数据交互 bean.likenumber++ bean.haslike = true setlikestatus(holder, bean) } //显示动画 onlikeanimationlistener?.dolikeanimation(v) } else { if (system.currenttimemillis() - lastclicktime <= throttletime && lastclicktime != 0l) { //处理点击过后为点赞状态的情况 onlikeanimationlistener?.dolikeanimation(v) lastclicktime = system.currenttimemillis() } else { //处理长按为点赞状态后的情况 onlikeanimationlistener?.dolikeanimation(v) } } fistlongpress = true } /** * 长按抬起手指回调处理 */ override fun onup() { fistlongpress = false } /** * 单击事件回调 */ override fun ondown(v: view) { if (system.currenttimemillis() - lastclicktime > throttletime || lastclicktime == 0l) { if (!bean.haslike) { //未点赞情况下,点赞接口和数据交互处理 bean.haslike = true bean.likenumber++ setlikestatus(holder, bean) throttletime = 1000 onlikeanimationlistener?.dolikeanimation(v) } else { //点赞状态下,取消点赞接口和数据交互处理 bean.haslike = false bean.likenumber-- setlikestatus(holder, bean) throttletime = 30 } } else if (lastclicktime != 0l && bean.haslike) { //在时间范围内,连续点击点赞,显示动画 onlikeanimationlistener?.dolikeanimation(v) } lastclicktime = system.currenttimemillis() } })
在显示动画页面初始化工作时初始化动画资源:
override fun oncreate(savedinstancestate: bundle?) { super.oncreate(savedinstancestate) setcontentview(r.layout.activity_list) likeanimationlayout?.provider = bitmapproviderfactory.getprovider(this) }
在显示动画的回调中启动动画:
override fun dolikeanimation(v: view) { val itemposition = intarray(2) val superlikeposition = intarray(2) v.getlocationonscreen(itemposition) likeanimationlayout?.getlocationonscreen(superlikeposition) val x = itemposition[0] + v.width / 2 val y = itemposition[1] - superlikeposition[1] + v.height / 2 likeanimationlayout?.launch(x, y) }
四、遇到的问题
因为流列表中使用了smartrefreshlayout下拉刷新控件,如果在列表前几条内容进行点赞动画当手指移动时触摸事件会被smartrefreshlayout拦截去执行下拉刷新,那么手指抬起时点赞控件得不到响应会一直进行动画操作,目前想到的解决方案是点赞控件在手指按下时查看父布局有无smartrefreshlayout,如果有通过反射先禁掉下拉刷新功能,手指抬起或者取消进行重置操作。代码如下:
override fun dispatchtouchevent(event: motionevent?): boolean { parent?.requestdisallowintercepttouchevent(true) return super.dispatchtouchevent(event) } override fun ontouchevent(event: motionevent): boolean { var ontouch: boolean when (event.action) { motionevent.action_down -> { isrefreshing = false isdowning = true //点击 lastdowntime = system.currenttimemillis() findsmartrefreshlayout(false) if (isrefreshing) { //如果有下拉控件并且正在刷新直接不响应 return false } postdelayed(autopolltask, click_interval_time) ontouch = true } motionevent.action_up -> { isdowning = false //抬起 if (system.currenttimemillis() - lastdowntime < click_interval_time) { //小于间隔时间按照单击处理 onfingerdowninglistener?.ondown(this) } else { //大于等于间隔时间按照长按抬起手指处理 onfingerdowninglistener?.onup() } findsmartrefreshlayout(true) removecallbacks(autopolltask) ontouch = true } motionevent.action_cancel ->{ isdowning = false findsmartrefreshlayout(true) removecallbacks(autopolltask) ontouch = false } else -> ontouch = false } return ontouch } /** * 如果父布局有smartrefreshlayout 控件,设置控件是否可用 */ 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 //通过反射获取是否可以先下拉刷新的初始值 enablerefresh = field.getboolean(parent) }catch (e: exception) { e.printstacktrace() } } if (enablerefresh){ //如果初始值不可以下拉刷新不要设置下拉刷新状态 parent.setenablerefresh(enable) } parent.setenableloadmore(enable) break } else { parent = parent.parent } } }
五、实现效果
六、完整代码获取
七、参考和感谢
再次感谢
总结
到此这篇关于android实现仿今日头条点赞动画效果的文章就介绍到这了,更多相关android今日头条点赞动画内容请搜索以前的文章或继续浏览下面的相关文章希望大家以后多多支持!