Android 进阶学习(七) RecyclerView 学习 (一) 自义定LayoutManager

在学习RecyclerView 之前根本没有想到RecyclerView 学习起来会消耗我这么多时间,从最开始看到开始写这篇文章大约用了一周的时间吧,期间看了很多博客,但是很多人写的东西我拿过来用根本不能达到我想要的结果,虽然我现在看的androidx 中关于RecyclerView 的源码,但是我觉得差别应该没有那么大,导致在写demo的过程中饶了很多弯路,在这里只是调侃一下为什么大部分人写帖子都是粘贴过来的,废话不多说了,进入正题

自定义 LayoutManager 中一些重要的方法

onLayoutChildren

onLayoutChildren 是 LayoutManager 测量和布局child 的入口,如果调用 manager 的requestLayout的方法,这个方法也会执行,所以这个方法中不能还原属性,

getItemCount

getItemCount 获取的item 的个数就是Adapter 中返回的个数,

getChildCount

getChildCount 是recyclerView 中可见的item 的数量,即没有被放进缓存的item 的数量,

image.png

从这个图片我们看到 ,如果我们遍历getChildCount ,那么我们是从可见列表中开始遍历的,他的position 也就是在adapter中的position 和 getChildCout 中的position 并不一定相同

getChildAt

获取屏幕中的第一个item,即getChildCount 中的第一个item

measureChildWithMargins

测量child ,但是child 可能包含Divider,机会被计算在内,

getDecoratedMeasuredHeight getDecoratedMeasuredWidth

这两个方法可以在测量过后得到child 的宽高,如果child 的宽高是一样的,那么只需要测量一次我们就知道了所有child 的宽高

recycler.getViewForPosition

recycler.getViewForPosition会得到一个view, 他的入参是一个position,这个position 就是item在adapter 中的position,获取View 的过程是一个相对复杂的过程,他到底是如何工作的,我会在下一篇文章说,

addView

addView 会将view 添加到 屏幕可见的item 列表中

layoutDecoratedWithMargins layoutDecorated

这两个方法是绘制 child 的方法, 可以让view 显示在recyclerview中

canScrollHorizontally canScrollVertically

这两个方法从字面意思的意思就能看出来,他是一个开关方法,控制着manager 的 水平和垂直是否可以滑动

offsetChildrenHorizontal offsetChildrenVertical

这两个方法非常重要,控制recyclerView 的水平和垂直的偏移量,在 使用这两个方法让manager 的偏移量发生变化时,item 所在的位置,也需要相对偏移量发生变化,

image.png

detachAndScrapAttachedViews

detachAndScrapAttachedViews 这个方法控制的数据也是getChildCount 中的数据,也就是显示在屏幕上面的数据,暂时先Detach掉,放入到Scrap缓存中 ,在后续绘制的过程中会很快的匹配到

removeAndRecycleView

removeAndRecycleView 这个方法就是回收子view了,将它放入到缓存中, 具体放入到哪里我们下一篇分析

到了这里所有我了解的比较重要的方法已经都完成了,接下来我么进入正题,期间我会写一下我所遇到的坑,先上一下我们要实现的效果图,

GIF 2020-11-16 14-46-02.gif

我们先一点一点开始
继承 RecyclerView.LayoutManager ,我们必须实现 generateDefaultLayoutParams 这个方法

   @Override
   public RecyclerView.LayoutParams generateDefaultLayoutParams() {
       return new RecyclerView.LayoutParams(RecyclerView.LayoutParams.WRAP_CONTENT, RecyclerView.LayoutParams.WRAP_CONTENT);
   }

onLayoutChildren 作为入口方法,我们先简单的实现一下布局的排列

   @Override
   public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
       if (getItemCount() == 0 || state.isPreLayout()) {///没有数据或者是 执行动画期间
           return;
       }
       for(int i=0;i<getItemCount();i++){
           View child = recycler.getViewForPosition(i);
           measureChildWithMargins(child,0,0);
           addView(child);
           layoutDecoratedWithMargins(child,getDecoratedMeasuredWidth(child)*i,0,getDecoratedMeasuredWidth(child)*(i+1),getDecoratedMeasuredHeight(child));
       }
}
image.png

方法很简单,根据测量的宽高水平排列这个child,这个就是我们实现的效果,但是不能滑动,上面我们介绍到如果想要滑动必须先打开滑动开关,

   @Override
   public boolean canScrollHorizontally() {
       return true;
   }

实现了这个方法后,我们重新运行还是不能滑动,我们接下来再继续实现scrollHorizontallyBy 这个方法,

@Override
   public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state) {
       int index = dx;
       offsetChildrenHorizontal(index);
       return index;
   }

