如何利用 Android 自定义控件实现炫酷的动画?|CSDN 博文精选

百家 作者:CSDN 2019-07-03 06:28:42


作者 |?u012551350

本文精选自 CSDN 博客,已获作者授权

「知足常乐」,很多人不满足现状,各种折腾,往往舍本逐末,常乐才能少一分浮躁,多一分宁静。近期在笔者身上发生了许多事情,心态也发生了很大的改变,有感于现实的无奈,在离家乡遥远城市里的落寂,追逐名利的浮躁;可能生活就是这样的,每个年龄段都有自己的烦恼。


说到折腾,很久以前就看到了各种自定义LayoutManager做出各种炫酷的动画,就想自己也要实现。但每次都因为系统自带的LinearLayoutManager源码搞得一脸懵逼。正好这段时间不忙,折腾了一天,写了个简单的Demo,效果如下:


RecyclerView的重要性不必多说,据过往开发经验而谈,超过一屏可滑动的界面,基本都可以采用 「RecyclerView的多类型」 来做,不管维护还是扩展都是非常有效率的。RecyclerView相关的面试题也是各大厂常问的问题之一(权重非常高)。



使用



mRecyclerView.setLayoutManager(stackLayoutManager?=?new?StackLayoutManager(this));


跟系统的LinearLayoutManager使用方式一致,文本只是简单的Demo,功能单一,主要讲解流程与步骤,请根据特定的需求修改。


各属性意义见图:



注意:因为item随着滑动会有不同的缩放,所以实际normalViewGap会被缩放计算。



自定义LayoutManager基础知识



有关自定义LayoutManager基础知识,请查阅以下文章,写的非常棒:


1、陈小缘的自定义LayoutManager第十一式之飞龙在天(小缘大佬自定义文章逻辑清晰明了,堪称教科书,非常经典)


https://blog.csdn.net/u011387817/article/details/81875021


2、 张旭童的掌握自定义LayoutManager(一) 系列开篇 常见误区、问题、注意事项,常用API


https://blog.csdn.net/zxt0601/article/details/52948009


3、张旭童的掌握自定义LayoutManager(二) 实现流式布局


https://blog.csdn.net/zxt0601/article/details/52956504


4、勇朝陈的Android仿豆瓣书影音频道推荐表单堆叠列表RecyclerView-LayoutManager


https://blog.csdn.net/ccy0122/article/details/90515386


这几篇文章针对自定义LayoutManager的误区、注意事项,分析的非常到位,来来回回我看了好几篇,希望对你有所帮助。



自定义LayoutManager基本流程



让Items显示出来


我们在自定义ViewGroup中,想要显示子View,无非就三件事:


  • 添加 通过addView方法把子View添加进ViewGroup或直接在xml中直接添加;

  • 测量 重写onMeasure方法并在这里决定自身尺寸以及每一个子View大小;

  • 布局 重写onLayout方法,在里面调用子View的layout方法来确定它的位置和尺寸。


其实在自定义LayoutManager中,在流程上也是差不多的,我们需要重写onLayoutChildren方法,这个方法会在初始化或者Adapter数据集更新时回调,在这方法里面,需要做以下事情:


  • 进行布局之前,我们需要调用detachAndScrapAttachedViews方法把屏幕中的Items都分离出来,内部调整好位置和数据后,再把它添加回去(如果需要的话);

  • 分离了之后,我们就要想办法把它们再添加回去了,所以需要通过addView方法来添加,那这些View在哪里得到呢? 我们需要调用 Recycler的getViewForPosition(int position) 方法来获取;

  • 获取到Item并重新添加了之后,我们还需要对它进行测量,这时候可以调用measureChild或measureChildWithMargins方法,两者的区别我们已经了解过了,相信同学们都能根据需求选择更合适的方法;

  • 在测量完还需要做什么呢? 没错,就是布局了,我们也是根据需求来决定使用layoutDecorated还是layoutDecoratedWithMargins方法;

  • 在自定义ViewGroup中,layout完就可以运行看效果了,但在LayoutManager还有一件非常重要的事情,就是回收了,我们在layout之后,还要把一些不再需要的Items回收,以保证滑动的流畅度。


