自定义View:实现RecyclerView的item添加悬浮层的效果

*本篇文章已授权微信公众号 guolin_blog (郭霖)独家发布

前言

又到了年底,好多的事情都要收尾,今天分享一个RecyclerView的包装扩展类,帮助大家实现添加Item的浮层的效果。

首先看一下效果图:


在这里插入图片描述

有人会问我:老铁,你实现的这个东西有个卵用?如果你没看明白,我们再看一张非常熟悉的应用场景:


在这里插入图片描述

正文

记得2年前在创业公司的时候,正是短视频火爆的高峰期,公司也做了一款二次元的短视频app,很可惜还没上线就被腰斩了。当时就要求做了这个效果,虽然实现了,但是实现的方案实在是太low了。今天也是弥补了这个遗憾。

实现思路一

在每一个Item中放入一个VideoPlayer,但是缺点太多:

可控性差:控制播放哪一个位置的视频,视频的停止和播放等等,都需要写大量的逻辑;
内存风险高:播放器还是很占用内存的,一个页面持有多个播放器,很容易导致内存泄露;
可维护性差:adapter中不可避免的需要插入播放相关的内容,耦合性强,代码臃肿,后期不易维护。

当然这个方案也有优点,就是不用考虑列表的滑动问题,因为播放器就在item里面。

PS:不得不说我当时用的就是这个思路,现在回想一下实在是太low比了。

实现思路二

实现VideoPlayerController类,单例模式,封装视频播放的相关逻辑,需要播放哪一个视频,添加到指定的item中,不播放移除播放器。

优点:

解耦:将adapter和播放逻辑进行解耦,增强维护性。
优化内存,一个页面仅持有一个播放器。

缺点:

滑动问题:只能适用于滑动停止的时候播放,可扩展性差。
性能问题:添加和移除View,都会重新测量Parent,可能会出现卡顿问题。

这是我偶然想到的一个实现思路,仅仅具有参考意义,不推荐使用。

实现思路三(最终方案)

通过控制一个浮层的显示,隐藏和滑动,覆盖列表中播放位置的item。
优点:

解耦:adapter完全不用写播放逻辑,因为已经被分离到悬浮的View中;
性能:一个列表仅持有一个播放器,也不会涉及到View的测量相关的问题。

缺点:

如果硬要说缺点的话,就是要对列表的滑动控制很精确,熟悉各种api和监听器。

这也是我最终确定的方案,也是目前想到的最完美的方案。

代码

我们为自定义View确命名为:FloatItemRecyclerView

我们的目的是扩展RecyclerView,所以FloatItemRecyclerView的定位是一个包装扩展类,什么是包装扩展类呢?

例如比较有名气的开源框架:PtrClassicFrameLayout,他实现的功能是下拉刷新功能,只要把需要下拉刷新的View放到里面去,就实现了刷新功能,不影响View本身的功能,把对架构的影响降到最低。

开发中,我们的通用架构中往往会使用一些开源的或自定义的RecyclerView,这种设计就会很棒,哪里需要套哪里,十分潇洒。

所以FloatItemRecyclerView内部需要持有一个RecyclerView类型的对象,我们通过泛型可以添加任意类型的RecyclerView的子类。

public class FloatItemRecyclerView<V extends RecyclerView> extends FrameLayout {

    /**
     * 要悬浮的View
     */
    private View floatView;

    /**
     * recyclerView
     */
    private V recyclerView;
    
    /**
     * 控制每一个item是否要显示floatView
     */
    private FloatViewShowHook<V> floatViewShowHook;

    /**
     * 根据item设置是否显示浮动的View
     */
    public interface FloatViewShowHook<V extends RecyclerView> {

        /**
         * 当前item是否要显示floatView
         *
         * @param child    itemView
         * @param position 在列表中的位置
         */
        boolean needShowFloatView(View child, int position);

        V initFloatItemRecyclerView();
    }
}

我们需要通过设置FloatViewShowHook完成的初始化工作:

initFloatItemRecyclerView:添加指定类型的RecyclerView,你需要自己设置LayoutManager和其他属性。

needShowFloatView:判断RecyclerView的某一个child是否需要显示浮层。如果你对RecyclerAdapter添加了Header或者Footer,别忘了对position做加减处理。你可以根据child 的位置或者通过position得到对应的数据,判断是否要显示浮层,例如图片和视频混合的列表,可以实现图片不添加浮层,而视频需要浮层播放的效果。

