自定义LayoutManager实现android-pile-layout滑动卡片堆叠效果

时间:2024-05-20 14:56:36

这是我的第一篇博客,总算有个契机开始写博客了,就从自定义LayoutManager开始吧。

前段时间看到一个项目(android-pile-layout),效果特别不错,这是效果图:

自定义LayoutManager实现android-pile-layout滑动卡片堆叠效果


效果确实不错,我看了下实现,是用的自定义的ViewGroup实现的,计算什么还搞的挺复杂的,具体的没细看。作者说也尝试过用LayoutManager实现,但是没找到合适的数学模型,好吧,看到数学模型我感觉好特么高端。不过当时我觉得这个肯定是可以用LayoutManager实现的嘛,不过不知道用LayoutManager应该从哪里入手,默默考虑了两天,昨天一口气写的差不多了,写完一看,其实也没写什么,中间遇到了一些问题,总以为解决不了,不过最后也完美解决,感觉美滋滋。

这是我实现的效果,感觉图片很糊,如果有合适的video转gif的软件可以推荐给我,这个gif搞了好久,这应该是质量最好的一张了:


自定义LayoutManager实现android-pile-layout滑动卡片堆叠效果

现在来想想怎么实现吧,先不管LayoutManager相关的具体细节,单单就说怎么去建立“数学模型”。其实我看到这个效果的第一眼就觉得是一个函数的问题。这是我做的一张示意图,方便大家理解

自定义LayoutManager实现android-pile-layout滑动卡片堆叠效果

这是初始状态下的一张截图。其中第一张图片距离第二张图片的距离是一个固定值,暂且叫space,第一张图片距离左边的距离是4个space(这个距离是可以配置的)。我们把第一张图片的位置叫做基准位置,很多计算都和这个位置有关系。并且这个LayoutManager的前提是每个Item都是一样的尺寸。当滚动距离是一个Item宽度和space之和时,那么就会切换到下一个item,像下面这张图所示的这样:

   自定义LayoutManager实现android-pile-layout滑动卡片堆叠效果

第一张图片后面的图片都会小一点,并且依次间隔距离都是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并且摆放到正确的位置。


其实写完这些后,达到的效果其实只是这样的:

自定义LayoutManager实现android-pile-layout滑动卡片堆叠效果

对的,骚年好眼力,就是滑到哪儿就是哪儿,并没有和最前面的动图显示的那样,最终会滑到一个固定的位置。这其实也分两种,一种是手指在正常滑动的过程中抬了起来,另外一种是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鼓励一下自定义LayoutManager实现android-pile-layout滑动卡片堆叠效果