第一次写博文,写得不好的地方还望各位看客见谅
为了学习自定义软件开发,且定制出满足自己需求的控件(不需要将就地使用第三方源码),本人花了一周的时间开发了个横向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;