然后需要添加OnScrollListener监听RecyclerView的滑动状态:

private void initOnScrollListener() {
        RecyclerView.OnScrollListener myScrollerListener = new RecyclerView.OnScrollListener() {
            @Override
            public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
                super.onScrollStateChanged(recyclerView, newState);
                if (floatView == null) {
                    return;
                }
                currentState = newState;
                switch (newState) {
                    // 停止滑动
                    case 0:
                        // 对正在显示的浮层的child做个副本,为了判断显示浮层的child是否发现了变化
                        View tempFirstChild = needFloatChild;
                        // 更新浮层的位置,覆盖child
                        updateFloatScrollStopTranslateY();
                        // 如果firstChild没有发生变化,回调floatView滑动停止的监听
                        if (tempFirstChild == needFloatChild) {
                            if (onFloatViewShowListener != null) {
                                onFloatViewShowListener.onScrollStopFloatView(floatView);
                            }
                        }
                        break;
                    // 开始滑动
                    case 1:
                        // 更新浮层的位置
                        updateFloatScrollStartTranslateY();
                        break;
                    // Fling
                    // 这里有一个bug,如果手指在屏幕上快速滑动,但是手指并未离开,仍然有可能触发Fling
                    // 所以这里不对Fling状态进行处理
//                    case 2:
//                        hideFloatView();
//                        break;
                }
            }

            @Override
            public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
                super.onScrolled(recyclerView, dx, dy);
                if (floatView == null) {
                    return;
                }
                switch (currentState) {
                    // 停止滑动
                    case 0:
                        updateFloatScrollStopTranslateY();
                        break;
                    // 开始滑动
                    case 1:
                        updateFloatScrollStartTranslateY();
                        break;
                    // Fling
                    case 2:
                        updateFloatScrollStartTranslateY();
                        if (onFloatViewShowListener != null) {
                            onFloatViewShowListener.onScrollFlingFloatView(floatView);
                        }
                        break;
                }
            }
        };
        recyclerView.addOnScrollListener(myScrollerListener);
    }

简单的概括实现的逻辑:

  • 静止状态:遍历RecyclerView的child,通过配置的Hook,判断child是否需要显示浮层,找到则跳出循环,通过这个child的位置,更新浮层的位置。
  • 开始滑动:如果有显示浮层的child,不停的刷新浮层的位置。
  • 惯性滑动:注释上已经写的很清楚了,不做处理。

对于child是否显示浮层的判断过程:

/**
 * 计算需要显示floatView的位置
 * 
 * @return 如果找到RecyclerView中对应的child,返回child的位置,否则发挥-1,表示没有要显示浮层的child
*/
 private int calculateShowFloatViewPosition() {
    // 如果没有设置floatViewShowHook,默认返回-1
        if (floatViewShowHook == null) {
            return -1;
        }
        int firstVisiblePosition;
        if (recyclerView.getLayoutManager() instanceof LinearLayoutManager) {
            firstVisiblePosition = ((LinearLayoutManager) recyclerView.getLayoutManager()).findFirstVisibleItemPosition();
        } else {
            throw new IllegalArgumentException("only support LinearLayoutManager!!!");
        }
        int childCount = recyclerView.getChildCount();
        for (int i = 0; i < childCount; i++) {
            View child = recyclerView.getChildAt(i);
            // 判断这个child是否需要显示
            if (child != null && floatViewShowHook.needShowFloatView(child, firstVisiblePosition + i)) {
                return i;
            }
        }
        // -1 表示没有需要显示floatView的item
        return -1;
}

如何判断child被滑出了屏幕呢?可以通过设置监听addOnChildAttachStateChangeListener,判断正在被移除的View是否是显示浮层的View。

// 监听item的移除情况
recyclerView.addOnChildAttachStateChangeListener(new RecyclerView.OnChildAttachStateChangeListener() {
      @Override
      public void onChildViewAttachedToWindow(@NonNull View view) {
      }

      @Override
      public void onChildViewDetachedFromWindow(@NonNull View view) {
          // 判断child是否被移除
          // 请注意:回调onChildViewDetachedFromWindow时并没有真正移除这个child
          // 所以这里增加一个判断:floatChildInScreen是否正在被adapter使用,防止浮层闪烁
          if (view == needFloatChild && floatChildInScreen()) {
              clearFirstChild();
          }
      }
 });
        
