29

自定义ViewGroup练习之仿写RecycleView

 5 years ago
source link: https://blog.csdn.net/mingyunxiaohai/article/details/89296318?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/mingyunxiaohai/article/details/89296318

哈哈,标题很唬人,其实就是根据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&⊤<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的练习就结束啦,最终效果如下

bQNJreU.gif源码地址

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK