在项目中经常遇到横向选中的情况,为了方便,特地封装了一个控件出来,废话不多说,先看看效果(图有点大……)。
实现目标
- 和Recyclerview用法基本一样
- 支持自定义初始化选中位置
- 支持自动选中最近的item
- 支持数据的动态添加和删除
攻克目标
和Recyclerview用法基本一样
为了实现和Recyclerview用法一样,我选择继承RecyclerView来实现,这样既可以实现滑动效果,还可以做到复用,降低内存占用。仔细分析下一个横向自动选中控件应该数据都是在中间选中,这样,为了能够选中第一个和最后一个数据,相对与普通水平的Recyclerview来说,我们必须添加上头部和尾部,考虑到开发者都是比较熟悉Recyclerview的adapter写法,为了做到和它用法接近,本控件使用了一个WrapperAdapter.
class WrapperAdapter extends RecyclerView.Adapter {
private Context context;
private RecyclerView.Adapter adapter;
}
可以看到WrapperAdapter中的一个属性 adapter就是用户编写的真正的adapter,这样做我们既可以保留用户的意愿也能自定义的添加一些特性,比如必须要加上的头部和尾部。实现方式如下:
class WrapperAdapter extends RecyclerView.Adapter {
private Context context;
private RecyclerView.Adapter adapter;
public WrapperAdapter(Adapter adapter, Context context) {
this.adapter = adapter;
this.context = context;
}
@Override
public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
if (viewType == HEADER_FOOTER_TYPE) {
/**
*当item是头部和尾部的时候我们只需要简单的添加一个view,宽度为整个控件宽度的一半
*减去每个item宽度的一半就行了,这样就能保证滑动到第一个item和最后一个item都是在中间位置选中
*/
View view = new View(context);
headerFooterWidth = parent.getMeasuredWidth() / 2 - (parent.getMeasuredWidth() / itemCount) / 2;
RecyclerView.LayoutParams params = new LayoutParams(headerFooterWidth, ViewGroup.LayoutParams.MATCH_PARENT);
view.setLayoutParams(params);
return new HeaderFooterViewHolder(view);
}
/**
*由于控件的每一个item的宽度都是根据每页显示多少item动态计算的,所以这里我们要设置每个item的宽度,这样我们就
*必须获取开发者自己的每个item的布局才能修改,为了拿到真正的item根布局,开发者在编写自己的RecyclerView.Adapter时必须
*实现IAutoLocateHorizontalView这个接口
*/
ViewHolder holder = adapter.onCreateViewHolder(parent, viewType);
itemView = ((IAutoLocateHorizontalView) adapter).getItemView(); //这里拿到真正的item根布局,从而修改宽度
int width = parent.getMeasuredWidth() / itemCount;
ViewGroup.LayoutParams params = itemView.getLayoutParams();
if (params != null) {
params.width = width;
itemWidth = width;
itemView.setLayoutParams(params);
}
return holder;
}
@SuppressWarnings("unchecked")
@Override
public void onBindViewHolder(ViewHolder holder, int position) {
//对于头部和尾部我们不需要任何操作,对于真正的有用位置,直接返回开发者自己编写的onBindViewHolder方法即可,注意这里的position要减去头部的影响。
if (!isHeaderOrFooter(position)) {
adapter.onBindViewHolder(holder, position - 1);
}
}
@Override
public int getItemCount() {
return adapter.getItemCount() + 2; //+2是因为添加了头部和尾部
}
@Override
public int getItemViewType(int position) {
//这种情况下,说明当前item是头部和尾部,所以应该单独返回一个类型,其它的直接返回adapter.getItemView(position-1)即可。之所以-1,是因为添加头部后,adapter的位置是相对与WrapperAdatpter少了一个item的。
if (position == 0 || position == getItemCount() - 1) {
return HEADER_FOOTER_TYPE;
}
return adapter.getItemViewType(position - 1);
}
class HeaderFooterViewHolder extends RecyclerView.ViewHolder {
HeaderFooterViewHolder(View itemView) {
super(itemView);
}
}
支持初始化选中位置
通常来说,Recyclerview的初始滑动位置都是通过.RecyclerView.LayoutManager来实现的,比如scrollToPosition(int),但这个方法中的position是指刚出现在最开始地方的位置,与我们想要的在中间选中不符合,所以不能用,经过尝试,最终选择了LayoutManager.scrollToPositionWithOffset方法。初始化时调用一下方法:
private void init() {
getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
if (isInit) {
if (initPos >= adapter.getItemCount()) { //如果设置的initPos越界了,默认为最后一项
initPos = adapter.getItemCount() - 1;
}
linearLayoutManager.scrollToPositionWithOffset(0, -initPos * (wrapAdapter.getItemWidth()));
isInit = false;
}
}
});
}
支持自动选中最近的item
为了支持自动选中功能,我们必须算出每次滑动时的偏移量,如果滑动停止后没有一个item刚好滑动到正中间,我们必须自己调用相关方法做一个纠正滑动。所以这里最关键的就是获取滑动偏移量以及纠正偏移量的计算。
首先是统计滑动偏移量,方法有多种,这里采用了最简单的方法,就是将每次滑动时的水平偏移量相加,如下:
@Override
public void onScrolled(int dx, int dy) {
super.onScrolled(dx, dy);
deltaX += dx;
}
当我们滑动停止后,滑动纠正代码如下:
@Override
public void onScrollStateChanged(int state) {
super.onScrollStateChanged(state);
if (state == SCROLL_STATE_IDLE) {
if (wrapAdapter == null) {
return;
}
int itemWidth = wrapAdapter.getItemWidth();
int headerFooterWidth = wrapAdapter.getHeaderFooterWidth();
if (itemWidth == 0 || headerFooterWidth == 0) {
//此时adapter还没有准备好,忽略此次调用
return;
}
//超出上个item的位置
int overLastPosOffset = deltaX % itemWidth;
if (overLastPosOffset == 0) {
//刚好处于一个item选中位置,无需滑动偏移纠正
} else if (Math.abs(overLastPosOffset) <= itemWidth / 2) {
scrollBy(-overLastPosOffset, 0);
} else if (overLastPosOffset > 0) {
scrollBy((itemWidth - overLastPosOffset), 0);
} else {
scrollBy(-(itemWidth + overLastPosOffset), 0);
}
//计算当下被选中的真正位置
calculateSelectedPos();
//此处通知刷新是为了重新绘制之前被选中的位置以及刚刚被选中的位置
wrapAdapter.notifyItemChanged(oldSelectedPos + 1);
wrapAdapter.notifyItemChanged(selectPos + 1);
oldSelectedPos = selectPos;
}
}
支持数据的动态添加和删除
数据动态添加和删除按道理是不会有影响的,但是我们之前在计算偏移量的时候是统计每次滑动的偏移量,这样,添加或删除了数据这个值就不准了
因此我们要进行重新计算,另外由于我们对于每次选中的位置改变都是有回调方法的,所以也会影响回调。代码也很简单,就是监听下数据的改变计算下就好,如下:
@Override
public void setAdapter(final Adapter adapter) {
this.adapter = adapter;
this.wrapAdapter = new WrapperAdapter(adapter, getContext(), itemCount);
adapter.registerAdapterDataObserver(new AdapterDataObserver() {
@Override
public void onChanged() {
super.onChanged();
wrapAdapter.notifyDataSetChanged();
correctDeltax(adapter);
}
@Override
public void onItemRangeInserted(int positionStart, int itemCount) {
wrapAdapter.notifyDataSetChanged();
correctDeltax(adapter);
}
@Override
public void onItemRangeRemoved(int positionStart, int itemCount) {
wrapAdapter.notifyDataSetChanged();
correctDeltax(adapter);
}
});
deltaX = 0;
if (linearLayoutManager == null) {
linearLayoutManager = new LinearLayoutManager(getContext());
}
linearLayoutManager.setOrientation(LinearLayoutManager.HORIZONTAL);
super.setLayoutManager(linearLayoutManager);
super.setAdapter(this.wrapAdapter);
isInit = true;
}
/**
* 删除item后偏移距离可能需要重新计算,从而保证selectPos的正确
*
* @param adapter
*/
private void correctDeltax(Adapter adapter) {
if (adapter.getItemCount() <= selectPos) {
deltaX -= wrapAdapter.getItemWidth() * (selectPos - adapter.getItemCount() + 1);
}
仔细分析就会发现,凡是添加数据的,deltax计算都不会有误,最多选中的不再是原来的数据而是新加的数据,但选中的位置不会错误,但删除时,有可能删除数据后,偏移量明显超出了删除后数据能达到的值,所以要减去多余的值。
总结
本文代码虽多,但只是给出了实现各个目标的思路,详细的实现需要读者自己去敲一遍代码仔细思考。
完整代码和使用方法前往github