自定义ViewGroup练习之仿写RecycleView

哈哈,标题很唬人,其实就是根据RecyclerView的核心思想来写一个简单的列表控件。

RecycleView的核心组件

  • 回收池:可以回收任意的item控件,并可以根据需要返回特定的item控件。
  • 适配器:Adapter接口,帮助RecycleView展示列表数据,使用适配器模式,将界面展示跟交互分离
  • RecycleView:主要做用户交互,事件触摸反馈,边界值的判断,协调回收池和适配器对象之间的工作。

下面就开始把上面的三个东西写出来,前两个都很简单,最后的RecyclerView稍微复杂一点

回收池

当然这里只是简单的实现一个回收池,具体RecyclerView的回收原理可以看之前的文章RecycleView的缓存原理

定义一个类叫做Recycler。我们想一下,一个回收池可以缓存一些View,第一次加载的时候,我们需要创建一些item把这个屏幕填满,当我们向上滑动的时候,最上面的item移除屏幕外面,我们需要把这个移除的item放到缓存池中,屏幕最下面如果有item需要填充的话,先去缓存池中寻找是否有缓存的item,如果有直接拿过来填充数据,如果没有就重新建一个新的item填充。

这个地方涉及到快速的添加和删除操作,所以这里使用Stack(栈)这个数据结构来缓存,它具有后进先出的特性。

代码如下

public class Recycler {

    private Stack<View>[] mViews;
    
    /**
     *
     * @param typeNum 有几种类型
     */
    public Recycler(int typeNum){

        mViews = new Stack[typeNum];

        //RecyclerView中可能有不同的布局类型,不同的type分开缓存
        for (int i = 0; i < typeNum; i++) {
            mViews[i] = new Stack<>();
        }
    }

    public void put(View view,int type){
        mViews[type].push(view);
    }

    public View get(int type){
        try {
            return mViews[type].pop();
        }catch (Exception e){
            return null;
        }
    }
}

这里为什么使用一个Stack的数组呢,因为我们平时使用RecyclerView的时候,会有多种布局类型的情况,那么我们复用的时候肯定只能复用跟自己类型一样的item,所以使用一个Stack的数组,不同的类型缓存在不同的Stack中,数组的大小就是我们布局类型的种类数。然后添加get 和 put 方法。

适配器

Adapter很简单,定义一个接口,供外部使用,接口里面有什么方法呢,直接去RecyclerView中看看然后把名字抄过来哈哈。因为是简单的实现嘛,就不涉及到ViewHolder相关的东西啦。

   interface Adapter{
        View onCreateViewHodler(int position, View convertView, ViewGroup parent);
        View onBinderViewHodler(int position, View convertView, ViewGroup parent);
        int getItemViewType(int row);
        int getViewTypeCount();
        int getCount();
        int getHeight(int index);
    }

使用的时候,也很简单,在我们自己的MyRecyclerView中定义一个setAdapter方法直接用这个set方法就好啦。然后在重写的各个方法中创建我们的item,或者给item绑定数据

MyRecyclerView recyclerView = findViewById(R.id.recycleview);
        recyclerView.setAdapter(new MyRecyclerView.Adapter() {
            @Override
            public View onCreateViewHodler(int position, View convertView, ViewGroup parent) {
                convertView=  getLayoutInflater().inflate( R.layout.list_item,parent,false);
                TextView textView= (TextView) convertView.findViewById(R.id.tvname);
                textView.setText("name "+position);
                return convertView;
            }

            @Override
            public View onBinderViewHodler(int position, View convertView, ViewGroup parent) {
                TextView textView= (TextView) convertView.findViewById(R.id.tvname);
                textView.setText("name "+position);
                return convertView;
            }
            @Override
            public int getItemViewType(int row) {
                return 0;
            }

            @Override
            public int getViewTypeCount() {
                return 1;
            }

            @Override
            public int getCount() {
                return 40;
            }

            @Override
            public int getHeight(int index) {
                return 150;
            }
        });