我们发现这时会滑出屏幕外,我们继续修正

   @Override
   public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state) {
       int index = dx;
       if ((index + horizontal_offset) < 0) {///修正偏移量
           index = -horizontal_offset;
       } else if ((index + horizontal_offset) > getMaxScrollWidth()) {/////修正偏移量
           index = getMaxScrollWidth() - horizontal_offset;
       }
       horizontal_offset += index;
       offsetChildrenHorizontal(index);
       return index;
   }

写到这里如果不写回收的话就已经结束了,但是我们要实现滑出屏幕外回收,就需要继续对他改造,想要回收我们肯定不能直接把所有的view都测量,而是哪个view在屏幕上就测量哪个view,并把它添加到recyclerView中,并绘制出来,
这里我们先定义几个变量,

///水平偏移量,
private int horizontal_offset = 0;

控件是否在屏幕内,我么只需要根据控件水平偏移量,看看这个控件和偏移量的关系即可

   /**
    * 判断是否在屏幕外
    *
    * 控件的右边小于偏移量或者左边大于偏移量+ 控件的的宽度
    *   
    * @param width_offset 两个child 之间的距离
    * @param child_widht   view 的宽度  show_width 整个recyclerview 的宽度
    * @return
    */
   public boolean isOutOfRange(int position){
       int child_left = (position * (child_widht + width_offset) + first_child_margin);
       int child_right = ((position + 1) * (width_offset + child_widht) + first_child_margin);
       if ((child_right) < (horizontal_offset) || (child_left) > (horizontal_offset + show_width)) {
           return true;
       }
       return false;
   }

了解了这个我们在重新修改onLayoutChildren 这个方法

   @Override
   public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
       if (getItemCount() == 0 || state.isPreLayout()) {///没有数据或者是 执行动画期间
           return;
       }
       View child;
       if (getChildCount() == 0) {///屏幕上没有数据,
           child = recycler.getViewForPosition(0);
       } else {
           child = getChildAt(0); //执行requestLayout 后,屏幕上就有数据,所以执行的是这个方法
       }
       measureChildWithMargins(child, 0, 0);//测量child  由于所有child 的宽高都是一致的,
       child_height = getDecoratedMeasuredHeight(child);//或者child 的高度
       child_widht = getDecoratedMeasuredWidth(child);//获取child 的宽度
       first_child_margin = getWidth() / 2 - child_widht / 2;///前面预留的宽度  recyclerview 的宽度除以2 - child的宽度除以2
       show_width = getWidth();
       layoutItem(recycler, state,0);
   }

初始化了数据之后我们还要根据偏移量重新对child 进行add,并对getChildCout (recyclerview 所包含的数据) 遍历,回收那些滑出屏幕外的child

   /**
    * 根据偏移量 重新布局item
    * @param recycler
    * @param state
    * @param dx
    */
   private void layoutItem(RecyclerView.Recycler recycler, RecyclerView.State state,int dx) {
       if (state.isPreLayout()) {
           return;
       }
       detachAndScrapAttachedViews(recycler);///暂时先detach
       for (int i = 0; i < getItemCount(); i++) {
           if(isOutOfRange(i)){///如果在屏幕外
               continue;
           }
           View child = recycler.getViewForPosition(i);///或者到这个view
           measureChildWithMargins(child, 0, 0);///重新测量,否则view 在复用的时候即使执行了bindViewHolder,也没有数据
           addView(child);//重新add
           ///这个左边应该是减去偏移的左边
           int left = first_child_margin + (child_widht + width_offset) * i;
           setScanAnim(child, left);///设置缩放动画
           layoutDecoratedWithMargins(child, left - horizontal_offset, 0, left + child_widht - horizontal_offset, child_height);
       }

       for (int m = 0; m < getChildCount(); m++) {
           View child=getChildAt(m);
           int i = getPosition(child);
           if(isOutOfRange(i)){///如果在屏幕外面
               removeAndRecycleView(child,recycler);//在屏幕外则回收
           }
       }
   }

在滑动scrollHorizontallyBy这个方法时,也调用layoutItem方法

   @Override
   public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state) {
       int index = dx;
       if ((index + horizontal_offset) < 0) {///修正偏移量
           index = -horizontal_offset;
       } else if ((index + horizontal_offset) > getMaxScrollWidth()) {/////修正偏移量
           index = getMaxScrollWidth() - horizontal_offset;
       }
       horizontal_offset += index;
//        ///通知控件水平移动  非常重要,要不然不会移动  该方法必须要放在   offsetChildrenHorizontal 之前,否则有问题,
       layoutItem(recycler, state,index);

       offsetChildrenHorizontal(index);
       return index;
   }

