美文网首页安卓深入浅出AndroidAndroid开发探索
手把手教你打造RecyclerView滚动特效

手把手教你打造RecyclerView滚动特效

作者: 代码咖啡 | 来源:发表于2016-11-15 09:47 被阅读5386次

    本篇文章已授权微信公众号 code小生 发布
    转载请表明出处:
    http://www.jianshu.com/p/4176c1247eed

    前情提要

    效果图

    最近开发中遇到这样的需求,recyclerview的item随滚动改变大小和透明度。这个效果看起来挺有动感的,似乎实现起来有点复杂,其实不然,接下来将带领大家手把手实现这个效果。

    Item动画分析

    我们化整为零,将这个效果分解到一个item上来看其实是这样的:

    item动画
    • 实现思路
      看到这个动画效果时,我首先想到的是,这个动画是可控的,不是通过设置anim.setDuration来实现的,所以要放弃Animation的念头,转而用传入process(动画执行的进度)的思路。

    • 分解动画
      继续化整为零,可以将这个动画效果分解为:蒙版透明度(alpha)、宽度(width)、图片缩放(scale)

    • 状态转换
      先不考虑动画变化的具体细节,先分清楚状态机。动画的变化状态为:
      蒙版:暗->亮->暗
      宽度:小->大->小
      图片:缩->放->缩

    • 考虑细节
      蒙版(黑色蒙版):
      1%->50%: 1.0->0.0;
      51%->100%: 0.0->1.0;
      宽度(通过设置横向外边距):
      1%->25%: 16dp->0dp;
      26%->75%: 0dp;
      76%->100%: 0dp->16dp
      图片缩放:

      图片缩放
      1%->25%: 1.0->(b/a);
      26%->50%: (b/a)->(c/a);
      51%->75%: (c/a)->(b/a);
      76%->100%: (b/a)->1.0;

    Item动画代码实现

    新建一个CustomAnimation类,定义相应动画控件的id,并初始化:

    // 无控件
    private static final int NO_VIEW = -999;
    // 透明度变化视图
    private int mAlphaViewId = NO_VIEW;
    // 图片变化视图
    private int mImageViewId = NO_VIEW;
    // 边距变化视图
    private int mMarginViewId = NO_VIEW;
    
    /**
     * 设置透明度变化控件的ID
     * @param resId
     */
    public void setAlphaViewId(int resId) {
        Log.i("animm", "setAlphaViewId");
        mAlphaViewId = resId;
    }
    
    /**
     * 设置图片变化控件的ID
     * @param resId
     */
    public void setImageViewId(int resId) {
        Log.i("animm", "setImageViewId");
        mImageViewId = resId;
    }
    
    /**
     * 设置外边距变化控件的ID
     * @param resId
     */
    public void setMarginViewId(int resId) {
        Log.i("animm", "setMarginViewId");
        mMarginViewId = resId;
    }
    

    定义变量process,并通过传入process的值进行效果实现:

    // 动画进度
    private int mProcess = 0;
    
    /**
     * 通过进度值控制动画的进度
     * @param viewGroup 父容器
     * @param process 动画变化进度
     */
    public void setAnimByProcess(ViewGroup viewGroup, int process) {
        if (viewGroup == null) {
            return;
        }
        mProcess = process;
        /**
         * 蒙版透明度设置
         */
        if (enableAlpha && mAlphaViewId != NO_VIEW) {
            View view = viewGroup.findViewById(mAlphaViewId);
            if (process > 0 && process <= 25) {
                float alpha = (25 - process) / 25.0f;
                view.setAlpha(alpha);
            } else if (process > 75 && process <= 100) {
                float alpha = (process - 75) / 25.0f;
                view.setAlpha(alpha);
            }
        }
       
       /**
         *
         * 设置图片大小
         */    if (enableImage && mImageViewId != NO_VIEW) {
            ImageView imageView = (ImageView) viewGroup.findViewById(mImageViewId);
            float curWidth = 0;
            if (process <= 25) {
                float percent = process / 25.0f;
                float marginHorizontal = mMarginHorizontal * percent;
                curWidth = mImgOrgWidth + 2 * marginHorizontal;
            } else if (process > 25 && process <= 50) {
                float percent = (process - 25) / 25.0f;
                float marginHorizontal = mMarginHorizontal * percent;
                curWidth = mScreenWidth + 2 * marginHorizontal;
            } else if (process > 50 && process <= 75) {
                float percent = (75 - process) / 25.0f;
                float marginHorizontal = mMarginHorizontal * percent;
                curWidth =  mScreenWidth + 2 * marginHorizontal;
            } else {
                float percent = (100 - process) / 25.0f;
                float marginHorizontal = mMarginHorizontal * percent;
                curWidth = mImgOrgWidth + 2 * marginHorizontal;
            }
            float scale = curWidth / mImgOrgWidth ;
            scale *= 1.1f;
            imageView.setScaleX(scale);
            imageView.setScaleY(scale);
        }
        /**
         * 设置外边距(横向)
         */
        if (enableMargin && mMarginViewId != NO_VIEW) {
            View view = viewGroup.findViewById(mMarginViewId);
            RelativeLayout.LayoutParams lp = (RelativeLayout.LayoutParams) view.getLayoutParams();
            if (process > 0 && process <= 25) {
                float percent = (25 - process) / 25.0f;
                float marginHorizontal = mMarginHorizontal * percent;
                lp.setMargins((int)marginHorizontal, (int)mMarginTop, (int)marginHorizontal, (int)mMarginBottom);
                view.setLayoutParams(lp);
            } else if (process > 75 && process <= 100) {
                float percent = (process - 75) / 25.0f;
                float marginHorizontal = mMarginHorizontal * percent;
                lp.setMargins((int)marginHorizontal, (int)mMarginTop, (int)marginHorizontal, (int)mMarginBottom);
                view.setLayoutParams(lp);
            }
        }
    }
    

    结合RecyclerView思考

    基于上述代码,我们基本实现动画的细节,接下来我们需要思考的是,如何将RecyclerView与process结合?思考这个问题前,我们来看一下这个效果:

    列表滑动效果

    这是我用简书的Markdown代码块语法实现的仿RecyclerView列表的效果,基于这个效果我想到将侧边栏的滑块和RecyclerView的Item结合起来,与动画的process变量相关联:

    0% 50% 100%

    通过右侧小滑块底部与Item顶部之间的距离占两个Item高度的百分比作为process的值:

    手机屏幕坐标示意图

    process = (turningLine - itemTop) / (2 * itemHeight);

    如此,我们将此关系放入新建的类TurnProcess中:

    public class TurnProcess {
        /**
         * 返回动画完成的进度
         * @param itemTop
         * @param turningLine
         * @param itemHeight
         * @return
         */
        public static int getProcess(float itemTop, float turningLine, float itemHeight) {
            if (turningLine < itemTop || turningLine > (itemHeight + itemTop)) {
                return 0;
            } else {
                float percent = (turningLine - itemTop) / itemHeight;
                return (int) (percent * 100);
            }
        }
    }
    

    计算滑动块底部的位置

    得到了上一步滑动与process的关系,接下来我们来计算一下滑块底部到RecyclerView可见范围顶部的距离。

    RecyclerView初始情况

    我们可以将RecyclerView初始情况设想如上图,此时turningLine的值为0。当RecyclerView滑动时:

    RecyclerView滚动高度与turningLine的关系

    由上图,我们可得到turniingLine与RecyclerView滑动距离的关系,从而得到turningLine的值:
    scrollY / totalScroll = turningLine / totalHeight;
    turningLine = scrollY * totalHeight / totalScroll;

    totalScroll的值可以通过RecyclerView总高度(包含不可见部分)与RecyclerView可见部分的高度相差得到;而scrollY则随着RecyclerView的滚动变化,因此需要对RecyclerView进行滚动事件的监听:

    recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
        @Override
        public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
            float scrollY = getScrollDistance(recyclerView);
        }
    }
    
    /**
     * 获取滚动的距离
     */
    private int getScrollDistance(RecyclerView recyclerView) {
        LinearLayoutManager layoutManager = (LinearLayoutManager) recyclerView.getLayoutManager();
        View firstVisibleItem = recyclerView.getChildAt(0);
        int firstItemPosition = layoutManager.findFirstVisibleItemPosition();
        int itemHeight = firstVisibleItem.getHeight();
        int firstItemBottom = layoutManager.getDecoratedBottom(firstVisibleItem);
        return (firstItemPosition + 1) * itemHeight - firstItemBottom;}
    

    如此,不断变化的turningLine与RecyclerView的滚动建立了关系;至此,动画与RecyclerView的逻辑关系梳理完毕。按照实现RecyclerView的套路一步步实现最基本的列表效果,然后将动画与滚动监听的关系放入Adapter中。需要强调的是:每一个Item都是随着RecyclerView的滚动进行变化的,所以每一个Item的ViewHolder中都注册RecyclerView的监听事件来监听RecyclerView的滑动。

    不足及期望

    这样的动画效果固然有趣,但是其仍存在很多不足,就自己发现的问题,列不足如下:

    • 每一个Item都监听RecyclerView的滑动事件非常耗时,在低端机上可能存在滑动不流畅的现象,尚未测试,但在红米 Not 3联发科版系统(不得不说这个系统真的很渣,亲测体验)上运行未出现异常。
    • 当RecyclerView滑动太快时,单位滚动距离内,滚动监听事件的触发频率较低,导致有些Item的动画进度未达到100%便从屏幕中消失,从而存在重新滚动到那个Item时,Item的动画停留在1%~99%之间的某一帧,影响RecyclerView的展示效果。
    • 因ImageView设置的ScaleType为CenterCrop,所以图片右侧变化在放大过程中会有类似于金属拉丝的效果,因此图片缩放的scale最好在原来的基础上乘以1.1,在单个Item的动画中此问题已解决,但在RecyclerView中,此问题仍然存在。

    在此,期望有耐心将本文看完的小伙伴们在文章下方的评论里留下宝贵意见,一起来完善这个效果。另,若有小伙伴在Github上看到有这样效果的稳定的第三方库,希望可以在文章下方评论中留下链接。

    代码已上传Github,欢迎访问Follow。

    花两天写了本篇文章,原创不易,转载请注明链接:http://www.jianshu.com/p/4176c1247eed,谢谢!

    相关文章

      网友评论

      本文标题:手把手教你打造RecyclerView滚动特效

      本文链接:https://www.haomeiwen.com/subject/vrihpttx.html