MyRecyclerView

重头戏MyRecyclerView来啦

public class MyRecyclerView extends ViewGroup {......}

它继承自ViewGroup,主要包括两个部分,布局部分和滑动部分。我们先写布局的部分,自定义ViewGroup主要包括测量和布局两个重要的部分,分别是重写onMeasure和onLayout方法

 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        final int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        final int heightSize = MeasureSpec.getSize(heightMeasureSpec);

        if(mAdapter!=null){
            rowCount = mAdapter.getCount();
            heights = new int[rowCount];
            for (int i = 0; i < rowCount; i++) {
                heights[i] = mAdapter.getHeight(i);
            }
        }
        int totalH = sumArray(heights, 0, heights.length);
        setMeasuredDimension(widthSize,Math.min(heightSize,totalH));

        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    }

onMeasure方法很简单,首先从Adapter中拿到总共有多少条数据,和每一条item的高度,然后把这个高度值存在一个数组中。

因为我们的目的是做一个列表,所以宽度部分我们就忽略不关心,直接使用其实际测量的大小就好了。我们主要看高度部分。

对于高度部分,我们需要根据item的高度之和来动态设置,如果我们列表item的高度的和大于了测量的高度,就使用测量的高度,反之则使用item高度之和作为其高度。

也就是说item的高之和如果小于屏幕高度,那么我们MyRecyclerView的高度就应该是这个和,反之就有item在屏幕之外了,所以我们的MyRecyclerView高度为屏幕高度就好啦。

求item总高度的计算公式我们封装成一个方法,后面也会用到

  private int sumArray(int array[], int firstIndex, int count) {
        int sum = 0;
        count += firstIndex;
        for (int i = firstIndex; i < count; i++) {
            sum += array[i];
        }
        return sum;
    }

第一个参数就是数组,第二个参数和第三个参数可以表示一个区间,我们求这个区间内的item的总高度,比如数组的第10个到第30之间的总高度。onMeasure中传入0到 heights.length就是总item的高度了。

onMeasure完成之后就是onLayout方法啦

protected void onLayout(boolean changed, int l, int t, int r, int b) {
        if(needRelayout&&changed){
            needRelayout = false;
            mCurrentViewList.clear();
            removeAllViews();
            if(mAdapter!=null){
             width = r-l;
             height = b-t;
             int top =0;
                for (int i = 0; i < rowCount&&top<height; i++) {
                    int bottom = heights[i]+top;
                    View view = createView(i,width,heights[i]);
                    view.layout(0,top,width,bottom);
                    mCurrentViewList.add(view);
                    top = bottom;
                }
            }
        }
    }

因为布局的方法可能会被触发多次,所以使用一个标志位needRelayout来保证只有在布局改变的时候才重新布局,避免不必要的性能损失。

定义一个集合mCurrentViewList来保存当前屏幕上的item,我们拿到一个item后放入这个集合中,当item的的总高度,或者最后一个item的顶部的高度大于屏幕总高度的时候,就不往集合里面放了。这也保证在布局类型一样的时候,我们只会创建这么多的item,以后就可以复用了。只有布局类型在多一种的时候才会考虑重新创建新的item

得到一个子View之后,找到这个子View的左 上 右 下 的位置,调用子View的layout方法来布局这个子view。

怎么得到一个item呢,定义一个createView方法

   private View createView(int row, int width, int height) {
        int itemType= mAdapter.getItemViewType(row);
        View reclyView = mRecycler.get(itemType);
        View view = null;
        if(reclyView==null){
            view = mAdapter.onCreateViewHodler(row,reclyView,this);
            if (view == null) {
                throw new RuntimeException("必须调用onCreateViewHolder");
            }
        }else {
            view = mAdapter.onBinderViewHodler(row,reclyView,this);
        }
        view.setTag(1234512045, itemType);
        view.measure(MeasureSpec.makeMeasureSpec(width,MeasureSpec.EXACTLY)
                ,MeasureSpec.makeMeasureSpec(height,MeasureSpec.EXACTLY));
        addView(view,0 );
        return view;
    }