当然缩放的时机也在layoutItem里面

   /**
    * 缩放
    * @param child
    * @param left
    */
   public void setScanAnim(View child, int left) {
       int child_center = (left - horizontal_offset + left + child_widht - horizontal_offset) / 2;
       int parentCenter = getWidth() / 2;
       float scale;
       if (child_center > parentCenter) {
           scale = 1.0f - (1 - 0.8f) * ((child_center - parentCenter) / (parentCenter * 1.0f));
       } else {
           scale = 1.0f - (1 - 0.8f) * ((parentCenter - child_center) / (parentCenter * 1.0f));
       }
       child.setScaleX(scale);
       child.setScaleY(scale);
   }

关于在离开触摸的时候自动修正position ,我们需要借助 anim来实现, 下面我贴一下所有的代码,至于想要实现更复杂的方法,大家可以参考一个github上一些开源控件,即使不能使用,自己修改一下也肯定可以满足自身的需求

public class TsmRouteManager extends RecyclerView.LayoutManager {

   public TsmRouteManager() {
       horizontal_offset = 0;
   }

   /**
    * 最小的缩放
    */
   private float minScanSize = 0.8f;
   /**
    * 最大的缩放
    */
   private float maxScanSize = 1.2f;
   /**
    * 第一个控件的偏移量
    */
   private int first_child_margin;

   private int child_widht;
   private int child_height;

   /**
    * 第一个控件的偏移量
    */
   private int horizontal_offset = 0;


   private int width_offset = 25;


   /**
    * 可见的高度
    */
   private int show_width;
   private ValueAnimator selectAnimator;


   @Override
   public RecyclerView.LayoutParams generateDefaultLayoutParams() {
       return new RecyclerView.LayoutParams(RecyclerView.LayoutParams.WRAP_CONTENT, RecyclerView.LayoutParams.WRAP_CONTENT);
   }

