自定义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[] 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分支没执行
下面来看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); //上滑 if(scrollY>0){ //上滑移除最上面的一条 while (scrollY>heights[firstRow]){ removeView(mCurrentViewList.remove(0)); //scrollY的值保持在0到一条item的高度之间 scrollY -= heights[firstRow]; firstRow++; } //上滑加载最下面的一条 // 当剩下的数据的总高度小于屏幕的高度的时候 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){ //下滑最上面加载 //这里判断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]; } //下滑最下面移除 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的时候说明上滑,反之则是下滑。
主要分四步:
- 上滑的时候,最上面的子View移除屏幕
- 上滑的时候,最下面的子View,如果需要,填充到屏幕
- 下滑的时候,移出去的子View需要填充进屏幕
- 下滑的时候,最下面的子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),主要是用来判断边界值,防止数组越界的崩溃发生
- 下滑极限值,通过sumArray方法,我们可以求出从数据的第0个元素到当前第一行firstRow之间item的总高度。当这个高度为0的时候,说明我们已经滑到了真正的第一行,这时候scrollY也应该被赋值为0
- 上滑极限值,通过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的练习就结束啦,最终效果如下