这是我的第一篇博客,总算有个契机开始写博客了,就从自定义LayoutManager开始吧。
前段时间看到一个项目(android-pile-layout),效果特别不错,这是效果图:
效果确实不错,我看了下实现,是用的自定义的ViewGroup实现的,计算什么还搞的挺复杂的,具体的没细看。作者说也尝试过用LayoutManager实现,但是没找到合适的数学模型,好吧,看到数学模型我感觉好特么高端。不过当时我觉得这个肯定是可以用LayoutManager实现的嘛,不过不知道用LayoutManager应该从哪里入手,默默考虑了两天,昨天一口气写的差不多了,写完一看,其实也没写什么,中间遇到了一些问题,总以为解决不了,不过最后也完美解决,感觉美滋滋。
这是我实现的效果,感觉图片很糊,如果有合适的video转gif的软件可以推荐给我,这个gif搞了好久,这应该是质量最好的一张了:
现在来想想怎么实现吧,先不管LayoutManager相关的具体细节,单单就说怎么去建立“数学模型”。其实我看到这个效果的第一眼就觉得是一个函数的问题。这是我做的一张示意图,方便大家理解
这是初始状态下的一张截图。其中第一张图片距离第二张图片的距离是一个固定值,暂且叫space,第一张图片距离左边的距离是4个space(这个距离是可以配置的)。我们把第一张图片的位置叫做基准位置,很多计算都和这个位置有关系。并且这个LayoutManager的前提是每个Item都是一样的尺寸。当滚动距离是一个Item宽度和space之和时,那么就会切换到下一个item,像下面这张图所示的这样:
第一张图片后面的图片都会小一点,并且依次间隔距离都是space。那么在这个模型中,我们要知道的其实就是,在滚动给定距离的情况下,计算所有应该显示出来item 的左边距,透明度和缩放应该是多少,并且把这些Item摆放在正确的位置,那么就变成一个很普通的找这些距离,透明度,缩放和滚动距离之间的对应关系的问题,就是所谓的“数学模型”,哈哈,感觉也很简单嘛。
不过在做这些之前建议大家先看一下LayoutManager相关的知识,不过我也没完全弄明白,看懂了大概就开始屁颠屁颠的写那些函数了。
首先是继承LayoutManager,这个没的说,为了让自定LayoutManager能够接收到水平滚动事件,必须重写canScrollHorizontally方法,返回true,表示告诉RecyclerView,水平滚动事件我都要了。
@Override public boolean canScrollHorizontally() { return true; }
这样还不够,每次滚动的距离要传递过来吧,所以还要重写scrollHorizontallyBy方法,用于接收滚动的距离,根据滚动的距离做计算和调整
@Override public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state) { return fill(recycler, dx); }
另外我们还定义一些变量用于记录关键的值
//the space unit for the stacked item int mSpace = 60; //the offset unit,deciding current position(the sum of one child's width and one space) int mUnit; //the counting variable ,save the total offset int mTotalOffset; ObjectAnimator animator; private int animateValue; private int duration = 300; private RecyclerView.Recycler recycler; private int lastAnimateValue; private int maxStackCount = 4;//the max stacked item count; private int initialStackCount = 4;//initial stacked item private float secondaryScale = 0.8f; private int initialOffset; private boolean initial;mSpace也就是我们前面说的space,表示单位间隔,mTotalOffset表示总的已经滚动的距离,很重要的一个值;maxStackCount表示最大堆叠的层数;initialStackCount表示初始堆叠的层数,secondaryScale表示没有进入stack的Item的默认缩放大小,也就是前面初始状态那张图里面第二个Item的缩放大小,比初始状态下基准位置的Item的scale要小一些。其他的变量你们看下项目代码就明白了,都是根据前面的值计算出来了的。
我觉得说到这,估计大家心里已经有思路了,知道该怎么做了,好的,下面说下求左边距,透明和缩放的三个函数。
左边距计算代码:
public int left(int position) { int left; int curPos = mTotalOffset / mUnit; float n = (mTotalOffset + 0.0f) / mUnit; float x = n - curPos; if (position <= curPos) { if (position == curPos) { left = (int) (mSpace * (maxStackCount - x)); } else { left = (int) (mSpace * (maxStackCount - x - (curPos - position))); } } else { left = mSpace * maxStackCount + position * mUnit - mTotalOffset; left = left <= 0 ? 0 : left; } return left; }
首先计算出当前基准位置上显示的是哪个Item,然后计算出Item偏离基准位置的百分比,就是计算出来的x。
分两种情况,一种传入的position对应的item在基准Item的左边(包含),一种是要计算的postion在基准item右边,一种是在左边。在左边的话并且就是基准item的话,无非就是在初始位置往左边偏离了x* space的距离,计算出来就是 mSpace*maxStackCount -x*mSpace.其他几种情况看代码应该可以懂了。
透明度的计算:
这个应该比较容易,和left的计算其实是差不多的
public float alpha(int position) { float alpha; int curPos = mTotalOffset / mUnit; float n = (mTotalOffset + 0.0f) / mUnit; if (position > curPos) alpha = 1.0f; else { //temporary linear map,barely ok float o = 1 - (n - position) / maxStackCount; alpha = o; } //for precise checking,oh may be kind of dummy return alpha <= 0.001f ? 0 : alpha; }
还是同样的,先计算出基准位置上显示的哪个Item。在Item左边的透明度都是1.在左边的呢计算也很简答,是单纯的线性变化,到最左边了就完全看不见了。看代码应该可以看懂。
缩放的计算:
public float scale(int position) { float scale; int curPos = this.mTotalOffset / mUnit; float n = (mTotalOffset + 0.0f) / mUnit; float x = n - curPos; // position >= curPos+1; if (position >= curPos) { if (position == curPos) scale = 1 - 0.3f * (n - curPos) / (maxStackCount + 0f); else if (position == curPos + 1) //let the item's (index:position+1) scale be 1 when the item offset 1/2 mUnit, // this have better visual effect { // scale = 0.8f + (0.4f * x >= 0.2f ? 0.2f : 0.4f * x); scale = secondaryScale + (x > 0.5f ? 1 - secondaryScale : 2 * (1 - secondaryScale) * x); } else scale = secondaryScale; } else {//position <= curPos if (position < curPos - maxStackCount) scale = 0f; else { scale = 1f - 0.3f * (n - curPos + curPos - position) / (maxStackCount + 0f); } } return scale; }好吧,又是根据mTotalOffset和mUnit计算出当前滚动距离下的基准Item。情况一分为二,右边的情况(含基准位置):
如果就是基准位置
scale = 1 - 0.3f * (n - curPos) / (maxStackCount + 0f);
因为缩放最小我控制在0.7f,所以乘了一个0.3f.,这个0.3f也可以写成配置的
如果是基准位置后一个Item
scale = secondaryScale + (x > 0.5f ? 1 - secondaryScale : 2 * (1 - secondaryScale) * x);代码长一点,其实也很简单,curPos+1位置的Item默认缩放是secondaryScale,往左是scale变大,但是为了视觉效果好一点,我们希望在划过 1/2 mUnit距离的时候,这个Item的scale已经等于1,看起来舒服点。
三个关键的的计算函数写完了,怎么用,用在哪里还没完呢,下面说下整体的使用逻辑。
在初始化的情况下,onLayoutChildren会被调用多次,这里就是让我摆放Item的地方,这里调用了一个叫fill(RecyclerView.Recycler recycler,int dy)的函数,是专门用于摆放Item的函数,也是各种LayoutManager的核心函数,所有的回收摆放和计算工作都在这里。
private int fill(RecyclerView.Recycler recycler, int dy) { if (mTotalOffset + dy < 0 || (mTotalOffset + dy + 0f) / mUnit > getItemCount() - 1) return 0; detachAndScrapAttachedViews(recycler); mTotalOffset += dy; int count = getChildCount(); //removeAndRecycle views for (int i = 0; i < count; i++) { View child = getChildAt(i); if (child != null && shouldRecycle(child, dy)) removeAndRecycleView(child, recycler); } int curPos = mTotalOffset / mUnit; float n = (mTotalOffset + 0f) / mUnit; float x = n % 1f; int start = curPos - maxStackCount >= 0 ? curPos - maxStackCount : 0; int end = curPos + maxStackCount > getItemCount() ? getItemCount() : curPos + maxStackCount; //layout view for (int i = start; i < end; i++) { View view = recycler.getViewForPosition(i); float scale = scale(i); float alpha = alpha(i); addView(view); measureChildWithMargins(view, 0, 0); int left = (int) (left(i) - (1 - scale) * view.getMeasuredWidth() / 2); layoutDecoratedWithMargins(view, left, 0, left + view.getMeasuredWidth(), view.getMeasuredHeight()); view.setAlpha(alpha); view.setScaleY(scale); view.setScaleX(scale); } return dy; }逻辑很清晰,先回收,然后计算应该显示哪些Iitem,最后就是根据前面得出的三个函数设置这些Item并且摆放到正确的位置。
其实写完这些后,达到的效果其实只是这样的:
对的,骚年好眼力,就是滑到哪儿就是哪儿,并没有和最前面的动图显示的那样,最终会滑到一个固定的位置。这其实也分两种,一种是手指在正常滑动的过程中抬了起来,另外一种是fling手势,这两种情况其实也好处理,直接贴代码,看代码很好理解
@Override public void onAttachedToWindow(RecyclerView view) { super.onAttachedToWindow(view); //check when raise finger and settle to the appropriate item view.setOnTouchListener(new View.OnTouchListener() { @Override public boolean onTouch(View v, MotionEvent event) { if (event.getAction() == MotionEvent.ACTION_DOWN && animator != null && animator.isRunning()) animator.cancel(); if (event.getAction() == MotionEvent.ACTION_UP) { int o = mTotalOffset % mUnit; int scrollX; if (o != 0) { if (o >= mUnit / 2) scrollX = mUnit - o; else scrollX = -o; // int dur= (int) (Math.abs((scrollX+0f)/mUnit)*duration); brewAndStartAnimator(duration, scrollX); } } return false; } }); view.setOnFlingListener(new RecyclerView.OnFlingListener() { @Override public boolean onFling(int velocityX, int velocityY) { int N = mTotalOffset / mUnit; float n = (mTotalOffset + 0f) / mUnit; int o = mTotalOffset % mUnit; int s = mUnit - o; int scrollX; if (velocityX > 0) { scrollX = s; } else scrollX = -o; if (BuildConfig.DEBUG) Log.i(TAG, "onFling: ===res:===" + (1f - n + N) + "========scrollX=" + (scrollX + 0f) / mUnit); int dur = duration; brewAndStartAnimator(dur, scrollX); return true; } }); }重写了OnAttachToWindow方法,给RecyclerView设置了触摸监听,以及Fling手势的监听,并且在监听里面计算了最后停留的position ,这一步使用了属性动画,大家应该可以看得懂。在没有设置fling的监听的情况下,RecyclerView内部也会根据fling加速度,计算成一小段一小段的距离回传给scrollHorizontallyBy这个方法,当然如果设置了fling监听,并且返回true,就不会回传啦。
其他的细节大家可以看项目代码,完整的项目链接:https://github.com/HirayClay/StackLayoutManager 觉得写的好可以给个star表扬一下,觉得写的烂的也可以给个star鼓励一下