   @Override
   public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
       if (getItemCount() == 0 || state.isPreLayout()) {///没有数据或者是 执行动画期间
           return;
       }
       View child;
       if (getChildCount() == 0) {///屏幕上没有数据,
           child = recycler.getViewForPosition(0);
       } else {
           child = getChildAt(0); //执行requestLayout 后,屏幕上就有数据,所以执行的是这个方法
       }
       measureChildWithMargins(child, 0, 0);//测量child  由于所有child 的宽高都是一致的,
       child_height = getDecoratedMeasuredHeight(child);//或者child 的高度
       child_widht = getDecoratedMeasuredWidth(child);//获取child 的宽度
       first_child_margin = getWidth() / 2 - child_widht / 2;///前面预留的宽度  recyclerview 的宽度除以2 - child的宽度除以2
       show_width = getWidth();
       layoutItem(recycler, state,0);
   }


   /**
    * 根据偏移量 重新布局item
    * @param recycler
    * @param state
    * @param dx
    */
   private void layoutItem(RecyclerView.Recycler recycler, RecyclerView.State state,int dx) {
       if (state.isPreLayout()) {
           return;
       }
       detachAndScrapAttachedViews(recycler);///暂时先detach
       for (int i = 0; i < getItemCount(); i++) {
           if(isOutOfRange(i)){///如果在屏幕外
               continue;
           }
           View child = recycler.getViewForPosition(i);///或者到这个view
           measureChildWithMargins(child, 0, 0);///重新测量,否则view 在复用的时候即使执行了bindViewHolder,也没有数据
           addView(child);//重新add
           ///这个左边应该是减去偏移的左边
           int left = first_child_margin + (child_widht + width_offset) * i;
           setScanAnim(child, left);///设置缩放动画
           layoutDecoratedWithMargins(child, left - horizontal_offset, 0, left + child_widht - horizontal_offset, child_height);
       }

       for (int m = 0; m < getChildCount(); m++) {
           View child=getChildAt(m);
           int i = getPosition(child);
           if(isOutOfRange(i)){///如果在屏幕外面
               removeAndRecycleView(child,recycler);//在屏幕外则回收
           }
       }
   }

   /**
    * 判断是否在屏幕外
    *
    * 控件的右边小于偏移量或者左边大于偏移量+ 控件的的宽度
    *
    * @param position
    * @return
    */
   public boolean isOutOfRange(int position){
       int child_left = (position * (child_widht + width_offset) + first_child_margin);
       int child_right = ((position + 1) * (width_offset + child_widht) + first_child_margin);
       if ((child_right) < (horizontal_offset) || (child_left) > (horizontal_offset + show_width)) {
           return true;
       }
       return false;
   }


   /**
    * 缩放
    * @param child
    * @param left
    */
   public void setScanAnim(View child, int left) {
       int child_center = (left - horizontal_offset + left + child_widht - horizontal_offset) / 2;
       int parentCenter = getWidth() / 2;
       float scale;
       if (child_center > parentCenter) {
           scale = 1.0f - (1 - 0.8f) * ((child_center - parentCenter) / (parentCenter * 1.0f));
       } else {
           scale = 1.0f - (1 - 0.8f) * ((parentCenter - child_center) / (parentCenter * 1.0f));
       }
       child.setScaleX(scale);
       child.setScaleY(scale);
   }

   @Override
   public boolean isAutoMeasureEnabled() {
       return true;
   }

   @Override
   public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state) {
       int index = dx;
       if ((index + horizontal_offset) < 0) {///修正偏移量
           index = -horizontal_offset;
       } else if ((index + horizontal_offset) > getMaxScrollWidth()) {/////修正偏移量
           index = getMaxScrollWidth() - horizontal_offset;
       }
       horizontal_offset += index;
//        ///通知控件水平移动  非常重要,要不然不会移动  该方法必须要放在   offsetChildrenHorizontal 之前,否则有问题,
       layoutItem(recycler, state,index);

       offsetChildrenHorizontal(index);
       return index;
   }


   public int getMaxScrollWidth() {
       return getItemCount() * child_widht - getWidth() / 2 + first_child_margin - child_widht / 2 + width_offset * (getItemCount() + 1);
   }


   @Override
   public void onScrollStateChanged(int state) {
       super.onScrollStateChanged(state);
       switch (state) {
           case RecyclerView.SCROLL_STATE_DRAGGING:
               //当手指按下时,停止当前正在播放的动画
               cancelAnimator();
               break;
           case RecyclerView.SCROLL_STATE_IDLE:
               smoothScrollToPosition(findShouldSelectPosition());
               break;
       }
   }

   public void smoothScrollToPosition(int position) {
       if (position > -1 && position < getItemCount()) {
           startValueAnimator(position);
       }
   }


   public void cancelAnimator() {
       if (selectAnimator != null && (selectAnimator.isStarted() || selectAnimator.isRunning())) {
           selectAnimator.cancel();
       }
   }


   /**
    * 开启动画,
    * @param position
    */
   private void startValueAnimator(int position) {
       cancelAnimator();

       final float distance = getScrollToPositionOffset(position);
       long minDuration = 100;
       long maxDuration = 300;
       long duration;

       float distanceFraction = (Math.abs(distance) / (child_widht + width_offset));

       if (distance <= (child_widht + width_offset)) {
           duration = (long) (minDuration + (maxDuration - minDuration) * distanceFraction);
       } else {
           duration = (long) (maxDuration * distanceFraction);
       }
       selectAnimator = ValueAnimator.ofFloat(0.0f, distance);
       selectAnimator.setDuration(duration);
       selectAnimator.setInterpolator(new LinearInterpolator());
       final float startedOffset = horizontal_offset;
       selectAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
           @Override
           public void onAnimationUpdate(ValueAnimator animation) {
               float value = (float) animation.getAnimatedValue();
               horizontal_offset = (int) (startedOffset + value);
               requestLayout();
           }
       });
       selectAnimator.start();
   }

   /**
    * 计算这个位置的时候 少减去 first_child_margin 后,正好让下一个view 处于屏幕中央
    *
    * @param position
    * @return
    */
   private float getScrollToPositionOffset(int position) {
       return position * (child_widht + width_offset) - Math.abs(horizontal_offset);
   }

   /**
    * 计算这个position的时候 少减去 first_child_margin 后,正好让下一个view 处于屏幕中央
    *
    * @return
    */
   private int findShouldSelectPosition() {
       if (getItemCount() == 0) {
           return -1;
       }
       int position = (int) (Math.abs(horizontal_offset) / (child_widht + width_offset));
       int remainder = (int) (Math.abs(horizontal_offset) % (child_widht + width_offset));
       // 超过一半,应当选中下一项
       if (remainder >= (child_widht + width_offset) / 2.0f) {
           if (position + 1 <= getItemCount() - 1) {
               return position + 1;
           }
       }
       return position;
   }


   @Override
   public boolean canScrollHorizontally() {
       return true;
   }


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

推荐阅读更多精彩内容

  • 前言 「知足常乐」,很多人不满足现状,各种折腾,往往舍本逐末,常乐才能少一分浮躁,多一分宁静。近期在小编身上发生了...
    文淑阅读 2,624评论 0 30
  • 原文链接如果你是Android开发者,到现在为止,你应该至少听说过RecyclerView;一个新的组件,将被加入...
    大飛機阅读 6,351评论 3 6
  • 久违的晴天,家长会。 家长大会开好到教室时,离放学已经没多少时间了。班主任说已经安排了三个家长分享经验。 放学铃声...
    飘雪儿5阅读 7,523评论 16 22
  • 创业是很多人的梦想,多少人为了理想和不甘选择了创业来实现自我价值,我就是其中一个。 创业后,我由女人变成了超人,什...
    亦宝宝阅读 1,812评论 4 1
  • 今天感恩节哎,感谢一直在我身边的亲朋好友。感恩相遇!感恩不离不弃。 中午开了第一次的党会,身份的转变要...
    迷月闪星情阅读 10,567评论 0 11