一 概述
在上篇文章里,我们用ItemDecoration为RecyclerView打造了带悬停头部的分组列表。其实Android版微信的通讯录界面,它的分组title也不是悬停的,我们已经领先了微信一小步(认真脸)~
再看看市面上常见的分组列表(例如饿了么点餐商品列表),不仅有悬停头部,悬停头部在切换时,还会伴有切换动画。
关于ItemDecoration还有一个问题,简单布局还好,我们可以draw出来,如果是复杂的头部呢?能否写个xml,inflate进来,这样使用起来才简单,即另一种简单使用onDraw和onDrawOver的姿势。
so,本文开头我们就先用两节完善一下我们的ItemDecoration。然后进入正题:自定义View实现右侧索引导航栏IndexBar,对数据源的排序字段按照拼音排序,最后将RecyclerView和IndexBar联动起来,触摸IndexBar上相应字母,RecyclerView滚动到相应位置。(在屏幕中间显示的其实就是一个TextView,我们set个体IndexBar即可)
由于大部分使用右侧索引导航栏的场景,都需要这几个固定步骤,对数据源排序,set给IndexBar,和RecyclerView联动等,所以最后再将其封装一把,成一个高度封装,因此扩展性不太高的控件,更方便使用,如果需要扩展的话,反正看完本文再其基础上修改应该很简单~。
本文摘要:
1. 用ItemDecoration实现悬停头部切换动画
2. 另一种简单使用onDraw()和onDrawOver()的姿势
3. 自定义View实现右侧**索引导航栏**IndexBar
4. 使用TinyPinyin对数据源排序
5. 联动IndexBar和RecyclerView。
6. 封装重复步骤,方便二次使用,并可定制导航数据源。
二 悬停头部的“切换动画”
实现了两种,
第一种就是仿饿了么点餐时,商品列表的悬停头部切换“动画效果”
第二种是一种头部折叠起来的视效,个人觉得也还不错~如下:(估计没人喜欢)
果然比上部残篇里的效果好看多了,那么代码多不多呢,看我的git show 记录:
就绿色部分的不到十行代码就搞定~先上这个图是为了让大家安心,代码不多,分分钟看完。
下面放上文字版代码,江湖人称 注释张 的我,已经写满了注释,
再简单说下吧,
滑动时,在判断头部即将切换(当前pos的tag和pos+1的tag不等)的时候,
1.计算出当前悬停头部应该上移的位移,
利用Canvas的画布移动方法Canvas.translate(),即可实现“饿了么”悬停头部切换效果。
2.计算出当前悬停头部应该在屏幕上还剩余的空间高度,作为头部绘制的高度
利用Canvas的Canvas.clipRect()方法,剪切画布,即可实现“折叠”的视效。
@Override public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {//最后调用 绘制在最上层 int pos = ((LinearLayoutManager) (parent.getLayoutManager())).findFirstVisibleItemPosition(); String tag = mDatas.get(pos).getTag(); //View child = parent.getChildAt(pos); View child = parent.findViewHolderForLayoutPosition(pos).itemView;//出现一个奇怪的bug,有时候child为空,所以将 child = parent.getChildAt(i)。-》 parent.findViewHolderForLayoutPosition(pos).itemView boolean flag = false;//定义一个flag,Canvas是否位移过的标志 if ((pos + 1) < mDatas.size()) {//防止数组越界(一般情况不会出现) if (null != tag && !tag.equals(mDatas.get(pos + 1).getTag())) {//当前第一个可见的Item的tag,不等于其后一个item的tag,说明悬浮的View要切换了 Log.d("zxt", "onDrawOver() called with: c = [" + child.getTop());//当getTop开始变负,它的绝对值,是第一个可见的Item移出屏幕的距离, if (child.getHeight() + child.getTop() < mTitleHeight) {//当第一个可见的item在屏幕中还剩的高度小于title区域的高度时,我们也该开始做悬浮Title的“交换动画” c.save();//每次绘制前 保存当前Canvas状态, flag = true; //一种头部折叠起来的视效,个人觉得也还不错~ //可与123行 c.drawRect 比较,只有bottom参数不一样,由于 child.getHeight() + child.getTop() < mTitleHeight,所以绘制区域是在不断的减小,有种折叠起来的感觉 //c.clipRect(parent.getPaddingLeft(), parent.getPaddingTop(), parent.getRight() - parent.getPaddingRight(), parent.getPaddingTop() + child.getHeight() + child.getTop()); //类似饿了么点餐时,商品列表的悬停头部切换“动画效果” //上滑时,将canvas上移 (y为负数) ,所以后面canvas 画出来的Rect和Text都上移了,有种切换的“动画”感觉 c.translate(0, child.getHeight() + child.getTop() - mTitleHeight); } } } mPaint.setColor(COLOR_TITLE_BG); c.drawRect(parent.getPaddingLeft(), parent.getPaddingTop(), parent.getRight() - parent.getPaddingRight(), parent.getPaddingTop() + mTitleHeight, mPaint); mPaint.setColor(COLOR_TITLE_FONT); mPaint.getTextBounds(tag, 0, tag.length(), mBounds); c.drawText(tag, child.getPaddingLeft(), parent.getPaddingTop() + mTitleHeight - (mTitleHeight / 2 - mBounds.height() / 2), mPaint); if (flag) c.restore();//恢复画布到之前保存的状态 }
这份代码核心处c.translate(0, child.getHeight() + child.getTop() - mTitleHeight);
实现的是饿了么效果,被注释掉的
//c.clipRect(parent.getPaddingLeft(), parent.getPaddingTop(), parent.getRight() - parent.getPaddingRight(), parent.getPaddingTop() + child.getHeight() + child.getTop());1
实现的是效果二。
三 另一种使用onDraw()和onDrawOver()的姿势
之前我们使用onDraw(),onDrawOver(),都是用canvas的方法活生生的绘制一个出View,这对于很多人(包括我)来说都不容易,xy坐标的确认,尺寸都较难把握,基本上调UI效果时间都很长。尤其是canvas.drawText()方法的y坐标,其实是baseLine的位置,不了解的童鞋肯定要踩很多坑。
当我们想要绘制的分类title、悬停头部复杂一点时,我都不敢想象要调试多久了,这个时候我们还敢用ItemDecoration吗。
有没有一种方法,就像我们平时使用的那样,在Layout布局xml里画好View,然后inflate出来就可以了呢。
这个问题开始确实也把我难住了,难道又要从入门到放弃了吗?
于是我又搜寻资料,功夫不负有心人。
解决问题的办法就是,View类的:public void draw(Canvas canvas) {
方法
下面我们就看一个用法Demo吧:
布局layout:header_complex.xml(注意有个ProgressBar哦)
<?xml version="1.0" encoding="utf-8"?><LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@color/colorPrimaryDark" android:orientation="vertical"> <Button android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/colorAccent" android:text="复杂头部" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="复杂头部" android:textColor="@color/colorAccent" /> <ProgressBar android:layout_width="wrap_content" android:layout_height="wrap_content" /></LinearLayout>
onDrawOver代码如下:简单讲解下,先inflate这个复杂的Layout,然后拿到它的LayoutParams,利用这个lp拿到宽和高的MeasureSpec,然后依次调用 measure,layout,draw方法,将复杂头部显示在屏幕上。
View toDrawView = mInflater.inflate(R.layout.header_complex, parent, false); int toDrawWidthSpec;//用于测量的widthMeasureSpec int toDrawHeightSpec;//用于测量的heightMeasureSpec //拿到复杂布局的LayoutParams,如果为空,就new一个。 // 后面需要根据这个lp 构建toDrawWidthSpec,toDrawHeightSpec ViewGroup.LayoutParams lp = toDrawView.getLayoutParams(); if (lp == null) { lp = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);//这里是根据复杂布局layout的width height,new一个Lp toDrawView.setLayoutParams(lp); } if (lp.width == ViewGroup.LayoutParams.MATCH_PARENT) { //如果是MATCH_PARENT,则用父控件能分配的最大宽度和EXACTLY构建MeasureSpec。 toDrawWidthSpec = View.MeasureSpec.makeMeasureSpec(parent.getWidth() - parent.getPaddingLeft() - parent.getPaddingRight(), View.MeasureSpec.EXACTLY); } else if (lp.width == ViewGroup.LayoutParams.WRAP_CONTENT) { //如果是WRAP_CONTENT,则用父控件能分配的最大宽度和AT_MOST构建MeasureSpec。 toDrawWidthSpec = View.MeasureSpec.makeMeasureSpec(parent.getWidth() - parent.getPaddingLeft() - parent.getPaddingRight(), View.MeasureSpec.AT_MOST); } else { //否则则是具体的宽度数值,则用这个宽度和EXACTLY构建MeasureSpec。 toDrawWidthSpec = View.MeasureSpec.makeMeasureSpec(lp.width, View.MeasureSpec.EXACTLY); } //高度同理 if (lp.height == ViewGroup.LayoutParams.MATCH_PARENT) { toDrawHeightSpec = View.MeasureSpec.makeMeasureSpec(parent.getHeight() - parent.getPaddingTop() - parent.getPaddingBottom(), View.MeasureSpec.EXACTLY); } else if (lp.height == ViewGroup.LayoutParams.WRAP_CONTENT) { toDrawHeightSpec = View.MeasureSpec.makeMeasureSpec(parent.getHeight() - parent.getPaddingTop() - parent.getPaddingBottom(), View.MeasureSpec.AT_MOST); } else { toDrawHeightSpec = View.MeasureSpec.makeMeasureSpec(lp.width, View.MeasureSpec.EXACTLY); } //依次调用 measure,layout,draw方法,将复杂头部显示在屏幕上。 toDrawView.measure(toDrawWidthSpec, toDrawHeightSpec); toDrawView.layout(parent.getPaddingLeft(), parent.getPaddingTop(), parent.getPaddingLeft() + toDrawView.getMeasuredWidth(), parent.getPaddingTop() + toDrawView.getMeasuredHeight()); toDrawView.draw(c);
这里还有个有趣的地方,某些需要不断调用onDraw()更新绘制自己最新状态的View,例如ProgressBar,由于在屏幕上显示的并不是真正的View,只是我们手动的调用了一次draw方法,进而调用View的onDraw()显示的一次“残影”,所以ProgressBar只会显示onDraw()当时的样子,并不会主动刷新了。
滑动时,由于会回调onDrawOver() 方法,所以ProgressBar又被手动调用了draw(),开始变化,滑动的快的话,progressBar会有动画效果。
停止不动时,ProgressBar也是静止的,保持draw()时绘制的状态。
四 自定义View实现右侧索引导航栏IndexBar
不管是自定义ItemDecoration还是实现右侧索引导航栏,都有大量的自定义View知识在里面 ,这里简单复习一下。
(步骤1-4是自定义View的必须套路,步骤5+是IndexBar特殊定制)
1 自定义View首先要确定这个View需要在xml里接受哪些属性?
在IndexBar里,我们先需要两个属性,每个索引的文字大小和手指按下时整个View的背景,
即在attrs.xml如下定义:
<attr name="textSize" format="dimension" /> <declare-styleable name="IndexBar"> <attr name="textSize" /> <attr name="pressBackground" format="color" /> </declare-styleable>
2 在View的构造方法中获得我们自定义的属性
套路代码如下,都是套路,记得使用完最后要将typeArray对象 recycle()。
int textSize = (int) TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_SP, 16, getResources().getDisplayMetrics());//默认的TextSize mPressedBackground = Color.BLACK;//默认按下是纯黑色 TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.IndexBar, defStyleAttr, 0); int n = typedArray.getIndexCount(); for (int i = 0; i < n; i++) { int attr = typedArray.getIndex(i); switch (attr) { case R.styleable.IndexBar_textSize: textSize = typedArray.getDimensionPixelSize(attr, textSize); break; case R.styleable.IndexBar_pressBackground: mPressedBackground = typedArray.getColor(attr, mPressedBackground); default: break; } } typedArray.recycle();
3 重写onMesure()方法(可选)
onMeasure()方法里,主要就是遍历一遍indexDatas,得到index最大宽度和高度。然后根据三种测量模式,分配不同的值给View,
EXACLTY就分配具体的测量值(match_parent,确定数值),
AT_MOST就分配父控件能给的最大值和自己需要的值之间的最小值。(保证不超过父控件限定的值)
UNSPECIFIED则分配自己需要的值。(随心所欲)
@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { //取出宽高的MeasureSpec Mode 和Size int wMode = MeasureSpec.getMode(widthMeasureSpec); int wSize = MeasureSpec.getSize(widthMeasureSpec); int hMode = MeasureSpec.getMode(heightMeasureSpec); int hSize = MeasureSpec.getSize(heightMeasureSpec); int measureWidth = 0, measureHeight = 0;//最终测量出来的宽高 //得到合适宽度: Rect indexBounds = new Rect();//存放每个绘制的index的Rect区域 String index;//每个要绘制的index内容 for (int i = 0; i < mIndexDatas.size(); i++) { index = mIndexDatas.get(i); mPaint.getTextBounds(index, 0, index.length(), indexBounds);//测量计算文字所在矩形,可以得到宽高 measureWidth = Math.max(indexBounds.width(), measureWidth);//循环结束后,得到index的最大宽度 measureHeight = Math.max(indexBounds.width(), measureHeight);//循环结束后,得到index的最大高度,然后*size } measureHeight *= mIndexDatas.size(); switch (wMode) { case MeasureSpec.EXACTLY: measureWidth = wSize; break; case MeasureSpec.AT_MOST: measureWidth = Math.min(measureWidth, wSize);//wSize此时是父控件能给子View分配的最大空间 break; case MeasureSpec.UNSPECIFIED: break; } //得到合适的高度: switch (hMode) { case MeasureSpec.EXACTLY: measureHeight = hSize; break; case MeasureSpec.AT_MOST: measureHeight = Math.min(measureHeight, hSize);//wSize此时是父控件能给子View分配的最大空间 break; case MeasureSpec.UNSPECIFIED: break; } setMeasuredDimension(measureWidth, measureHeight); }
4 重写onDraw()方法
整理一下需求和思路:
利用index数据源的size,和控件可绘制的高度(高度-paddingTop-paddingBottom),求出每个index区域的高度mGapHeight。
每个index在绘制时,都是处于水平居中,竖直方向上在mGapHeight区域高度内居中。
思路整理清楚,代码很简单如下:
public static String[] INDEX_STRING = {"A", "B", "C", "D", "E", "F", "G", "H", "I","J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V","W", "X", "Y", "Z", "#"};//#在最后面(默认的数据源) private List<String> mIndexDatas;//索引数据源 private int mGapHeight;//每个index区域的高度 ..... mIndexDatas = Arrays.asList(INDEX_STRING);//数据源
在onSizeChanged方法里,获取控件的宽高,并计算出mGapHeight:
@Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); mWidth = w; mHeight = h; mGapHeight = (mHeight - getPaddingTop() - getPaddingBottom()) / mIndexDatas.size(); }
最后在onDraw()方法里绘制,
如果对于竖直居中baseLine的计算不太理解可以先放置,这块的确挺绕人,后面应该会写一篇 canvas.drawText()x y坐标计算的小短文.
可记住重点就是 Paint默认的TextAlign是Left,即x方向,左对齐,所以x坐标决定绘制文字的左边界。
y坐标是绘制文字的baseLine位置。
@Override protected void onDraw(Canvas canvas) { int t = getPaddingTop();//top的基准点(支持padding) Rect indexBounds = new Rect();//存放每个绘制的index的Rect区域 String index;//每个要绘制的index内容 for (int i = 0; i < mIndexDatas.size(); i++) { index = mIndexDatas.get(i); mPaint.getTextBounds(index, 0, index.length(), indexBounds);//测量计算文字所在矩形,可以得到宽高 Paint.FontMetrics fontMetrics = mPaint.getFontMetrics();//获得画笔的FontMetrics,用来计算baseLine。因为drawText的y坐标,代表的是绘制的文字的baseLine的位置 int baseline = (int) ((mGapHeight - fontMetrics.bottom - fontMetrics.top) / 2);//计算出在每格index区域,竖直居中的baseLine值 canvas.drawText(index, mWidth / 2 - indexBounds.width() / 2, t + mGapHeight * i + baseline, mPaint);//调用drawText,居中显示绘制index } }
以上四步基本完成了IndexBar的绘制工作,下面我们为它添加一些行为的响应。
5 重写onTouchEvent()方法
我们需要重写onTouchEvent()方法,
以便处理手指按下时的View背景变色,抬起时恢复原来颜色
并根据手指触摸的落点坐标,判断当前处于哪个index区域,回调给相应的监听器处理(显示当前index的值,滑动RecyclerView至相应区域等。。)
代码如下:
@Override public boolean onTouchEvent(MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: setBackgroundColor(mPressedBackground);//手指按下时背景变色 //注意这里没有break,因为down时,也要计算落点 回调监听器 case MotionEvent.ACTION_MOVE: float y = event.getY(); //通过计算判断落点在哪个区域: int pressI = (int) ((y - getPaddingTop()) / mGapHeight); //边界处理(在手指move时,有可能已经移出边界,防止越界) if (pressI < 0) { pressI = 0; } else if (pressI >= mIndexDatas.size()) { pressI = mIndexDatas.size() - 1; } //回调监听器 if (null != mOnIndexPressedListener) { mOnIndexPressedListener.onIndexPressed(pressI, mIndexDatas.get(pressI)); } break; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: default: setBackgroundResource(android.R.color.transparent);//手指抬起时背景恢复透明 //回调监听器 if (null != mOnIndexPressedListener) { mOnIndexPressedListener.onMotionEventEnd(); } break; } return true; }
6 联动IndexBar和RecyclerView
具体的操作交由监听器处理,定义和实现如下:
值得一提的就是,滑动RecyclerView到指定postion,我们使用的是LinearLayoutManager的scrollToPositionWithOffset(int position, int offset)
方法,offset传入0,postion即目标postion即可。如果使用RecyclerView.scrollToPosition();
等方法,滑动会很飘~定位不准。
mPressedShowTextView 就是在屏幕中间显示的当前处于哪个index的TextView。
/** * 当前被按下的index的监听器 */ public interface onIndexPressedListener { void onIndexPressed(int index, String text);//当某个Index被按下 void onMotionEventEnd();//当触摸事件结束(UP CANCEL) } private onIndexPressedListener mOnIndexPressedListener; public void setmOnIndexPressedListener(onIndexPressedListener mOnIndexPressedListener) { this.mOnIndexPressedListener = mOnIndexPressedListener; }
//设置index触摸监听器 setmOnIndexPressedListener(new onIndexPressedListener() { @Override public void onIndexPressed(int index, String text) { if (mPressedShowTextView != null) { //显示hintTexView mPressedShowTextView.setVisibility(View.VISIBLE); mPressedShowTextView.setText(text); } //滑动Rv if (mLayoutManager != null) { int position = getPosByTag(text); if (position != -1) { mLayoutManager.scrollToPositionWithOffset(position, 0); } } } @Override public void onMotionEventEnd() { //隐藏hintTextView if (mPressedShowTextView != null) { mPressedShowTextView.setVisibility(View.GONE); } } });
五 封装重复步骤,方便二次使用。
在我个人的理解里,程序过多的封装是会导致扩展性的降低(也是因为我水平有限),然而我们今天要封装的这个IndexBar,由于使用场景和套路还是挺固定的(城市分组列表,商品分类列表)所以值得将相关的操作都聚合起来,二次使用更方便。毕竟,一个项目里同样的代码写第二遍的程序员都不是好的圣斗士。(其实是我的leader不想写第二遍,让我封装一下给他秒用)
梳理一下固定的操作:
1 都是先对原始数据sourceDatas源按照排序字段拼音排序。
2 然后将屏幕中hint的TextView ,以及索引数据源indexDatas(通过sourceDatas获得),通过set方法传给IndexBar。
3 联动IndexBar和RecyclerView,使得触摸IndexBar相应区域RecyclerView会滚动(借助sourceDatas获得对应postion)。
根据上述,我的设想在使用时,只需要给IndexBar设置 原始数据sourceDatas,HintTextView,和RecyclerView的LinearLayoutManager,在IndexBar内部对sourceDatas排序,并获得索引数据源indexDatas,然后设置一个默认的index触摸监听器,在手指按下滑动时,由于IndexBar持有HintTextView和LayoutManager,则HintTextView的show hide,以及LayoutManager的滚动 都在IndexBar内部完成。
最终使用预览:
//使用indexBar mTvSideBarHint = (TextView) findViewById(R.id.tvSideBarHint);//HintTextView mIndexBar = (IndexBar) findViewById(R.id.indexBar);//IndexBar mIndexBar.setmPressedShowTextView(mTvSideBarHint)//设置HintTextView .setNeedRealIndex(true)//设置需要真实的索引 .setmLayoutManager(mManager)//设置RecyclerView的LayoutManager .setmSourceDatas(mDatas);//设置数据源
布局xml:
<?xml version="1.0" encoding="utf-8"?><FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent"> <android.support.v7.widget.RecyclerView android:id="@+id/rv" android:layout_width="match_parent" android:layout_height="match_parent"> </android.support.v7.widget.RecyclerView> <mcxtzhang.itemdecorationdemo.IndexBar.widget.IndexBar android:id="@+id/indexBar" android:layout_width="24dp" android:layout_height="match_parent" android:layout_gravity="right" app:pressBackground="@color/partTranslucent" app:textSize="16sp" /> <TextView android:id="@+id/tvSideBarHint" android:layout_width="80dp" android:layout_height="80dp" android:layout_gravity="center" android:background="@drawable/shape_side_bar_bg" android:gravity="center" android:textColor="@android:color/white" android:textSize="48sp" android:visibility="gone" tools:text="A" tools:visibility="visible" /></FrameLayout>
其中,setNeedRealIndex(true)//设置需要真实的索引,是指索引栏的数据不是固定的A-Z,#。而是根据真实的sourceDatas生成。
因为链式调用用起来很爽,所以在这些set方法里都return 了 this。
1 抽象两个实体类和一个接口。
先把tag抽象出来,放在顶层,这里存放的就是IndexBar显示的每个index值(A-Z,#)(本例是城市的汉语拼音首字母),而且在联动滑动时,根据tag获取postion时,也需要用到tag。它是导航分组列表的基础。
public class BaseIndexTagBean { private String tag;//所属的分类(城市的汉语拼音首字母) public String getTag() { return tag; } public void setTag(String tag) { this.tag = tag; } }
然后抽象一个接口和一个实体类,
接口定义一个方法getTarget(),它返回 需要被转化成拼音,并取出首字母 索引排序的 字段。(本例就是城市的名字)
实体类继承BaseIndexTagBean,并实现以上接口,且额外存放 需要排序的字段的拼音值,(本例是城市的拼音)。它根据getTarget()返回的值利用TinyPinyin库得到拼音。
public interface IIndexTargetInterface { String getTarget();//需要被转化成拼音,并取出首字母 索引排序的 字段}
public abstract class BaseIndexPinyinBean extends BaseIndexTagBean implements IIndexTargetInterface { private String pyCity;//城市的拼音 public String getPyCity() { return pyCity; } public void setPyCity(String pyCity) { this.pyCity = pyCity; } }
有了以上两个类一个接口,我们就可以将 对原始数据源sourceDatas按照拼音排序,并取出索引数据源indexDatas的操作封装起来。
2 封装原始数据源初始化(利用TinyPinyin获取全拼音),取出索引数据源indexDatas的操作。
使用时,我们先让具体的实体bean,继承自BaseIndexPinyinBean ,在getTarget()方法返回排序目标字段。本例如下:
public class CityBean extends BaseIndexPinyinBean { private String city;//城市名字 public CityBean() { } public CityBean(String city) { this.city = city; } public String getCity() { return city; } public void setCity(String city) { this.city = city; } @Override public String getTarget() { return city; } }
IndexBar类内代码:
使用时会调用IndexBar.setmSourceDatas()方法传入原始数据源,在方法内对数据源初始化,并取出索引数据源。
private List<? extends BaseIndexPinyinBean> mSourceDatas;//Adapter的数据源 public IndexBar setmSourceDatas(List<? extends BaseIndexPinyinBean> mSourceDatas) { this.mSourceDatas = mSourceDatas; initSourceDatas();//对数据源进行初始化 return this; }
/** * 初始化原始数据源,并取出索引数据源 * * @return */ private void initSourceDatas() { int size = mSourceDatas.size(); for (int i = 0; i < size; i++) { BaseIndexPinyinBean indexPinyinBean = mSourceDatas.get(i); StringBuilder pySb = new StringBuilder(); String target = indexPinyinBean.getTarget();//取出需要被拼音化的字段 //遍历target的每个char得到它的全拼音 for (int i1 = 0; i1 < target.length(); i1++) { //利用TinyPinyin将char转成拼音 //查看源码,方法内 如果char为汉字,则返回大写拼音 //如果c不是汉字,则返回String.valueOf(c) pySb.append(Pinyin.toPinyin(target.charAt(i1))); } indexPinyinBean.setPyCity(pySb.toString());//设置城市名全拼音 //以下代码设置城市拼音首字母 String tagString = pySb.toString().substring(0, 1); if (tagString.matches("[A-Z]")) {//如果是A-Z字母开头 indexPinyinBean.setTag(tagString); if (isNeedRealIndex) {//如果需要真实的索引数据源 if (!mIndexDatas.contains(tagString)) {//则判断是否已经将这个索引添加进去,若没有则添加 mIndexDatas.add(tagString); } } } else {//特殊字母这里统一用#处理 indexPinyinBean.setTag("#"); if (isNeedRealIndex) {//如果需要真实的索引数据源 if (!mIndexDatas.contains("#")) { mIndexDatas.add("#"); } } } } sortData(); }
3 封装对原始数据源sourceDatas,索引数据源indexDatas的排序操作。
/** * 对数据源排序 */ private void sortData() { //对右侧栏进行排序 将 # 丢在最后 Collections.sort(mIndexDatas, new Comparator<String>() { @Override public int compare(String lhs, String rhs) { if (lhs.equals("#")) { return 1; } else if (rhs.equals("#")) { return -1; } else { return lhs.compareTo(rhs); } } }); //对数据源进行排序 Collections.sort(mSourceDatas, new Comparator<BaseIndexPinyinBean>() { @Override public int compare(BaseIndexPinyinBean lhs, BaseIndexPinyinBean rhs) { if (lhs.getTag().equals("#")) { return 1; } else if (rhs.getTag().equals("#")) { return -1; } else { return lhs.getPyCity().compareTo(rhs.getPyCity()); } } }); }
4 是否需要真实的索引数据源。
相关变量定义:
public static String[] INDEX_STRING = {"A", "B", "C", "D", "E", "F", "G", "H", "I","J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "#"};//#在最后面(默认的数据源) private List<String> mIndexDatas;//索引数据源 private boolean isNeedRealIndex;//是否需要根据实际的数据来生成索引数据源(例如 只有 A B C 三种tag,那么索引栏就 A B C 三项)
初始化init时,判断不需要真实的索引数据源,就用默认值(A-Z,#)
if (!isNeedRealIndex) {//不需要真实的索引数据源 mIndexDatas = Arrays.asList(INDEX_STRING); }
使用时,如果如果真实索引数据源,调用这个方法,传入true,一定要在设置数据源setmSourceDatas(List)之前调用。
/** * 一定要在设置数据源{@link #setmSourceDatas(List)}之前调用 * * @param needRealIndex * @return */ public IndexBar setNeedRealIndex(boolean needRealIndex) { isNeedRealIndex = needRealIndex; if (isNeedRealIndex){ if (mIndexDatas != null) { mIndexDatas = new ArrayList<>(); } } return this; }
在initSourceDatas() 里,会根据这个变量往mIndexDatas里增加index。
5 IndexBar和外部联动的相关(HintTextView,和RecyclerView的LinearLayoutManager)
set方法很简单:
public IndexBar setmPressedShowTextView(TextView mPressedShowTextView) { this.mPressedShowTextView = mPressedShowTextView; return this; } public IndexBar setmLayoutManager(LinearLayoutManager mLayoutManager) { this.mLayoutManager = mLayoutManager; return this; }
它们两最终都是在index触摸监听器里用到,代码上文已提及,只不过这次挪到IndexBar内部init里。
init函数如下:
private void init(Context context, AttributeSet attrs, int defStyleAttr) { ... if (!isNeedRealIndex) {//不需要真实的索引数据源 mIndexDatas = Arrays.asList(INDEX_STRING); } //设置index触摸监听器 setmOnIndexPressedListener(new onIndexPressedListener() { @Override public void onIndexPressed(int index, String text) { if (mPressedShowTextView != null) { //显示hintTexView mPressedShowTextView.setVisibility(View.VISIBLE); mPressedShowTextView.setText(text); } //滑动Rv if (mLayoutManager != null) { int position = getPosByTag(text); if (position != -1) { mLayoutManager.scrollToPositionWithOffset(position, 0); } } } @Override public void onMotionEventEnd() { //隐藏hintTextView if (mPressedShowTextView != null) { mPressedShowTextView.setVisibility(View.GONE); } } }); }
/** * 根据传入的pos返回tag * * @param tag * @return */ private int getPosByTag(String tag) { if (TextUtils.isEmpty(tag)) { return -1; } for (int i = 0; i < mSourceDatas.size(); i++) { if (tag.equals(mSourceDatas.get(i).getTag())) { return i; } } return -1; }
六 完整代码
思前想后还是放出来吧,三百多行有点长
/** * 介绍:索引右侧边栏 * 作者:zhangxutong * 邮箱:mcxtzhang@163.com * CSDN:http://blog.csdn.net/zxt0601 * 时间: 16/09/04. */public class IndexBar extends View { private static final String TAG = "zxt/IndexBar"; public static String[] INDEX_STRING = {"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "#"};//#在最后面(默认的数据源) private List<String> mIndexDatas;//索引数据源 private boolean isNeedRealIndex;//是否需要根据实际的数据来生成索引数据源(例如 只有 A B C 三种tag,那么索引栏就 A B C 三项) private int mWidth, mHeight;//View的宽高 private int mGapHeight;//每个index区域的高度 private Paint mPaint; private int mPressedBackground;//手指按下时的背景色 //以下边变量是外部set进来的 private TextView mPressedShowTextView;//用于特写显示正在被触摸的index值 private List<? extends BaseIndexPinyinBean> mSourceDatas;//Adapter的数据源 private LinearLayoutManager mLayoutManager; public IndexBar(Context context) { this(context, null); } public IndexBar(Context context, AttributeSet attrs) { this(context, attrs, 0); } public IndexBar(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(context, attrs, defStyleAttr); } private void init(Context context, AttributeSet attrs, int defStyleAttr) { int textSize = (int) TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_SP, 16, getResources().getDisplayMetrics());//默认的TextSize mPressedBackground = Color.BLACK;//默认按下是纯黑色 TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.IndexBar, defStyleAttr, 0); int n = typedArray.getIndexCount(); for (int i = 0; i < n; i++) { int attr = typedArray.getIndex(i); switch (attr) { case R.styleable.IndexBar_textSize: textSize = typedArray.getDimensionPixelSize(attr, textSize); break; case R.styleable.IndexBar_pressBackground: mPressedBackground = typedArray.getColor(attr, mPressedBackground); default: break; } } typedArray.recycle(); if (!isNeedRealIndex) {//不需要真实的索引数据源 mIndexDatas = Arrays.asList(INDEX_STRING); } mPaint = new Paint(); mPaint.setAntiAlias(true); mPaint.setTextSize(textSize); mPaint.setColor(Color.BLACK); //设置index触摸监听器 setmOnIndexPressedListener(new onIndexPressedListener() { @Override public void onIndexPressed(int index, String text) { if (mPressedShowTextView != null) { //显示hintTexView mPressedShowTextView.setVisibility(View.VISIBLE); mPressedShowTextView.setText(text); } //滑动Rv if (mLayoutManager != null) { int position = getPosByTag(text); if (position != -1) { mLayoutManager.scrollToPositionWithOffset(position, 0); } } } @Override public void onMotionEventEnd() { //隐藏hintTextView if (mPressedShowTextView != null) { mPressedShowTextView.setVisibility(View.GONE); } } }); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); } @Override protected void onDraw(Canvas canvas) { int t = getPaddingTop();//top的基准点(支持padding) Rect indexBounds = new Rect();//存放每个绘制的index的Rect区域 String index;//每个要绘制的index内容 for (int i = 0; i < mIndexDatas.size(); i++) { index = mIndexDatas.get(i); mPaint.getTextBounds(index, 0, index.length(), indexBounds);//测量计算文字所在矩形,可以得到宽高 Paint.FontMetrics fontMetrics = mPaint.getFontMetrics();//获得画笔的FontMetrics,用来计算baseLine。因为drawText的y坐标,代表的是绘制的文字的baseLine的位置 int baseline = (int) ((mGapHeight - fontMetrics.bottom - fontMetrics.top) / 2);//计算出在每格index区域,竖直居中的baseLine值 canvas.drawText(index, mWidth / 2 - indexBounds.width() / 2, t + mGapHeight * i + baseline, mPaint);//调用drawText,居中显示绘制index } } @Override public boolean onTouchEvent(MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: setBackgroundColor(mPressedBackground);//手指按下时背景变色 //注意这里没有break,因为down时,也要计算落点 回调监听器 case MotionEvent.ACTION_MOVE: float y = event.getY(); //通过计算判断落点在哪个区域: int pressI = (int) ((y - getPaddingTop()) / mGapHeight); //边界处理(在手指move时,有可能已经移出边界,防止越界) if (pressI < 0) { pressI = 0; } else if (pressI >= mIndexDatas.size()) { pressI = mIndexDatas.size() - 1; } //回调监听器 if (null != mOnIndexPressedListener) { mOnIndexPressedListener.onIndexPressed(pressI, mIndexDatas.get(pressI)); } break; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: default: setBackgroundResource(android.R.color.transparent);//手指抬起时背景恢复透明 //回调监听器 if (null != mOnIndexPressedListener) { mOnIndexPressedListener.onMotionEventEnd(); } break; } return true; } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); mWidth = w; mHeight = h; mGapHeight = (mHeight - getPaddingTop() - getPaddingBottom()) / mIndexDatas.size(); } /** * 当前被按下的index的监听器 */ public interface onIndexPressedListener { void onIndexPressed(int index, String text);//当某个Index被按下 void onMotionEventEnd();//当触摸事件结束(UP CANCEL) } private onIndexPressedListener mOnIndexPressedListener; public onIndexPressedListener getmOnIndexPressedListener() { return mOnIndexPressedListener; } public void setmOnIndexPressedListener(onIndexPressedListener mOnIndexPressedListener) { this.mOnIndexPressedListener = mOnIndexPressedListener; } /** * 显示当前被按下的index的TextView * * @return */ public IndexBar setmPressedShowTextView(TextView mPressedShowTextView) { this.mPressedShowTextView = mPressedShowTextView; return this; } public IndexBar setmLayoutManager(LinearLayoutManager mLayoutManager) { this.mLayoutManager = mLayoutManager; return this; } /** * 一定要在设置数据源{@link #setmSourceDatas(List)}之前调用 * * @param needRealIndex * @return */ public IndexBar setNeedRealIndex(boolean needRealIndex) { isNeedRealIndex = needRealIndex; if (mIndexDatas != null) { mIndexDatas = new ArrayList<>(); } return this; } public IndexBar setmSourceDatas(List<? extends BaseIndexPinyinBean> mSourceDatas) { this.mSourceDatas = mSourceDatas; initSourceDatas();//对数据源进行初始化 return this; } /** * 初始化原始数据源,并取出索引数据源 * * @return */ private void initSourceDatas() { int size = mSourceDatas.size(); for (int i = 0; i < size; i++) { BaseIndexPinyinBean indexPinyinBean = mSourceDatas.get(i); StringBuilder pySb = new StringBuilder(); String target = indexPinyinBean.getTarget();//取出需要被拼音化的字段 //遍历target的每个char得到它的全拼音 for (int i1 = 0; i1 < target.length(); i1++) { //利用TinyPinyin将char转成拼音 //查看源码,方法内 如果char为汉字,则返回大写拼音 //如果c不是汉字,则返回String.valueOf(c) pySb.append(Pinyin.toPinyin(target.charAt(i1))); } indexPinyinBean.setPyCity(pySb.toString());//设置城市名全拼音 //以下代码设置城市拼音首字母 String tagString = pySb.toString().substring(0, 1); if (tagString.matches("[A-Z]")) {//如果是A-Z字母开头 indexPinyinBean.setTag(tagString); if (isNeedRealIndex) {//如果需要真实的索引数据源 if (!mIndexDatas.contains(tagString)) {//则判断是否已经将这个索引添加进去,若没有则添加 mIndexDatas.add(tagString); } } } else {//特殊字母这里统一用#处理 indexPinyinBean.setTag("#"); if (isNeedRealIndex) {//如果需要真实的索引数据源 if (!mIndexDatas.contains("#")) { mIndexDatas.add("#"); } } } } sortData(); } /** * 对数据源排序 */ private void sortData() { //对右侧栏进行排序 将 # 丢在最后 Collections.sort(mIndexDatas, new Comparator<String>() { @Override public int compare(String lhs, String rhs) { if (lhs.equals("#")) { return 1; } else if (rhs.equals("#")) { return -1; } else { return lhs.compareTo(rhs); } } }); //对数据源进行排序 Collections.sort(mSourceDatas, new Comparator<BaseIndexPinyinBean>() { @Override public int compare(BaseIndexPinyinBean lhs, BaseIndexPinyinBean rhs) { if (lhs.getTag().equals("#")) { return 1; } else if (rhs.getTag().equals("#")) { return -1; } else { return lhs.getPyCity().compareTo(rhs.getPyCity()); } } }); } /** * 根据传入的pos返回tag * * @param tag * @return */ private int getPosByTag(String tag) { if (TextUtils.isEmpty(tag)) { return -1; } for (int i = 0; i < mSourceDatas.size(); i++) { if (tag.equals(mSourceDatas.get(i).getTag())) { return i; } } return -1; } }
七 总结
不管是自定义ItemDecoration还是实现右侧索引导航栏,其实大量的自定义View知识在里面 ,
so 要想自定义ItemDecoration玩得好,自定义View少不了。
对数据源的排序字段按照拼音排序,我们使用TinyPinyin(https://github.com/promeG/TinyPinyin)帮助我们排序。
它的特性很适合Android平台。
1. 生成的拼音不包含声调,也不处理多音字,默认一个汉字对应一个拼音;
2. 拼音均为大写;
3. 无需初始化,执行效率很高(Pinyin4J的4倍);
4. 很低的内存占用(小于30KB)。
(介绍来源于其项目github)
其实不仅仅是IndexBar以及它和RecyclerView,HintTextView的联动可以封装在一起。
悬停头部ItemDecoration也可以利用 BaseIndexTagBean 类来抽象一下,不与具体的实体类耦合,
将
private List<CityBean> mDatas;
替换成
private List<?extends BaseIndexPinyinBean> mDatas;
即可。