第一次寫博文,寫得不好的地方還望各位看客見諒
爲了學習自定義軟件開發,且定製出滿足自己需求的控件(不需要將就地使用第三方源碼),本人花了一週的時間開發了個橫向ListView,寫博客是爲了記錄整個開發過程及思路,也能和各位看客一起學習和探討。
這一系列文章是針對的讀者是已經瞭解listview緩存和工作原理的android開發人員,如果對listview緩存和工作原理還不瞭解的讀者,可以查看以下文章:
《Android研究院之ListView原理學習與優化總結》
目前橫向ListView的可替代方案有以下三種:
1.HorizontalScrollView——android官方提供
2.RecyclerView——android6.0提供的
3.第三方開源控件
儘管有衆多的選擇,但感覺還是自己會實現比較酷一些,還有就是,自己的東西可以隨便改改改改改。
本篇文章將介紹橫向ListView的實現基本思路,在接下來的一系列文章中將不斷介紹整個控件的完善思路(包括:實現快速滾動、添加頭/尾視圖、添加滾動條、實現下拉刷新/上拉加載等)。
參考文章: 《Android UI開發: 橫向ListView(HorizontalListView)及一個簡單相冊的完整實現》
橫向ListView的基礎邏輯:
1.新建java類,類名:HorizontalListView
2.繼承AdapterView
3.實現setAdapter()和getAdapter()方法(需要爲adapter註冊數據觀察器)
4.實現onTouchEvent()方法響應事件(採用android提供的手勢解析器GestureDetector解析事件)
5.實現onLayout方法,佈局列表項
1).計算當前列表發生滾動的滾動“位移值”,記錄已經發生有效滾動的“位移累加值”
2).根據“位移值”提取需要緩存的視圖(已經滾動到可視區域外的列表項)
3).根據“位移值”設置需要顯示的的列表項
4).根據整體列表“顯示偏移值”整頓所有列表項位置(調用子view的列表項)
5).計算可以發生滾動的“最大位移值”
先上代碼:
package com.hss.os.horizontallistview.history_version;
import android.content.Context;
import android.database.DataSetObserver;
import android.os.Build;
import android.support.annotation.RequiresApi;
import android.util.AttributeSet;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.View;
import android.widget.AdapterView;
import android.widget.ListAdapter;
import java.util.LinkedList;
import java.util.Queue;
/**
* 橫向ListView的基礎邏輯
* 1.繼承AdapterView
* 2.實現setAdapter()和getAdapter()方法(需要爲adapter註冊數據觀察器)
* 3.實現onTouchEvent()方法響應事件(採用android提供的手勢解析器GestureDetector解析事件)
* 4.實現onLayout方法,佈局列表項
1).計算當前列表發生滾動的滾動“位移值”,記錄已經發生有效滾動的“位移累加值”
2).根據“位移值”提取需要緩存的視圖(已經滾動到可視區域外的列表項)
3).根據“位移值”設置需要顯示的的列表項
4).根據整體列表“顯示偏移值”整頓所有列表項位置(調用子view的列表項)
5).計算可以發生滾動的“最大位移值”
*
* Created by hss on 2017/7/17.
*/
public class HorizontalListView1 extends AdapterView<ListAdapter> {
private ListAdapter adapter = null;
private GestureDetector mGesture;
private Queue<View> cacheView = new LinkedList<>();//列表項緩存視圖
private int firstItemIndex = 0;//顯示的第一個子項的下標
private int lastItemIndex = -1;//顯示的最後的一個子項的下標
private int scrollValue=0;//列表已經發生有效滾動的位移值
private int hasToScrollValue=0;//接下來列表發生滾動所要達到的位移值
private int maxScrollValue=Integer.MAX_VALUE;//列表發生滾動所能達到的最大位移值(這個由最後顯示的列表項決定)
private int displayOffset=0;//列表顯示的偏移值(用於矯正列表顯示的所有子項的顯示位置)
public HorizontalListView1(Context context) {
super(context);
init(context);
}
public HorizontalListView1(Context context, AttributeSet attrs) {
super(context, attrs);
init(context);
}
public HorizontalListView1(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context);
}
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
public HorizontalListView1(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
init(context);
}
private void init(Context context){
mGesture = new GestureDetector(getContext(), mOnGesture);
}
private void initParams(){
removeAllViewsInLayout();
if(adapter!=null&&lastItemIndex<adapter.getCount())
hasToScrollValue=scrollValue;//保持顯示位置不變
else hasToScrollValue=0;//滾動到列表頭
scrollValue=0;//列表已經發生有效滾動的位移值
firstItemIndex = 0;//顯示的第一個子項的下標
lastItemIndex = -1;//顯示的最後的一個子項的下標
maxScrollValue=Integer.MAX_VALUE;//列表發生滾動所能達到的最大位移值(這個由最後顯示的列表項決定)
displayOffset=0;//列表顯示的偏移值(用於矯正列表顯示的所有子項的顯示位置)
requestLayout();
}
private DataSetObserver mDataObserver = new DataSetObserver() {
@Override
public void onChanged() {
//執行Adapter數據改變時的邏輯
initParams();
}
@Override
public void onInvalidated() {
//執行Adapter數據失效時的邏輯
initParams();
}
};
@Override
public ListAdapter getAdapter() {
return adapter;
}
@Override
public void setAdapter(ListAdapter adapter) {
if(adapter!=null){
adapter.registerDataSetObserver(mDataObserver);
}
if(this.adapter!=null){
this.adapter.unregisterDataSetObserver(mDataObserver);
}
this.adapter=adapter;
requestLayout();
}
@Override
public View getSelectedView() {
return null;
}
@Override
public void setSelection(int position) {
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
/*
1).計算當前列表發生滾動的滾動“位移值”,記錄已經發生有效滾動的“位移累加值”
2).根據“位移值”提取需要緩存的視圖(已經滾動到可視區域外的列表項)
3).根據“位移值”設置需要顯示的的列表項
4).根據整體列表“顯示偏移值”整頓所有列表項位置(調用子view的列表項)
5).計算可以發生滾動的“最大位移值”
*/
int dx=calculateScrollValue();
removeNonVisibleItems(dx);
showListItem(dx);
adjustItems();
calculateMaxScrollValue();
}
/**
* 計算這一次整體滾動偏移量
* @return
*/
private int calculateScrollValue(){
int dx=0;
hasToScrollValue=hasToScrollValue<0? 0:hasToScrollValue;
hasToScrollValue=hasToScrollValue>maxScrollValue? maxScrollValue:hasToScrollValue;
dx=hasToScrollValue-scrollValue;
scrollValue=hasToScrollValue;
return -dx;
}
/**
* 計算最大滾動值
*/
private void calculateMaxScrollValue(){
if(adapter==null) return;
if(lastItemIndex==adapter.getCount()-1) {//已經顯示了最後一項
if(getChildAt(getChildCount() - 1).getRight()>=getShowEndEdge()) {
maxScrollValue = scrollValue + getChildAt(getChildCount() - 1).getRight() - getShowEndEdge(); }else{
maxScrollValue = 0;
}
}
}
/**
* 根據偏移量提取需要緩存視圖
* @param dx
*/
private void removeNonVisibleItems(int dx) {
if(getChildCount()>0) {
//移除列表頭
View child = getChildAt(getChildCount());
while (getChildCount()>0&&child != null && child.getRight() + dx <= 0) {
displayOffset += child.getMeasuredWidth();
cacheView.offer(child);
removeViewInLayout(child);
firstItemIndex++;
child = getChildAt(0);
}
//移除列表尾
child = getChildAt(getChildCount()-1);
while (getChildCount()>0&&child != null && child.getLeft() + dx >= getShowEndEdge()) {
cacheView.offer(child);
removeViewInLayout(child);
lastItemIndex--;
child = getChildAt(getChildCount()-1);
}
}
}
/**
* 根據偏移量顯示新的列表項
* @param dx
*/
private void showListItem(int dx) {
if(adapter==null)return;
int firstItemEdge = getFirstItemLeftEdge()+dx;
int lastItemEdge = getLastItemRightEdge()+dx;
displayOffset+=dx;//計算偏移量
//顯示列表頭視圖
while(firstItemEdge > getPaddingLeft() && firstItemIndex-1 >= 0) {
firstItemIndex--;//往前顯示一個列表項
View child = adapter.getView(firstItemIndex, cacheView.poll(), this);
addAndMeasureChild(child, 0);
firstItemEdge -= child.getMeasuredWidth();
displayOffset -= child.getMeasuredWidth();
}
//顯示列表未視圖
while(lastItemEdge < getShowEndEdge() && lastItemIndex+1 < adapter.getCount()) {
lastItemIndex++;//往後顯示一個列表項
View child = adapter.getView(lastItemIndex, cacheView.poll(), this);
addAndMeasureChild(child, getChildCount());
lastItemEdge += child.getMeasuredWidth();
}
}
/**
* 調整各個item的位置
*/
private void adjustItems() {
if(getChildCount() > 0){
int left = displayOffset+getPaddingLeft();
int endIndex = getChildCount()-1;
for(int i=0;i<=endIndex;i++){
View child = getChildAt(i);
int childWidth = child.getMeasuredWidth();
child.layout(left, getPaddingTop(), left + childWidth, child.getMeasuredHeight()+getPaddingTop());
left += childWidth + child.getPaddingRight();
}
}
}
/**
* 取得視圖可見區域的右邊界
* @return
*/
private int getShowEndEdge(){
return getWidth()-getPaddingRight();
}
private int getFirstItemLeftEdge(){
if(getChildCount()>0) {
return getChildAt(0).getLeft();
}else{
return 0;
}
}
private int getLastItemRightEdge(){
if(getChildCount()>0) {
return getChildAt(getChildCount()-1).getRight();
}else{
return 0;
}
}
private void addAndMeasureChild(View child, int viewIndex) {
LayoutParams params = child.getLayoutParams();
params = params==null ? new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT):params;
addViewInLayout(child, viewIndex, params, true);
child.measure(MeasureSpec.makeMeasureSpec(getWidth(), MeasureSpec.UNSPECIFIED),
MeasureSpec.makeMeasureSpec(getHeight(), MeasureSpec.UNSPECIFIED));
}
/**
* 在onTouchEvent處理事件,讓子視圖優先消費事件
* @param event
* @return
*/
@Override
public boolean onTouchEvent(MotionEvent event) {
return mGesture.onTouchEvent(event);
}
private GestureDetector.OnGestureListener mOnGesture = new GestureDetector.SimpleOnGestureListener() {
@Override
public boolean onDown(MotionEvent e) {
return true;
}
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
synchronized(HorizontalListView1.this){
hasToScrollValue += (int)distanceX;
}
requestLayout();
return true;
}
};
}
以下是具體實現解析:
第1-3步是整體實現的準備工作,比較簡單,這裏就不做講解
4.實現onTouchEvent()方法響應事件(採用android提供的手勢解析器GestureDetector解析事件)
處理觸摸事件的方法有三個(以下說法針對當前使用的GestureDetector實現):
1.dispatchTouchEvent() —— 如果在這裏處理,子視圖和當前視圖可以同時響應事件
2.onInterceptTouchEvent() —— 如果在這裏處理,子視圖無法響應事件
3.onTouchEvent() —— 優先子視圖響應事件
以上三個方法涉及到事件分發機制,如果對這方面不是很懂也想學習的,可參考以下文章:
《《Android深入透析》之Android事件分發機制 》
在實現GestureDetector.OnGestureListener時,必需實現onDown()和onScroll()兩個方法
onScroll()方法用於獲取用戶的滾動行爲所產生的滾動值
onDown()方法必須實現且返回值必須是true,否則onScroll()方法無法執行,具體原因還未深究
5.實現onLayout方法,佈局列表項
1).計算當前列表發生滾動的滾動“位移值”,記錄已經發生有效滾動的“位移累加值”
private int calculateScrollValue(){
int dx=0;
hasToScrollValue=hasToScrollValue<0? 0:hasToScrollValue;
hasToScrollValue=hasToScrollValue>maxScrollValue? maxScrollValue:hasToScrollValue;
dx=hasToScrollValue-scrollValue;
scrollValue=hasToScrollValue;
return -dx;
}
在這裏採用了三個變量:
private int scrollValue=0;//列表已經發生有效滾動的位移值
private int hasToScrollValue=0;//接下來列表發生滾動所要達到的位移值
private int maxScrollValue=Integer.MAX_VALUE;//列表發生滾動所能達到的最大位移值(這個由最後顯示的列表項決定)
在這個時候就有個問題,爲什麼要採用這三個變量而不是直接使用用戶滾動行爲所產生的偏移值(onScroll()方法中的distanceX);直接使用distanceX去計算也是可以實現我們所需要的功能的,不過這樣處理起來,各部分的邏輯代碼耦合度就會很高,無法切分出各個步驟,這個對於代碼的維護工作帶來很大的不便,代碼的可讀性也不好,邏輯也不夠清晰,採用這三個變量能很好的解決以上問題(這個思路是借用別人的,具體是誰最初想到的,我也不清楚,不過挺佩服的)
2).根據“位移值”提取需要緩存的視圖(已經滾動到可視區域外的列表項)
/**
* 根據偏移量提取需要緩存視圖
* @param dx
*/
private void removeNonVisibleItems(int dx) {
if(getChildCount()>0) {
//移除列表頭
View child = getChildAt(getChildCount());
while (getChildCount()>0&&child != null && child.getRight() + dx <= 0) {
displayOffset += child.getMeasuredWidth();
cacheView.offer(child);
removeViewInLayout(child);
firstItemIndex++;
child = getChildAt(0);
}
//移除列表尾
child = getChildAt(getChildCount()-1);
while (getChildCount()>0&&child != null && child.getLeft() + dx >= getShowEndEdge()) {
cacheView.offer(child);
removeViewInLayout(child);
lastItemIndex--;
child = getChildAt(getChildCount()-1);
}
}
}
這一步是在列表發生滾動之後根據發生滾動的位移值dx計算滾動後第一個和最後一個列表項是否已經滾動到不可見的區域(注意:可見的區域寬度 =(控件的寬度 - 左padding - 右padding))
3).根據“位移值”設置需要顯示的的列表項
/**
* 根據偏移量顯示新的列表項
* @param dx
*/
private void showListItem(int dx) {
if(adapter==null)return;
int firstItemEdge = getFirstItemLeftEdge()+dx;
int lastItemEdge = getLastItemRightEdge()+dx;
displayOffset+=dx;//計算偏移量
//顯示列表頭視圖
while(firstItemEdge > getPaddingLeft() && firstItemIndex-1 >= 0) {
firstItemIndex--;//往前顯示一個列表項
View child = adapter.getView(firstItemIndex, cacheView.poll(), this);
addAndMeasureChild(child, 0);
firstItemEdge -= child.getMeasuredWidth();
displayOffset -= child.getMeasuredWidth();
}
//顯示列表未視圖
while(lastItemEdge < getShowEndEdge() && lastItemIndex+1 < adapter.getCount()) {
lastItemIndex++;//往後顯示一個列表項
View child = adapter.getView(lastItemIndex, cacheView.poll(), this);
addAndMeasureChild(child, getChildCount());
lastItemEdge += child.getMeasuredWidth();
}
}
這一步根據列表滾動的“位移值dx”計算是否需要在列表中添加新的item View,如果列表在移動的過程中,第一個顯示的item View的左邊界出現在整體視圖可見區域的左邊界內即(firstItemEdge > getPaddingLeft() ),則在列表頭添加一個新的item View,同時記錄下整個列表顯示的左邊偏移值(displayOffset -= child.getMeasuredWidth(); ),該值十分重要,是體現整個列表顯示狀態的值;如果最後一個顯示的item View的右邊界出現在整體視圖可見區域的右邊界內即(lastItemEdge < getShowEndEdge() ) ,則在列表尾添加一個新的item View;第一次顯示列表時,是以追加的方式顯示item View的
注意:
1.代碼中採用while() {}循環操作而不是採用if()直接判斷是爲了代碼邏輯的嚴密性,實際上這裏採用if()進行判斷操作效果是一樣的,可這樣做整個代碼的邏輯就不夠嚴密,可能在以後的擴展中留下隱患(bug),在removeNonVisibleItems(int dx)方法中的while操作也是基於以上考慮
2.firstItemEdge 和lastItemEdge 的值採用以下方法計算,不僅是爲了增強代碼的可讀性,更是爲了往後的擴展做準備
private int getFirstItemLeftEdge(){
if(getChildCount()>0) {
return getChildAt(0).getLeft();
}else{
return 0;
}
}
private int getLastItemRightEdge(){
if(getChildCount()>0) {
return getChildAt(getChildCount()-1).getRight();
}else{
return 0;
}
}
4).根據整體列表“顯示偏移值”整頓所有列表項位置(調用子view的列表項)
/**
* 調整各個item的位置
*/
private void adjustItems() {
if(getChildCount() > 0){
int left = displayOffset+getPaddingLeft();
int top = getPaddingTop();
int endIndex = getChildCount()-1;
int childWidth,childHeight;
for(int i=0;i<=endIndex;i++){
View child = getChildAt(i);
childWidth = child.getMeasuredWidth();
childHeight = child.getMeasuredHeight();
child.layout(left, top, left + childWidth, top + childHeight);
left += childWidth;
}
}
}
在這裏是對視圖項進行正確的佈局排列,把各個列表項安放到合適的位置上;這個列表如何顯示,總體依賴displayOffset這個值;值得注意的是,child.layout()中的right和bottom的值需要在寬和高的基礎上分別加上left和top的值,否則整個item View無法完全顯示。
5).計算可以發生滾動的“最大位移值”
/**
* 計算最大滾動值
*/
private void calculateMaxScrollValue(){
if(adapter==null) return;
if(lastItemIndex==adapter.getCount()-1) {//已經顯示了最後一項
if(getChildAt(getChildCount() - 1).getRight()>=getShowEndEdge()) {
maxScrollValue = scrollValue + getChildAt(getChildCount() - 1).getRight() - getShowEndEdge();
}else{
maxScrollValue = 0;
}
}
}
當列表滾動到最後一個列表項時,則可計算整個列表可滾動最大值;scrollValue 表示已經發生滾動的距離,getChildAt(getChildCount() - 1).getRight() - getShowEndEdge()表示還可以發生滾動的距離,也表示最後一個列表項(item View)未顯示出來的部分;如果顯示項過少而無法鋪滿整個控件,最大滾動位移值爲0,即maxScrollValue = 0;