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

本篇文章已授权微信公众号 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,谢谢!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,761评论 5 460
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,953评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,998评论 0 320
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,248评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,130评论 4 356
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,145评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,550评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,236评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,510评论 1 291
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,601评论 2 310
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,376评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,247评论 3 313
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,613评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,911评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,191评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,532评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,739评论 2 335

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,079评论 25 707
  • 内容抽屉菜单ListViewWebViewSwitchButton按钮点赞按钮进度条TabLayout图标下拉刷新...
    皇小弟阅读 46,659评论 22 664
  • 简介: 提供一个让有限的窗口变成一个大数据集的灵活视图。 术语表: Adapter:RecyclerView的子类...
    酷泡泡阅读 5,113评论 0 16
  • 前面的话 总觉得诗意和哲理之类的,是零碎的、断续的、明灭的。多有两万七千行的诗剧,峰峦重叠的逻辑著作,歌德、黑格尔...
    Ms倩阅读 1,155评论 0 0
  • 这是一个关于边伯贤和朴灿烈的故事,没有华丽的词藻精彩的情节,仅仅只是我心中边伯贤和朴灿烈的故事。 【开端】 边伯贤...
    边伯贤迷妹阿懵阅读 256评论 0 0