首先通过adapter拿到布局类型,然后根据布局类型去缓存池中寻找,如果找到了,就调用onBinderViewHodler方法来绑定数据,如果没有找到,调用onCreateViewHodler方法来创建一个新的item。

然后给这个新建的View设置一个tag,值就是它的布局类型,因为我们开始建立回收池的时候是建立的一个Stack数组,数组下标就是布局类型,所以这里设置tag方便我们回收的时候拿到布局类型

最后就是测量一下新建的子View,并通过addView方法放入到布局中。

通过上面的步骤,运行之后就可以看到一个列表就铺满整个屏幕了。不过这个列表现在是不能滑动的,现在我们来给它加上滑动的功能。

事件的处理我们重写两个方法,onInterceptTouchEvent来拦截事件,onTouchEvent方法来处理事件

    public boolean onInterceptTouchEvent(MotionEvent ev) {
        boolean intercepted = false;
        switch (ev.getAction()){
            case MotionEvent.ACTION_DOWN:
                //记录下手指按下的位置
                currentY = ev.getRawY();
                break;
            case MotionEvent.ACTION_MOVE:
                //当手指的位置大于最小滑动距离的时候拦截事件
                float moveY = currentY - ev.getRawY();
                if(Math.abs(moveY)>touchSlop){
                    intercepted =  true;
                }
            default:
        }
        return intercepted;
    }

当按下(ACTION_DOWN)事件的时候,记录下当前手指点击的位置,当移动(ACTION_MOVE)事件的时候,判断我们的手指移动的距离是不是大于系统规定的最小距离,如果是就返回true拦截事件

系统规定的最小距离可能每个手机都不一样,还好系统提供了响应的方法来让我们获取

    //获取系统最小滑动距离
    ViewConfiguration configuration = ViewConfiguration.get(context);
    touchSlop = configuration.getScaledTouchSlop();

注意:如果我们监听了onInterceptTouchEvent中的ACTION_MOVE事件,需要在布局文件中添加clickable为true,否则不会调用ACTION_MOVE方法。具体原因可以去查看系统事件拦截机制的源码。或者看这篇文章重写了onInterceptTouchEvent(ev)方法,但是为什么Action_Move分支没执行

 <com.chs.androiddailytext.recyclerview.MyRecyclerView
        android:id="@+id/recycleview"
        android:clickable="true"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>

下面来看onTouchEvent,这个方法中我们只需要监听ACTION_MOVE事件就好了。

public boolean onTouchEvent(MotionEvent event) {

       if(event.getAction() == MotionEvent.ACTION_MOVE){
          //滑动距离
           int diff = (int) (currentY - event.getRawY());
          //上滑是正 下滑是负数
           //因为调用系统的scrollBy方法,只是滑动当前的MyRecyclerView容器
           //我们需要在滑动的时候,动态的删除和加入子view,所以重写系统的scrollBy方法
           scrollBy(0,diff);
       }
        return super.onTouchEvent(event);
    }

