横向滑动的列表
在安卓开发中,有时候会遇到列表宽度不足以显示所有数据的情况,如果不打算使用两级显示(即列表显示有限的数据,点击后转到新页面显示详细数据)就需要一种能左右滑动来展示更多数据的列表,然而无论是ListView还是ExpandableListView乃至ScrollView都没有这样的能力,因此实现这个功能需要另辟蹊径。
简单而又直接的方案
第一种方案的想法非常简单而且直接,安卓提供的各种基本组件里有一种叫做HorizontalScrollView的可滑动组件,它和ScrollView是亲戚,两者提供的功能一模一样,唯一的不同是ScrollView默认垂直滑动,HorizontalScrollView默认水平滑动,这个组件很适合用来满足之前提到的需求。
方法很简单,只要将ListView或者ExpandableListView放入HorizontalScrollView里面,然后正常使用就行,运行后列表就可以自由地上下或者左右滑动了。
需要注意的是,因为HorizontalScrollView可以左右滑动,因此内部的ListView等组件不应该通过match_parent之类的参数来确定宽度,这样会导致宽度不够而无法滑动;列表所使用的子项也需要有确定的宽度,最好能写定在XML文件中(当然也可以在getView方法里进行设置)。
具体代码如下
XML布局文件
<HorizontalScrollView
android:id="@+id/hsvContent"
android:layout_width="wrap_content"
android:layout_height="300dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="match_parent">
<ListView
android:id="@+id/lvContent"
android:layout_width="wrap_content"
android:layout_height="match_parent">
</ListView>
</LinearLayout>
</HorizontalScrollView>
将这部分代码放到需要这个列表的地方,注意列表和其父容器的宽度均为wrap_content,HorizontalScrollView的高度可以根据需求自定义。
使用时只需要正常地按照ListView的使用方法进行即可。
解决更灵活的需求
第一种简单易用的方案已经可以解决大部分需求了,但偶尔还是能遇到更加特别的一些需求需要解决,这时候简单方案就可能不够用了。
比如说,如果要滑动的是ExpandableListView的次级子列表部分,或者想让列表可以按需求变化展示高度,比如刚开始显示五个,点击展开后显示全部;或者想让列表仅有一部分可以左右滑动,其它的部分不能;还有如果需要一个浮动展示的表头,它也需要跟随列表一起滑动等等。这些多种多样很灵活的需求如果还继续使用第一种简单方案就可能会造成障碍。
有没有第二种思路呢?答案是肯定的,既然可以将ListView放到HorizontalScrollView里面,那反过来也是完全可行的,而且因为HorizontalScrollView变成了ListView的内部子项,控制起来也更加灵活。
现在来假设一个需求场景:需要展示一系列数据,比如篮球球员的个人技术统计,项目很多因此要水平滑动才能展示,要求球员的名字不能动,只有技术统计项才能动;同时因为首发只有五人,球队的人数多于这个数字,因此需要一个展开功能;展示数据时需要告诉用户表中每个数据都是什么意思,因此需要一个浮动的表头来提示
这个需求把之前提到的可能都组合了一下,现在来实现它。
既然思路要反过来,意思也就是说要将HorizontalScrollView放到ListView里面成为它的子项,再加上只有一部分数据可以滑动,那么可以写出如下的子项XML布局
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:background="#fff"
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/tvArg_1"
android:layout_width="100dp"
android:layout_height="36dp"
android:gravity="center"
android:text="参数1"
/>
<com.game.personal.exampleproj.InterceptLinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
>
<com.game.personal.exampleproj.InnerHorizontalScrollView
android:id="@+id/hsvItem"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:scrollbars="none"
>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<TextView
android:id="@+id/tvArg_2"
android:layout_width="100dp"
android:layout_height="36dp"
android:gravity="center"
android:text="参数2"
/>
<TextView
android:id="@+id/tvArg_3"
android:layout_width="100dp"
android:layout_height="36dp"
android:gravity="center"
android:text="参数3"
/>
<TextView
android:id="@+id/tvArg_4"
android:layout_width="100dp"
android:layout_height="36dp"
android:gravity="center"
android:text="参数4"
/>
<TextView
android:id="@+id/tvArg_5"
android:layout_width="100dp"
android:layout_height="36dp"
android:gravity="center"
android:text="参数5"
/>
<TextView
android:id="@+id/tvArg_6"
android:layout_width="100dp"
android:layout_height="36dp"
android:gravity="center"
android:text="参数6"
/>
<TextView
android:id="@+id/tvArg_7"
android:layout_width="100dp"
android:layout_height="36dp"
android:gravity="center"
android:text="参数7"
/>
<TextView
android:id="@+id/tvArg_8"
android:layout_width="100dp"
android:layout_height="36dp"
android:gravity="center"
android:text="参数8"
/>
<TextView
android:id="@+id/tvArg_9"
android:layout_width="100dp"
android:layout_height="36dp"
android:gravity="center"
android:text="参数9"
/>
</LinearLayout>
</com.game.personal.exampleproj.InnerHorizontalScrollView>
</com.game.personal.exampleproj.InterceptLinearLayout>
</LinearLayout>
考虑到使用正常的HorizontalScrollView无法达到要求,因为ListView的每个子项都是分开的而且有重用机制,因此需要设计一个满足要求的组件。
要设计这个组件,首先需要清晰设计它的目的,也就是让表上显示的所有子项都可以随着任何一个子项的水平滑动而同时滑动,重用机制下新出现的子项也要能迅速切换到合适的位置。
据此可以设计出如下的InnerHorizontalScrollView
public class InnerHorizontalScrollView extends HorizontalScrollView {
private ScrollViewObserver scrollViewObserver;
private int grpIndex;
public InnerHorizontalScrollView(Context context) {
super(context);
initAttr();
// TODO Auto-generated constructor stub
}
public InnerHorizontalScrollView(Context context, AttributeSet attrs) {
super(context, attrs);
initAttr();
// TODO Auto-generated constructor stub
}
public InnerHorizontalScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initAttr();
// TODO Auto-generated constructor stub
}
public void addOnScrollChangedListener(OnScrollChangedListener listener) {
scrollViewObserver.AddOnScrollChangedListener(listener);
}
public void removeOnScrollChangedListener(OnScrollChangedListener listener) {
scrollViewObserver.RemoveOnScrollChangedListener(listener);
}
public void setGrpIndex(int grpIndex) {
this.grpIndex = grpIndex;
scrollViewObserver.setgIndex(this.grpIndex);
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
// TODO Auto-generated method stub
return super.onTouchEvent(ev);
}
@Override
protected void onScrollChanged(int l, int t, int oldl, int oldt) {
// TODO Auto-generated method stub
if (scrollViewObserver != null) {
scrollViewObserver.NotifyOnScrollChanged(l, t, oldl, oldt);
}
super.onScrollChanged(l, t, oldl, oldt);
}
private void initAttr() {
scrollViewObserver = new ScrollViewObserver();
grpIndex = -1;
}
public static interface OnScrollChangedListener {
public void onScrollChanged(int l, int t, int oldl, int oldt, int grp);
}
public static class ScrollViewObserver {
List<OnScrollChangedListener> mList;
LTPair ltcache;
LTPair oldltcache;
int gIndex;
public ScrollViewObserver() {
super();
mList = new ArrayList<OnScrollChangedListener>();
ltcache = new LTPair();
oldltcache = new LTPair();
gIndex = -1;
}
public void setgIndex(int gIndex) {
this.gIndex = gIndex;
}
public void AddOnScrollChangedListener(OnScrollChangedListener listener) {
listener.onScrollChanged(ltcache.l, ltcache.t, oldltcache.l, oldltcache.t, gIndex);
if (!mList.contains(listener)) {
mList.add(listener);
}
}
public void RemoveOnScrollChangedListener(
OnScrollChangedListener listener) {
mList.remove(listener);
}
public void NotifyOnScrollChanged(int l, int t, int oldl, int oldt) {
if (mList == null || mList.size() == 0) {
return;
}
ltcache.l = l;
ltcache.t = t;
oldltcache.l = oldl;
oldltcache.t = oldt;
for (int i = 0; i < mList.size(); i++) {
if (mList.get(i) != null) {
mList.get(i).onScrollChanged(l, t, oldl, oldt, gIndex);
}
}
}
private class LTPair {
public int l;
public int t;
}
}
}
在类中设计了一个OnScrollChangedListener接口,并且声明了ScrollViewObserver类用来控制其它可能的项目组件滑动。整体设计思路依然是观察者模式,在类的内部提供一个观察者对象,然后将所有需要观察的组件加进来,每次滑动时通知到所有的注册组件同时滑动即可。
那么谁来充当观察者呢?根据ListView的特性,不能让其中的子项充当,必须在外部找到一个对象。在目前这个例子里,最好的对象莫过于浮动的表头了,因为其结构和每个子项相同,而且总是存在,也有一同滑动的需求。
所以整体结构也就逐渐清晰了,浮动表头在列表外初始化,作为被订阅对象,所有的列表子项(基于重用机制一般就几个或者十几个)作为订阅者注册到浮动表头中的水平滑动组件的观察者对象中,之后的滑动过程就完全交由表头控制。
首先重写ListView组件,为它加上表头显示逻辑的回调
public class AdjustIndicatorListView extends ListView implements OnScrollListener {
private OnGroupIndicatorShowListener onGroupIndicatorShowListener;
private int touchStatus; // 触摸动作类型
private MotionPack motionPack; // 事件坐标缓存
// 触摸动作类型枚举
private static final int IDLE = -1;
private static final int MOVING = 1;
private static final int STANDBY = 0;
private static final int VERTICAL = 2;
private static final int HORIZONTAL = 3;
public AdjustIndicatorListView(Context context) {
super(context);
initAttr();
}
public AdjustIndicatorListView(Context context, AttributeSet attrs) {
super(context, attrs);
initAttr();
}
public AdjustIndicatorListView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initAttr();
}
private void initAttr() {
touchStatus = IDLE;
motionPack = new MotionPack();
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
// TODO Auto-generated method stub
int action = ev.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN:
motionPack.startX = ev.getRawX();
motionPack.startY = ev.getRawY();
touchStatus = STANDBY;
break;
case MotionEvent.ACTION_MOVE:
motionPack.endX = ev.getRawX();
motionPack.endY = ev.getRawY();
if (touchStatus == STANDBY) {
touchStatus = MOVING;
}
break;
case MotionEvent.ACTION_UP:
touchStatus = IDLE;
break;
default:
touchStatus = IDLE;
break;
}
// 此处用于判断当前用户是正在横向拖动还是竖向拖动,避免因为触摸点超出单个Item响应范围导致的滑动中断
if (touchStatus == MOVING) {
if (Math.abs(motionPack.endX - motionPack.startX) >= Math.abs(motionPack.endY - motionPack.startY)) {
touchStatus = HORIZONTAL;
} else {
touchStatus = VERTICAL;
}
}
if (touchStatus == VERTICAL) {
return true;
} else if (touchStatus == HORIZONTAL) {
return false;
} else {
return super.onInterceptTouchEvent(ev);
}
}
@Override
public void onScrollStateChanged(AbsListView view, int scrollState) {
//
}
@Override
public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount,
int totalItemCount) {
boolean show;
if (firstVisibleItem == 0) {
if (getChildAt(firstVisibleItem) != null) {
if (getChildAt(firstVisibleItem).getTop() > 0) {
show = false;
} else {
show = true;
}
} else {
show = true;
}
} else {
show = true;
}
if (getFirstVisiblePosition() == 0 && getChildAt(0) != null && getChildAt(0).getTop() > 0) {
show = false;
}
if (onGroupIndicatorShowListener != null) {
onGroupIndicatorShowListener.OnGroupIndicatorShow(show);
}
}
public void setOnGroupIndicatorShowListener(
OnGroupIndicatorShowListener onGroupIndicatorShowListener) {
setOnScrollListener(this);
this.onGroupIndicatorShowListener = onGroupIndicatorShowListener;
}
public interface OnGroupIndicatorShowListener {
void OnGroupIndicatorShow(boolean show);
}
private class MotionPack {
public float startX;
public float endX;
public float startY;
public float endY;
}
}
然后重写一个LinearLayout组件,让onInterceptTouchEvent返回true表示彻底拦截所有的事件,把InnerHorizontalScrollView放入其中,使它无法接收到任何事件,只能通过订阅表头的滑动情况来运动。
public class InterceptLinearLayout extends LinearLayout {
public InterceptLinearLayout(Context context) {
super(context);
}
public InterceptLinearLayout(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public InterceptLinearLayout(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
return true;
}
}
最后还需要一个事件处理入口,简单地为子项设置onTouchListener即可,在每次触摸事件发生时都交给表头处理。
使用的代码如下
需要设置到浮动表头的OnScrollChangedListener
class OnScrollListenerImpl implements InnerHorizontalScrollView.OnScrollChangedListener {
private InnerHorizontalScrollView hsv;
OnScrollListenerImpl(InnerHorizontalScrollView scrollView) {
this.hsv = scrollView;
}
@Override
public void onScrollChanged(int l, int t, int oldl, int oldt, int grp) {
this.hsv.scrollTo(l, t);
}
}
需要设置给列表本身的OnTouchListener
class ListTouchListener implements View.OnTouchListener {
private InnerHorizontalScrollView hsv;
ListTouchListener(InnerHorizontalScrollView scrollView) {
// TODO Auto-generated constructor stub
this.hsv = scrollView;
}
@Override
public boolean onTouch(View v, MotionEvent event) {
hsv.onTouchEvent(event);
return false;
}
}
ListView需要的Adapter类
class IndicatorAdapter extends BaseAdapter {
@Override
public int getCount() {
return indicatorDataList == null?0:indicatorDataList.size();
}
@Override
public Object getItem(int position) {
return indicatorDataList == null?null:indicatorDataList.get(position);
}
@Override
public long getItemId(int position) {
return position;
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
ItemHolder holder;
if(convertView == null) {
convertView = LayoutInflater.from(resContext).inflate(R.layout.layout_adlv_item, null);
holder = new ItemHolder();
holder.args = new ArrayList<>();
for(int i = 1; i < 10; i++) {
TextView tv = (TextView) convertView.findViewById(resContext.getResources().getIdentifier("tvArg_"+i, "id", resContext.getPackageName()));
holder.args.add(tv);
}
holder.hsv = (InnerHorizontalScrollView) convertView.findViewById(R.id.hsvItem);
holder.listener = new OnScrollListenerImpl(holder.hsv);
hsvContent.addOnScrollChangedListener(holder.listener);
holder.hsv.addOnScrollChangedListener(indicatorImp);
convertView.setFocusable(true);
convertView.setClickable(true);
convertView.setOnTouchListener(listTouchListener);
convertView.setTag(holder);
} else {
holder = (ItemHolder) convertView.getTag();
}
List<String> contentData = (List<String>) getItem(position);
if(contentData != null) {
for(int i = 0; i < 9; i++) {
holder.args.get(i).setText(contentData.get(i));
}
}
return convertView;
}
class ItemHolder {
List<TextView> args;
InnerHorizontalScrollView hsv;
OnScrollListenerImpl listener;
}
}
主要代码
View indicatorView;
InnerHorizontalScrollView hsvContent;
OnScrollListenerImpl indicatorImp;
View listHeader;
InnerHorizontalScrollView headerHsv;
OnScrollListenerImpl headerImp;
ListTouchListener listTouchListener;
AdjustIndicatorListView lvContent;
IndicatorAdapter indicatorAdapter;
List<List<String>> indicatorDataList;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
resContext = this;
indicatorView = findViewById(R.id.indicatorBar);
hsvContent = (InnerHorizontalScrollView)indicatorView.findViewById(R.id.hsvItem);
listTouchListener = new ListTouchListener(hsvContent);
indicatorView.setOnTouchListener(listTouchListener);
indicatorImp = new OnScrollListenerImpl(hsvContent);
hsvContent.addOnScrollChangedListener(indicatorImp);
lvContent = (AdjustIndicatorListView) findViewById(R.id.adlvContent);
lvContent.setOnGroupIndicatorShowListener(new AdjustIndicatorListView.OnGroupIndicatorShowListener() {
@Override
public void OnGroupIndicatorShow(boolean show) {
indicatorView.setVisibility(show?View.VISIBLE:View.GONE);
}
});
listHeader = LayoutInflater.from(resContext).inflate(R.layout.layout_adlv_item, null);
headerHsv = (InnerHorizontalScrollView) listHeader.findViewById(R.id.hsvItem);
headerImp = new OnScrollListenerImpl(headerHsv);
hsvContent.addOnScrollChangedListener(headerImp);
lvContent.addHeaderView(listHeader);
}
至此横向滑动的列表设计完成,实际测试表明两个方案都能达到各自的目标,第二种方案虽然设计较为复杂,但灵活性很高,可以用在列表的任意位置,也可以在滑动过程中做其他处理。