之前分析了 事件分布 和 ListView復用機制 ,不能只分析不使用把,這次就用之前分析的知識來完成一個自定義列表控件。
首先看一下效果,結合了ListView的復用機制以及觸摸事件的使用。
首先我們需要實現一個靜態的頁面效果。
他分為四部分,左上角是怎么滑動都不會動的,上和左各有一個首行只可以單向滑動,而藍色部分是可以上下左右,甚至斜著都可以,而且在實現靜態頁面的同是我們利用學過的ListView源碼里的邏輯可以實現只加載屏幕內顯示的View,所以不論有多少數據,我們都不用擔心內存問題。
首先我們看一下需要用到的變量都是干什么的
private BaseTableAdapter adapter;
private int downX;//滑動時手指落下的X Y
private int downY;
private int scrollX;//滑動的距離
private int scrollY;
private int firstRow;//當前第一行postiton
private int firstColumn; //當前第一列position
private int[] widths;//存放每個View的寬高
private int[] heights;
@SuppressWarnings("unused")
private View headView;//頭View 為使用
private List<View> rowViewList;//保存一行數據 因為在滑動是可能一行數據直接就滑上去了
private List<View> columnViewList;
private List<List<View>> bodyViewTable;//表格數據
private int rowCount;//行數
private int columnCount;//列數
private int width;//控件寬高
private int height;
private final ImageView[] shadows;//分割的黑線
private final int shadowSize;//黑線寬度
private int minimumVelocity;//慣性滑動時最小和最大速率
private int maximumVelocity;
private final Flinger flinger;//慣性滑動
private VelocityTracker velocityTracker;//慣性滑動
private boolean needRelayout; //需要重繪標志位
private int touchSlop; //滑動最小距離
private Recycler recycler;//復用相關類
接下來按一下onMeasure方法。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
final int widthMode = MeasureSpec.getMode(widthMeasureSpec);
final int heightMode = MeasureSpec.getMode(heightMeasureSpec);
final int widthSize = MeasureSpec.getSize(widthMeasureSpec);
final int heightSize = MeasureSpec.getSize(heightMeasureSpec);
final int w;
final int h;
if (adapter != null) {
this.rowCount = adapter.getRowCount();//獲取數據個數
this.columnCount = adapter.getColumnCount();
//
widths = new int[columnCount + 1];//初始化保存的數組 這里+1 是包括了有一個單向滑動的頭部。
for (int i = -1; i < columnCount; i++) {//這里從-1開始是為了可以添加columnCount + 1條數據
widths[i + 1] += adapter.getWidth(i);
}
heights = new int[rowCount + 1];
for (int i = -1; i < rowCount; i++) {
heights[i + 1] += adapter.getHeight(i);
}
if (widthMode == MeasureSpec.AT_MOST) {//AT_MOST wrap_content
//sumArray方法是計算出數組的總和
w = Math.min(widthSize, sumArray(widths));//判讀屏幕寬度和數據寬度,取最小的
} else if (widthMode == MeasureSpec.UNSPECIFIED) {
w = sumArray(widths);
} else {//具體指或match_parent
w = widthSize;
int sumArray = sumArray(widths);
if (sumArray < widthSize) {//如果 現有view的寬度小于 屏幕寬度 將會把屏幕寬度平分
final float factor = widthSize / (float) sumArray;
for (int i = 1; i < widths.length; i++) {
widths[i] = Math.round(widths[i] * factor);
}
widths[0] = widthSize - sumArray(widths, 1, widths.length - 1);
}
}
if (heightMode == MeasureSpec.AT_MOST) {
h = Math.min(heightSize, sumArray(heights));
} else if (heightMode == MeasureSpec.UNSPECIFIED) {
h = sumArray(heights);
} else {
h = heightSize;
}
} else {
if (heightMode == MeasureSpec.AT_MOST || widthMode == MeasureSpec.UNSPECIFIED) {
w = 0;
h = 0;
} else {
w = widthSize;
h = heightSize;
}
}
//必須調用
setMeasuredDimension(w, h);
}
通過onMeasure我們不僅適配了屏幕,而且還獲取了每個View的寬高,這樣任由我們擺放了,所以接下來看一下onLayout方法。
@SuppressLint("DrawAllocation")
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
if (needRelayout || changed) {
needRelayout = false;
resetTable();//情況所有的集合和View
if (adapter != null) {
width = r - l;//屏幕當前的寬度和高度
height = b - t;
//畫那四條黑線,在實現靜態頁面是還沒什么用
int left, top, right, bottom;
right = Math.min(width, sumArray(widths));
bottom = Math.min(height, sumArray(heights));
addShadow(shadows[0], widths[0], 0, widths[0] + shadowSize, bottom);
addShadow(shadows[1], 0, heights[0], right, heights[0] + shadowSize);
addShadow(shadows[2], right - shadowSize, 0, right, bottom);
addShadow(shadows[3], 0, bottom - shadowSize, right, bottom);
//畫左上角那個固定的ViewItem(紅色部分)
headView = makeAndSetup(-1, -1, 0, 0, widths[0], heights[0]);
//畫除左上角以外的第一行數據(橘黃色部分)
left = widths[0] ;
//這里用到了源碼里的機制,只加載屏幕以內的View
//當left(當前View的左邊<屏幕的寬度才去加載)
for (int i = firstColumn; i < columnCount && left < width; i++) {
//不停的去找下個View的左右邊的值
right = left + widths[i + 1];
final View view = makeAndSetup(-1, i, left, 0, right, heights[0]);
rowViewList.add(view);//保存第一行數據
left = right;
}
//畫除左上角以外的第一列數據(棕色部分)
top = heights[0] ;
for (int i = firstRow; i < rowCount && top < height; i++) {
bottom = top + heights[i + 1];
final View view = makeAndSetup(i, -1, 0, top, widths[0], bottom);
columnViewList.add(view);
top = bottom;
}
//畫Body部分(藍色部分)
top = heights[0];
for (int i = firstRow; i < rowCount && top < height; i++) {
bottom = top + heights[i + 1];
left = widths[0] - scrollX;
List<View> list = new ArrayList<View>();
for (int j = firstColumn; j < columnCount && left < width; j++) {
right = left + widths[j + 1];
final View view = makeAndSetup(i, j, left, top, right, bottom);
list.add(view);//當前行 一個一個添加 最后相當于一行數據
left = right;
}
bodyViewTable.add(list);//添加一行數據 最后相當于 表格內所有數據
top = bottom;
}
shadowsVisibility();//分割的黑線
}
}
}
這里突出了靜態時的一個關鍵點,就是只加載當前頁面內的數據,優化效率非常明顯。之后只要設置數據就可以正常顯示了,具體看 源碼,這里我們看一下優化的效率。首先我們改變一下代碼
//畫Body部分(藍色部分)
for (int i = firstRow; i < rowCount ; i++) {
...
for (int j = firstColumn; j < columnCount ; j++) {
...
}
...
}
添加藍色區域View的時候 我們把屏幕限制條件刪除,并且我們隔八秒后添加1億跳數據,我們看一下內存狀況。
寶寶表示震精了~我們在看一下添加上限制條件后是什么情況。
//畫Body部分(藍色部分)
for (int i = firstRow; i < rowCount && top < height ; i++) {
...
for (int j = firstColumn; j < columnCount && left < width ; j++) {
...
}
...
}
效果很明顯,在第8秒時內存只是增加了一點,將屏幕填滿了,之后就再也沒有變化,靜態的效果我們已經達到了,之后就是滑動時對View的分離以及復用的操作。首先我們需要了解一下復用類。
public class Recycler {
private Stack<View>[] views;
public Recycler(int type) {
views=new Stack[type];
for (int i = 0; i < type; i++) {
views[i]=new Stack<View>();
}
}
public void addRecycledView(View view,int type){//滑動時就會調用
views[type].push(view);//根據ItemType添加View
}
public View getRecyclerView(int type){//添加View的時候調用(靜態頁面第一次添加View也會調用)
try {//一定要try catch 因為type第一次出現時可能還沒有添加過
return views[type].pop();//根據ItemType拿到View
} catch (Exception e) {
return null;
}
}
}
這個類其實就是對View的一個Item滑出屏幕時需要添加到這個數組里,item出現是需要判斷之前是否有緩存過。在添加View的時候就會起到非常大的優化作用。
然后就開始實現滑動效果,這里主要會將觸摸事件攔截,以及滑動時臨界值的計算。首先看一下攔截事件。
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercept = false;
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
downX = (int) ev.getRawX();
downY = (int) ev.getRawY();
break;
case MotionEvent.ACTION_MOVE:
//攔截move事件 防止子view中有Button一類的控件
int x2 = Math.abs(downX - (int)ev.getRawX());
int y2 = Math.abs(downY - (int)ev.getRawY());
//touchSlop是用來判斷是否是一個合理的滑動
//因為一般情況只要我們手指按下去,不發生Move事件的情況很少很少,總要動一點點的,可能我們都沒察覺自己動了。
//這里給一個滑動最小距離,大于這個最小距離才算是滑動。
if (x2 > touchSlop || y2 > touchSlop) {
intercept = true;
}
break;
}
return intercept;
}
攔截事件很簡單,防止子View中有Button一類的控件,如果沒有就可以不用攔截。重點還是在onTouchEvent()的Move事件。
@Override
public void scrollBy(int x, int y) {
scrollX += x;
scrollY += y;
if (needRelayout) {
return;
}
scrollBounds();
if (scrollX == 0) {
// no op
} else if (scrollX > 0) {//向左滑動
//當scrollX大于body(藍色區域)內第一個可見View的寬度的時候
//這里用while是有可能快速移動,直接處理多個View的情況
while (widths[firstColumn + 1] < scrollX) {
if (!rowViewList.isEmpty()) {
removeLeft();
}
scrollX -= widths[firstColumn + 1];
firstColumn++;
}
//如果不快速滑動 這里的rowViewList可以理解為body中可見的View
//這里的getFilledWidth()其實就是計算出第一列(單向滑動的那列)的寬度+body(藍色區域)內rowViewList的中保存的所有View的寬度(有可能首尾的View超出去一部分或超出多個View,那部分也算)-scrollX
//所以這里計算的就是body(藍色區域)左邊到rowViewList的中保存的最后一個View的寬度(最后一個View超出屏幕部分也算)。因為scrollX就是向左滑了的部分,也就是左邊超出的部分
//這里用while是有可能快速移動,直接處理多個View的情況
while (getFilledWidth() < width) {//這里的判斷就是當把最后一個View超出屏幕的部分全部移回來了,就是添加下個view的時候
addRight();
}
} else {//向右滑動第一個View全部出現時調用一次
//往右滑的時候scrollX是負的。所以getFilledWidth()里的-scrollX 成了 +|scrollX|
//和上邊的一樣getFilledWidth()計算的是第一列(單向滑動的那列)的寬度+body(藍色區域)的第一個view到rowViewList的中保存的最后一個View的寬度(最后一個View超出屏幕部分也算)
//因為只有在右滑時第一個View全部出現的時候調用一次,所以這里body(藍色區域)的第一個view就相當于從body的左邊開始
//這里判斷就相當于:可見的第一個View到rowViewList的中保存的最后一個
while (!rowViewList.isEmpty() && getFilledWidth() - widths[firstColumn + rowViewList.size()] >= width) {
removeRight();
}
//當scrollX小于0的時候證明已經 將之前左滑的部分又向右滑回來了
while (0 > scrollX) {
addLeft();
firstColumn--;
scrollX += widths[firstColumn + 1];
}
}
...
repositionViews();//沒有這個體現不出滑動的效果
shadowsVisibility();//分割線
}
我在這里只分析了左右滑動的臨界值的計算,說實話有點燒腦,不過自己多嘗試兩遍還是可以理解的。注釋中基本把所有的理解都寫了。提示一點注釋用到rowViewList的地方如果不快速滑動,可以理解為當前行可見View的一個集合。
最后我感覺可以用 源碼 來理解會更方便一些。
這篇文章是在我學習的基礎上進行了總結,可想而知我還是個很小的菜鳥,如果其中有錯誤還請指出,我會盡快修改文章,并改正自己的理解,謝謝。