自定义ViewGroup练习之仿写RecycleView

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

RecycleView的核心组件

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

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

回收池

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

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

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

代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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相关的东西啦。

1
2
3
4
5
6
7
8
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绑定数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
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来啦

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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总高度的计算公式我们封装成一个方法,后面也会用到

1
2
3
4
5
6
7
8
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方法啦

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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方法来处理事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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拦截事件

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

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

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

1
2
3
4
5
<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事件就好了。

1
2
3
4
5
6
7
8
9
10
11
12
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的移动了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
      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布局一边即可,如下:

1
2
3
4
5
6
7
8
9
10
11
12
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的值就需要定格在当前的高度不能再增加了。

判断极限值的代码如下:

1
2
3
4
5
6
7
8
9
10
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的练习就结束啦,最终效果如下

源码地址

# UI

コメント

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×