求出我们手指的滑动距离,上滑是正下滑是负,然后调用scrollBy方法,传入移动的距离来移动View。不过scrollBy是ViewGroup中的方法,调用它只能滑动我们的MyRecyclerView,并不能滑动其内部的item子View,所以只能重写这个方自己来控制字item的移动了。

      public void scrollBy(int x, int y) {
        scrollY+=y;
        scrollY = scrollBounds(scrollY);
        //<1>上滑
        if(scrollY>0){
         //上滑移除最上面的一条
         while (scrollY>heights[firstRow]){
           removeView(mCurrentViewList.remove(0));
           //scrollY的值保持在0到一条item的高度之间
           scrollY -= heights[firstRow];
           firstRow++;
         }
         //<2>上滑加载最下面的一条
        // 当剩下的数据的总高度小于屏幕的高度的时候
         while (getFillHeight() < height){
            int addLast = firstRow + mCurrentViewList.size();
             View view = createView(addLast,width,heights[addLast]);
             //上滑是往mCurrentViewList中添加数据
             mCurrentViewList.add(mCurrentViewList.size(),view);
         }
        }else if(scrollY<0){
            //<3>下滑最上面加载
            //这里判断scrollY<0即可,滑到顶置零
            while (scrollY<0){
                //第一行应该变成firstRow - 1
                int firstAddRow = firstRow - 1;
                View view = createView(firstAddRow, width, heights[firstAddRow]);
                //找到view添加到第一行
                mCurrentViewList.add(0,view);
                firstRow --;
                scrollY += heights[firstRow+1];
            }
            //<4>下滑最下面移除
            while (sumArray(heights, firstRow, mCurrentViewList.size())-scrollY>height){
                removeView(mCurrentViewList.remove(mCurrentViewList.size() - 1));
            }
//            while (sumArray(heights, firstRow, mCurrentViewList.size()) - scrollY - heights[firstRow + mCurrentViewList.size() - 1] >= height) {
//                removeView(mCurrentViewList.remove(mCurrentViewList.size() - 1));
//            }
        }
        //重新布局
        repositionViews();
    }

这里我们通过判断scrollY的正负值来判断向上滑动还是向下滑动,当scrollY大于0的时候说明上滑,反之则是下滑。

主要分四步:

  1. 上滑的时候,最上面的子View移除屏幕
  2. 上滑的时候,最下面的子View,如果需要,填充到屏幕
  3. 下滑的时候,移出去的子View需要填充进屏幕
  4. 下滑的时候,最下面的子View,需要移除屏幕。

使用firstRow这个标志位来判断当前屏幕的第一行,在我们总的数据中占第几个。从0开始,每移出去一个item,它就加一 ,移进来一个item它就减一,还记得最开始的sumArray方法吗,它可以求一个区间内的item的总高度。这里如果我们传入当前的firstRow,和数据的总个数,就可以求出从当前第一行到数据总和之间的item的总高度。这个高度很有用,它关系着我们最下面对元素是否要填充屏幕。

我们之前定义了一个mCurrentViewList来保存当前屏幕上的现实的View,移入移除的原理就是我们添加进这个集合和从这个集合中删除一个View的过程。移动完成之后,调用repositionViews方法在重新把mCurrentViewList中的子View布局一边即可,如下:

   private void repositionViews() {
        int left, top, right, bottom, i;
        top =  - scrollY;
        i = firstRow;
        for (View view : mCurrentViewList) {
            if(i<heights.length){
                bottom = top + heights[i++];
                view.layout(0, top, width, bottom);
                top = bottom;
            }
        }
    }

scrollBy方法中最开始给 scrollY 赋值的时候,我们调用了一个scrollBounds(scrollY),主要是用来判断边界值,防止数组越界的崩溃发生

  1. 下滑极限值,通过sumArray方法,我们可以求出从数据的第0个元素到当前第一行firstRow之间item的总高度。当这个高度为0的时候,说明我们已经滑到了真正的第一行,这时候scrollY也应该被赋值为0
  2. 上滑极限值,通过sumArray方法,我们可以算出当前的第一行firstRow到总数据最后一个之间的item的总高度,如果小于当前屏幕的高度了,那就不会有新的item可以填充进来了,这时候scrollY的值就需要定格在当前的高度不能再增加了。

判断极限值的代码如下:

 private int scrollBounds(int scrollY) {
        //上滑极限值
        if (scrollY > 0) {
            scrollY = Math.min(scrollY,sumArray(heights, firstRow, heights.length-firstRow)-height);
        }else {
            //下滑极限值
            scrollY = Math.max(scrollY, -sumArray(heights, 0, firstRow));
        }
        return scrollY;

    }

OK到这里这个自定义ViewGroup的练习就结束啦,最终效果如下
在这里插入图片描述
源码地址

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值