以上内容出自陈小缘的自定义LayoutManager第十一式之飞龙在天(https://blog.csdn.net/u011387817/article/details/81875021)。


布局实现


再看下相关参数:



如果去掉itemView的缩放,透明度动画,那么效果是这样的:


看到的效果与LinearLayoutManager一样,但本篇并不使用LinearLayoutManager,而是通过自定义LayoutManager来实现。


索引值为0的view 一次完全滑出屏幕所需要的移动距离,定位为 firstChildCompleteScrollLength ;非索引值为0的view滑出屏幕所需要移动的距离为:firstChildCompleteScrollLength + onceCompleteScrollLength ; item 之间的间距为 normalViewGap。


我们在 scrollHorizontallyBy 方法中记录偏移量 dx,保存一个累计偏移量 mHorizontalOffset ,然后针对索引值为0与非0两种情况,在 mHorizontalOffset 小于 firstChildCompleteScrollLength 情况下,用该偏移量除以 firstChildCompleteScrollLength 获取到已经滚动了的百分比 fraction ;同理索引值非0的情况下,偏移量需要减去 firstChildCompleteScrollLength 来获取到滚动的百分比。根据百分比,怎么布局childview就很容易了。


接下来开始写代码,先取个比较接地气的名字,就叫 StackLayoutManager ,好普通的名字,哈哈。


StackLayoutManager 继承 RecyclerView.LayoutManager ,需要重写 generateDefaultLayoutParams 方法:


????@Override
????public?RecyclerView.LayoutParams?generateDefaultLayoutParams()?{
????????return?new?RecyclerView.LayoutParams(RecyclerView.LayoutParams.WRAP_CONTENT,?RecyclerView.LayoutParams.WRAP_CONTENT);
????}


先看看成员变量:


????/**
?????*?一次完整的聚焦滑动所需要的移动距离
?????*/

????private?float?onceCompleteScrollLength?=?-1;

????/**
?????*?第一个子view的偏移量
?????*/

????private?float?firstChildCompleteScrollLength?=?-1;

????/**
?????*?屏幕可见第一个view的position
?????*/

????private?int?mFirstVisiPos;

????/**
?????*?屏幕可见的最后一个view的position
?????*/

????private?int?mLastVisiPos;

????/**
?????*?水平方向累计偏移量
?????*/

????private?long?mHorizontalOffset;

????/**
?????*?view之间的margin
?????*/

????private?float?normalViewGap?=?30;

????private?int?childWidth?=?0;

????/**
?????*?是否自动选中
?????*/

????private?boolean?isAutoSelect?=?true;
????//?选中动画
????private?ValueAnimator?selectAnimator;


接着看看 scrollHorizontallyBy 方法:


???@Override
????public?int?scrollHorizontallyBy(int?dx,?RecyclerView.Recycler?recycler,?RecyclerView.State?state)?
{
????????//?手指从右向左滑动,dx?>?0;?手指从左向右滑动,dx?< ?0;
????????//?位移0、没有子View?当然不移动
????????if?(dx?==?0?||?getChildCount()?==?0)?{
????????????return?0;
????????}

????????//?误差处理
????????float?realDx?=?dx?/?1.0f;
????????if?(Math.abs(realDx)?< ?0.00000001f)?{
????????????return?0;
????????}

????????mHorizontalOffset?+=?dx;

????????dx?=?fill(recycler,?state,?dx);

????????return?dx;
????}

????private?int?fill(RecyclerView.Recycler?recycler,?RecyclerView.State?state,?int?dx)?{
????????int?resultDelta?=?dx;
????????resultDelta?=?fillHorizontalLeft(recycler,?state,?dx);
????????recycleChildren(recycler);
????????return?resultDelta;
????}

????private?int?fillHorizontalLeft(RecyclerView.Recycler?recycler,?RecyclerView.State?state,?int?dx)?{
????????//----------------1、边界检测-----------------
????????if?(dx?< ?0)?{
????????????//?已到达左边界
????????????if?(mHorizontalOffset?< ?0)?{
????????????????mHorizontalOffset?=?dx?=?0;
????????????}
????????}

????????if?(dx?>?0)?{
????????????if?(mHorizontalOffset?>=?getMaxOffset())?{
????????????????//?根据最大偏移量来计算滑动到最右侧边缘
????????????????mHorizontalOffset?=?(long)?getMaxOffset();
????????????????dx?=?0;
????????????}
????????}

????????//?分离全部的view,加入到临时缓存
????????detachAndScrapAttachedViews(recycler);

????????float?startX?=?0;
????????float?fraction?=?0f;
????????boolean?isChildLayoutLeft?=?true;

????????View?tempView?=?null;
????????int?tempPosition?=?-1;

????????if?(onceCompleteScrollLength?==?-1)?{
????????????//?因为mFirstVisiPos在下面可能被改变,所以用tempPosition暂存一下
????????????tempPosition?=?mFirstVisiPos;
????????????tempView?=?recycler.getViewForPosition(tempPosition);
????????????measureChildWithMargins(tempView,?0,?0);
????????????childWidth?=?getDecoratedMeasurementHorizontal(tempView);
????????}

????????//?修正第一个可见view?mFirstVisiPos?已经滑动了多少个完整的onceCompleteScrollLength就代表滑动了多少个item
????????firstChildCompleteScrollLength?=?getWidth()?/?2?+?childWidth?/?2;
????????if?(mHorizontalOffset?>=?firstChildCompleteScrollLength)?{
????????????startX?=?normalViewGap;
????????????onceCompleteScrollLength?=?childWidth?+?normalViewGap;
????????????mFirstVisiPos?=?(int)?Math.floor(Math.abs(mHorizontalOffset?-?firstChildCompleteScrollLength)?/?onceCompleteScrollLength)?+?1;
????????????fraction?=?(Math.abs(mHorizontalOffset?-?firstChildCompleteScrollLength)?%?onceCompleteScrollLength)?/?(onceCompleteScrollLength?*?1.0f);
????????}?else?{
????????????mFirstVisiPos?=?0;
????????????startX?=?getMinOffset();
????????????onceCompleteScrollLength?=?firstChildCompleteScrollLength;
????????????fraction?=?(Math.abs(mHorizontalOffset)?%?onceCompleteScrollLength)?/?(onceCompleteScrollLength?*?1.0f);
????????}

????????//?临时将mLastVisiPos赋值为getItemCount()?-?1,放心,下面遍历时会判断view是否已溢出屏幕,并及时修正该值并结束布局
????????mLastVisiPos?=?getItemCount()?-?1;

????????float?normalViewOffset?=?onceCompleteScrollLength?*?fraction;
????????boolean?isNormalViewOffsetSetted?=?false;

????????//----------------3、开始布局-----------------
????????for?(int?i?=?mFirstVisiPos;?i?< =?mLastVisiPos;?i++)?{
????????????View?item;
????????????if?(i?==?tempPosition?&&?tempView?!=?null)?{
????????????????//?如果初始化数据时已经取了一个临时view
????????????????item?=?tempView;
????????????}?else?{
????????????????item?=?recycler.getViewForPosition(i);
????????????}

????????????addView(item);
????????????measureChildWithMargins(item,?0,?0);

????????????if?(!isNormalViewOffsetSetted)?{
????????????????startX?-=?normalViewOffset;
????????????????isNormalViewOffsetSetted?=?true;
????????????}

????????????int?l,?t,?r,?b;
????????????l?=?(int)?startX;
????????????t?=?getPaddingTop();
????????????r?=?l?+?getDecoratedMeasurementHorizontal(item);
????????????b?=?t?+?getDecoratedMeasurementVertical(item);

????????????layoutDecoratedWithMargins(item,?l,?t,?r,?b);

????????????startX?+=?(childWidth?+?normalViewGap);

????????????if?(startX?>?getWidth()?-?getPaddingRight())?{
????????????????mLastVisiPos?=?i;
????????????????break;
????????????}
????????}
????????return?dx;
????}


涉及的方法:


????/**
?????*?最大偏移量
?????*
?????*?@return
?????*/

????private?float?getMaxOffset()?{
????????if?(childWidth?==?0?||?getItemCount()?==?0)?return?0;
????????return?(childWidth?+?normalViewGap)?*?(getItemCount()?-?1);
????}

????/**
?????*?获取某个childView在水平方向所占的空间,将margin考虑进去
?????*
?????*?@param?view
?????*?@return
?????*/

????public?int?getDecoratedMeasurementHorizontal(View?view)?{
????????final?RecyclerView.LayoutParams?params?=?(RecyclerView.LayoutParams)
????????????????view.getLayoutParams();
????????return?getDecoratedMeasuredWidth(view)?+?params.leftMargin
????????????????+?params.rightMargin;
????}

????/**
?????*?获取某个childView在竖直方向所占的空间,将margin考虑进去
?????*
?????*?@param?view
?????*?@return
?????*/

????public?int?getDecoratedMeasurementVertical(View?view)?{
????????final?RecyclerView.LayoutParams?params?=?(RecyclerView.LayoutParams)
????????????????view.getLayoutParams();
????????return?getDecoratedMeasuredHeight(view)?+?params.topMargin
????????????????+?params.bottomMargin;
????}


回收复用


这里使用Android仿豆瓣书影音频道推荐表单堆叠列表RecyclerView-LayoutManager中(https://blog.csdn.net/ccy0122/article/details/90515386)使用的回收技巧:


?/**
?????*?@param?recycler
?????*?@param?state
?????*?@param?delta
?????*/

????private?int?fill(RecyclerView.Recycler?recycler,?RecyclerView.State?state,?int?delta)?{
????????int?resultDelta?=?delta;
????????//。。。省略

????????recycleChildren(recycler);
???????log("childCount=?["?+?getChildCount()?+?"]"?+?",[recycler.getScrapList().size():"?+?recycler.getScrapList().size());
????????return?resultDelta;
????}

????/**
?????*?回收需回收的Item。
?????*/

????private?void?recycleChildren(RecyclerView.Recycler?recycler)?{
????????List?scrapList?=?recycler.getScrapList();
????????for?(int?i?=?0;?i?< ?scrapList.size();?i++)?{
????????????RecyclerView.ViewHolder?holder?=?scrapList.get(i);
????????????removeAndRecycleView(holder.itemView,?recycler);
????????}
????}


回收复用这里就不验证了,感兴趣的小伙伴可自行验证。



动画效果



????private?int?fillHorizontalLeft(RecyclerView.Recycler?recycler,?RecyclerView.State?state,?int?dx)?{
????????//?省略?......
????????//----------------3、开始布局-----------------
????????for?(int?i?=?mFirstVisiPos;?i?< =?mLastVisiPos;?i++)?{
????????????//?省略?......

????????????//?缩放子view
????????????final?float?minScale?=?0.6f;
????????????float?currentScale?=?0f;
????????????final?int?childCenterX?=?(r?+?l)?/?2;
????????????final?int?parentCenterX?=?getWidth()?/?2;
????????????isChildLayoutLeft?=?childCenterX?< =?parentCenterX;
????????????if?(isChildLayoutLeft)?{
????????????????final?float?fractionScale?=?(parentCenterX?-?childCenterX)?/?(parentCenterX?*?1.0f);
????????????????currentScale?=?1.0f?-?(1.0f?-?minScale)?*?fractionScale;
????????????}?else?{
????????????????final?float?fractionScale?=?(childCenterX?-?parentCenterX)?/?(parentCenterX?*?1.0f);
????????????????currentScale?=?1.0f?-?(1.0f?-?minScale)?*?fractionScale;
????????????}
????????????item.setScaleX(currentScale);
????????????item.setScaleY(currentScale);
????????????item.setAlpha(currentScale);

????????????layoutDecoratedWithMargins(item,?l,?t,?r,?b);
???????????//?省略?......
????????}
????????return?dx;
????}


childView 越向屏幕中间移动缩放比越大,越向两边移动缩放比越小。


自动选中


1、滚动停止后自动选中


监听 onScrollStateChanged,在滚动停止时计算出应当停留的 position,再计算出停留时的 mHorizontalOffset 值,播放属性动画将当前 mHorizontalOffset 不断更新至最终值即可。相关代码如下:


????@Override
????public?void?onScrollStateChanged(int?state)?{
????????super.onScrollStateChanged(state);
????????switch?(state)?{
????????????case?RecyclerView.SCROLL_STATE_DRAGGING:
????????????????//当手指按下时,停止当前正在播放的动画
????????????????cancelAnimator();
????????????????break;
????????????case?RecyclerView.SCROLL_STATE_IDLE:
????????????????//当列表滚动停止后,判断一下自动选中是否打开
????????????????if?(isAutoSelect)?{
????????????????????//找到离目标落点最近的item索引
????????????????????smoothScrollToPosition(findShouldSelectPosition());
????????????????}
????????????????break;
????????????default:
????????????????break;
????????}
????}

?????/**
?????*?平滑滚动到某个位置
?????*
?????*?@param?position?目标Item索引
?????*/

????public?void?smoothScrollToPosition(int?position)?{
????????if?(position?>?-1?&&?position?< ?getItemCount())?{
????????????startValueAnimator(position);
????????}
????}

????private?int?findShouldSelectPosition()?{
????????if?(onceCompleteScrollLength?==?-1?||?mFirstVisiPos?==?-1)?{
????????????return?-1;
????????}
????????int?position?=?(int)?(Math.abs(mHorizontalOffset)?/?(childWidth?+?normalViewGap));
????????int?remainder?=?(int)?(Math.abs(mHorizontalOffset)?%?(childWidth?+?normalViewGap));
????????//?超过一半,应当选中下一项
????????if?(remainder?>=?(childWidth?+?normalViewGap)?/?2.0f)?{
????????????if?(position?+?1?< =?getItemCount()?-?1)?{
????????????????return?position?+?1;
????????????}
????????}
????????return?position;
????}

????private?void?startValueAnimator(int?position)?{
????????cancelAnimator();

????????final?float?distance?=?getScrollToPositionOffset(position);

????????long?minDuration?=?100;
????????long?maxDuration?=?300;
????????long?duration;

????????float?distanceFraction?=?(Math.abs(distance)?/?(childWidth?+?normalViewGap));

????????if?(distance?< =?(childWidth?+?normalViewGap))?{
????????????duration?=?(long)?(minDuration?+?(maxDuration?-?minDuration)?*?distanceFraction);
????????}?else?{
????????????duration?=?(long)?(maxDuration?*?distanceFraction);
????????}
????????selectAnimator?=?ValueAnimator.ofFloat(0.0f,?distance);
????????selectAnimator.setDuration(duration);
????????selectAnimator.setInterpolator(new?LinearInterpolator());
????????final?float?startedOffset?=?mHorizontalOffset;
????????selectAnimator.addUpdateListener(new?ValueAnimator.AnimatorUpdateListener()?{
????????????@Override
????????????public?void?onAnimationUpdate(ValueAnimator?animation)?{
????????????????float?value?=?(float)?animation.getAnimatedValue();
????????????????mHorizontalOffset?=?(long)?(startedOffset?+?value);
????????????????requestLayout();
????????????}
????????});
????????selectAnimator.start();
????}


2、点击非焦点view自动将其选中为焦点view


我们可以直接拿到 view 的 position,直接调用 smoothScrollToPosition 方法,就可以实现自动选中为焦点。


中间view覆盖在两边view之上,效果是这样的:



从效果中可以看出,索引为2的view覆盖在1,3的上面,同时1又覆盖在0的上面,以此内推。


RecyclerView 继承于 ViewGroup ,那么在添加子view addView(View child, int index) 中 index 的索引值越大,越显示在上层。那么可以得出,为2的绿色卡片被添加是 index 最大,分析可以得出以下结论:


index 的大小:


0 < 1 < 2 > 3 > 4


中间最大,两边逐渐减小的原则。


获取到中间 view 的索引值,如果小于等于该索引值则调用 addView(item) ,反之调用 addView(item, 0) ;相关代码如下:


????private?int?fillHorizontalLeft(RecyclerView.Recycler?recycler,?RecyclerView.State?state,?int?dx)?{
????????//省略?......
????????//----------------3、开始布局-----------------
????????for?(int?i?=?mFirstVisiPos;?i?< =?mLastVisiPos;?i++)?{
?????????????//省略?......
????????????int?focusPosition?=?(int)?(Math.abs(mHorizontalOffset)?/?(childWidth?+?normalViewGap));
????????????if?(i?< =?focusPosition)?{
????????????????addView(item);
????????????}?else?{
????????????????addView(item,?0);
????????????}
?????????????//省略?......?
????????}
????????return?dx;
????}


文章到这里就差不多要结束了。


源码地址:https://github.com/HpWens/MeiWidgetView。


CSDN博客原文:https://blog.csdn.net/u012551350/article/details/93971801,欢迎大家入驻 CSDN 博客。


【END】

?热 文?推 荐?

?物联网终端五年后将超 270 亿!破竹之势下程序员如何修炼内功?

?华为将发布鲲鹏 920 芯片数据;三星 S10 自燃;Mageia 7 正式发布 | 极客头条

?不要让开源成为贸易战的牺牲品!

?程序员们如何破局 5G?

?软件为什么会沦为遗留系统?

?因为有了 TA,搞定行业应用开发,不怕不怕啦!

?除了V神,17个以太坊大会讲师的演讲精华都在这儿了!

?2019年技术盘点容器篇(二):听腾讯云讲讲踏入成熟期的容器技术?| 程序员硬核评测

?50行Python代码,获取公众号全部文章

?不写一行代码,也能玩转Kaggle竞赛?

?马云曾经偶像,终于把阿里留下的1400亿败光了!

点击阅读原文,输入关键词,即可搜索您想要的 CSDN 文章。

你点的每个“在看”,我都认真当成了喜欢

关注公众号:拾黑(shiheibook)了解更多

[广告]赞助链接:

四季很好,只要有你,文娱排行榜:https://www.yaopaiming.com/
让资讯触达的更精准有趣:https://www.0xu.cn/

公众号 关注网络尖刀微信公众号
随时掌握互联网精彩
赞助链接