/**
 * 判断item是否正在显示内容
*/
private boolean floatChildInScreen() {
    return recyclerView.getChildAdapterPosition(needFloatChild) != -1;
}

这里还额外判断了floatChildInScreen(),这是因为经测试发现,在滚动的时候RecyclerView可能会执行onLayout,在onLayout时,又会把所有的child调用remove,然后回调onChildViewDetachedFromWindow,最终刷新adapter,从而导致浮层闪烁的问题。

通过查看源码发现,dispatchChildDetached负责分发onChildViewDetachedFromWindow,然后才真正移除child:

源码

所以我们可以增加判断:要被移除的正在显示浮层child,如果正在被adapter使用,我们不去隐藏显示浮层,这样就避免了浮层闪烁的问题。具体隐藏闪烁的原因还不清楚,可能跟我与PtrClassicFrameLayout一起使用有关。

我们还得增加一个OnLayoutChangeListener,当设置adapter和数据发生变化的时候会得到这个回调,我们可以重新判断具体哪一个Child要显示浮层。

// 设置OnLayoutChangeListener监听,会在设置adapter和adapter.notifyXXX的时候回调
// 所以我们要这里判断显示浮层的child
recyclerView.addOnLayoutChangeListener(new OnLayoutChangeListener() {
      @Override
      public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) {
          if (recyclerView.getAdapter() == null) {
              return;
          }
          // 数据已经刷新,找到需要显示悬浮的Item
          clearFirstChild();
          // 找到第一个child
          getFirstChild();
          updateFloatScrollStartTranslateY();
          showFloatView();
      }
});

整体思路就是这么简单,如果你需要这样的效果,你只需要添加如下代码:

FloatItemRecyclerView<RecyclerView> recyclerView = findViewById(R.id.recycler_view);
recyclerView.setFloatViewShowHook(this);
recyclerView.setFloatView(getLayoutInflater().inflate(R.layout.float_view, (ViewGroup) getWindow().getDecorView(), false));
recyclerView.setOnFloatViewShowListener(this);
recyclerView.setAdapter(new MyAdapter());

// 手动查询要显示浮层的child
recyclerView.findChildToPlay()

效果就是第一张图,这里就不重复贴出来了。

最后

突然想起在网上看到的一个段子:一个Android开发程序员,因为不会使用RecyclerView面试被拒了。

无论这个段子的是真是假,可见熟练使用RecyclerView已经变得非常重要。我们要开发一个列表,是选择ListView还是RecyclerView呢?简单说一下我的经验:

1、
开发通用架构推荐使用RecyclerView,否则你可能要维护多套不同样式的库。(列表,网格,瀑布流,自定义等,便于扩展)
2、
仅仅是开发一个列表,推荐使用RecyclerView,如果产品经理心情好,换成瀑布流怎么办。(便于维护)
3、
如果开发自定义View,且列表需要Header或Footer:如果和项目耦合性较强,且已经有扩展好的RecyclerView.Adapter,可以优先考虑使用RecyclerView;如果是想写开源库,ListView可以优先考虑,因为选择RecyclerView需要捆绑一个可以添加Header和Footer的Adapter,需要慎重考虑。

之后我会再做一个ListView的版本,方便大家使用。

以上就是今天分享的内容,希望对大家今后的学习工作有所帮助。本来想发布到jcenter上,不过似乎gradle 4.6和bintray插件不兼容,只能暂时上传到github上,大家可以下载查看具体内容。

https://github.com/li504799868/FloatItemRecyclerView

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

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,898评论 25 707
  • 用两张图告诉你,为什么你的 App 会卡顿? - Android - 掘金 Cover 有什么料? 从这篇文章中你...
    hw1212阅读 12,708评论 2 59
  • 【Android 控件 RecyclerView】 概述 RecyclerView是什么 从Android 5.0...
    Rtia阅读 307,448评论 27 439
  • 原文链接:https://github.com/opendigg/awesome-github-android-u...
    IM魂影阅读 32,923评论 6 472
  • 家电行业的老大没落,已经没有盈利能力,即使有补贴也不行!任何一个行业都不会是一直会发展下去,经历了这么多年,我们已...
    娱乐1阅读 114评论 0 0