前言 :每个人都有属于自己的一片森林,也许我们从来不曾去过,但它一直在那里,总会在那里。迷失的人迷失了,相逢的人一定会再相逢。—— 村上春树。
好不容易到周末了,可是天公不作美,下了一整天雪,晚饭后和朋友一起去抓了娃娃,然后果断一个也没抓到,不过,乐在其中。我们还是要找到一点生活的乐趣的,不然原本简单的生活太索然无味了。言归正传,步入正题。
在手机通讯录里面,在想找到“张三”这个人的时候,不是把 ListView 一直上滑滑到对应位置,而是通过旁边的索引来找这个人名。如:张三对应的前面的是大写字母“Z”,同理李四对应“L”。本篇就是实现类似这样的功能,先看最终效果图:
通过本篇文章我们将涉及下面几个方面的知识:
- 最右边索引器控件的实现(本篇内容)
- 将索引器与 ListView 相关联,实现自动索引
实现这样简单的控件分以下步骤进行:
1. 绘制 A-Z 索引栏
首先,把架子搭建起来:
自定义的索引栏控件 FastIndexView 派生自 View:
public class FastIndexView extends View {
private int mHeight;//控件的高度
private int mWidth;//控件的宽度--30dp
public FastIndexView(Context context) {
super(context);
}
public FastIndexView(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
//拿到控件的宽高
mHeight = getMeasuredHeight();
mWidth = getMeasuredWidth();
}
}
以及在 Activity 中:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/activity_main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.itydl.a03.MainActivity">
<!--左侧ListView-->
<ListView
android:id="@+id/lv"
android:layout_width="match_parent"
android:layout_height="match_parent">
</ListView>
<!--右侧索引栏-->
<com.itydl.a03.view.FastIndexView
android:background="@drawable/index_letter_bg"
android:layout_alignParentRight="true"
android:layout_width="25dp"
android:layout_height="match_parent">
</com.itydl.a03.view.FastIndexView>
</RelativeLayout>
在 Activity 中,把自定义的 FastIndexView 设置成了25dp,高度为屏幕的高度并放置在了主布局的最右侧,值得注意的是,底部放置了一个.9图片,他可以随着咱们放置内容的增加而主动增长(.9制作可以自行百度,当然也可以自己写个 shape 作为背景也可以自动拉伸)。它下面是一个 ListView,它里面就是放置联动效果的联系人的。
此时运行起来效果如下:
看到还是很美观的,那么接下来就把字母使用 paint 画上去。
首先咱们看看基本的绘制:
public FastIndexView(Context context, AttributeSet attrs) {
super(context, attrs);
//设置画笔基本属性
mPaint = new Paint();
mPaint.setAntiAlias(true);//抗锯齿功能
mPaint.setColor(Color.parseColor("#fa7829")); //设置画笔颜色
mPaint.setStyle(Paint.Style.FILL);//设置填充样式 Style.FILL/Style.FILL_AND_STROKE/Style.STROKE
mPaint.setTextSize(20);//设置绘制字体的大小
}
这里是创建了 paint 实例,他就是一个画笔,然后往画布 canvas 上画画:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawText("A",10,20,mPaint);
canvas.drawText("B",10,50,mPaint);
canvas.drawText("C",10,80,mPaint);
}
画布就是 canvas。
【对于绘制相关的一些东西,后面会继续更的,就是可能更文比较慢】。
很简单,只是画了三个字母,然后运行程序:
已经能够把字母绘制上去了。显然,这么绘制很不靠谱,我们想办法通过数学公式计算一下怎么绘制上去:
还是看一张图理解:
这里把索引栏放大了几倍。在调用 canvas.drawText("A",x,y,mPaint);时候,x,y 为图中 A
红点位置(绿圈位置)。注意,每个字母的 x 看上去值一样,其实有的字母宽一些的话就不一样了,这里要清楚这一点;然后y的值只要计算出每个控件的中间位置,然后+字母的高度的一半就是字母的 y 坐标,对于控件的中间位置=index*单个控件的高度+(单个控件的高度/2)。index 表示每个字母的索引,比如这里的A索引是0,B 索引是1...这样,字母的y = 单个控件中间位置 + 字母的高度的一半。而字母的宽高可以通过如下代码获取:
Rect bounds = new Rect();
mPaint.getTextBounds(letter, 0, letter.length(), bounds);
这里 Rect 是一个矩形,这个矩形就是下面 A 字母外层粉红色笔勾勒的大小。
对于计算过程如下(以 A 为例子):
可见,字母的宽度也可以通过 bounds.width() 获取到的,选择其中之一即可。
那么,在 onDraw() 里面代码如下:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
for (int i = 0; i < LETTERS.length; i++) {
String letter = LETTERS[i];
//x = 控件宽度的一半 - 字母宽度的一半.mPaint.measureText(letter)可以测量当前文本的宽度
float x = mWidth/2 - mPaint.measureText(letter)/2;
//y = index*单个控件高度 + 控件高度的一半 + 字母高度的一半
// 获取文本所占的矩形区域
Rect bounds = new Rect();
mPaint.getTextBounds(letter, 0, letter.length(), bounds);
// float x = mWidth/2 - bounds.width()/2;//也可以通过这种方式获取字母的宽度
float y = i * mSingleHeight + mSingleHeight/2 + bounds.height()/2f;
canvas.drawText(letter,x,y,mPaint);
}
}
对于 onDraw() 里面的代码,上面已经做了非常非常详细的解释,相信没有任何问题了吧。然后再运行程序:
此时已经把所有的字母都绘制到控件上去了。接下来就是用户触摸反馈,点击到哪里,把这个字母给找出来。
2. 响应触摸事件
第一步绘制完毕,接下来就是用户触摸反馈,点击到哪里,把这个字母给找出来。需要重写 onTouchEvent 方法:
应该能想到,手指触摸要想找到对应的字母,显然是与 Y 的坐标是有关系的,跟 X 坐标没有任何关系。那么,具体的关系是怎么样的呢?还是通过一张图来解释:
还是找 A 这个字母,不管是按下还是移动,只要此时的 Y 坐标在 A 所在范围内就算是 A 字母。同理,B、C...都是如此。
那么关键点就成了如何计算手指触摸是不是在该字母的范围内了。
对于 A 的范围是0-->单个控件高度此时触摸反馈字母就是 A;对于 B 的范围是一个控件高度-->两个控件高度此时触摸反馈字母是 B;对于 C 的范围是两个空间高度-->三个控件控件高度触摸反馈字母就是 C ...那么这个规律也就找出来了:
[图片上传失败...(image-d55a72-1550319488257)]
这个 index 计算方式,不是通过 for 循环来计算,是根据此时的 Y 坐标除以单个控件的高度来计算。比如单个控件高度是 10.0f,此时触摸位置为 9.0f,此时的 index=(int)9.0f/10.0f = 0即 A;此时触摸位置为 22.3f,那么索引值为 index = (int)22.3f/10.f = 2即C。
首先看一下 DOWN 事件:
float downY = event.getY();
mIndex = (int) (downY/mSingleHeight);
if(mIndex != lastIndex){
if(mIndex *mSingleHeight < downY && downY < (mIndex +1)*mSingleHeight){
String letter = LETTERS[mIndex];
Log.e(TAG,"ACTION_DOWN,当前字母:"+letter);
lastIndex = mIndex;
}
}
这里就是根据上面推算得出的,先拿到当前触摸到的索引位置,然后判断当前触摸是位于了索引所对应的控件范围内,则打印当前的字母。
其实对于 MOVE 事件是一致的:
case MotionEvent.ACTION_MOVE:
mIndex = (int) (event.getY()/mSingleHeight);
if(mIndex != lastIndex){
if(mIndex*mSingleHeight < event.getY() && event.getY() < (mIndex+1)*mSingleHeight){
String letter = LETTERS[mIndex];
Log.e(TAG,"ACTION_MOVE,当前字母:"+letter);
lastIndex = mIndex;
}
}
break;
具体分析看一下DOWN事件。
这里有个值叫 lastIndex,初始化值为-1,。
private int lastIndex = -1;
是为了不让咱们 MOVE 的时候老是打印这个字母所做的标记,去掉 lastIndex 你可以试试 log,只要在这个区域内,就不停地打印该区域的字母。要记得在 UP 事件里面把 lastIndex 再置为-1,否则你当前位于 C 区域,下一次再点击 C 区域就不会打印 C 了。这个可以自己调试一下。
最后把整个代码贴出来:
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
float downY = event.getY();
mIndex = (int) (downY/mSingleHeight);
if(mIndex != lastIndex){
if(mIndex *mSingleHeight < downY && downY < (mIndex +1)*mSingleHeight){
String letter = LETTERS[mIndex];
Log.e(TAG,"ACTION_DOWN,当前字母:"+letter);
lastIndex = mIndex;
}
}
break;
case MotionEvent.ACTION_MOVE:
mIndex = (int) (event.getY()/mSingleHeight);
if(mIndex != lastIndex){
if(mIndex*mSingleHeight < event.getY() && event.getY() < (mIndex+1)*mSingleHeight){
String letter = LETTERS[mIndex];
Log.e(TAG,"ACTION_MOVE,当前字母:"+letter);
lastIndex = mIndex;
}
}
break;
case MotionEvent.ACTION_UP:
lastIndex = -1;
break;
default:
break;
}
return true;
}
然后运行程序:
可以清晰的看到滑动到哪个字母,就把对应的 LOG 给咱们打印出来了。
是不是已经结束了?这里其实存在一个 BUG,当我们滑动的时候,滑到 Z 再往下滑动会出现报数组角标越界异常:
[图片上传失败...(image-1e9cad-1550319488257)]
这里分析一下原因,在 MOVE 的时候有如下代码:
if(mIndex*mSingleHeight < event.getY() && event.getY() < (mIndex+1)*mSingleHeight){
String letter = LETTERS[mIndex];
Log.e(TAG,"ACTION_MOVE,当前字母:"+letter);
lastIndex = mIndex;
}
假如控件高度是100,总共有10个字母,单个控件的高度是10,那么 Z 的索引显然就是9。滑动索引栏至超过 Z 之后(假设滑动到了106),106/10 = 10.此时取数组第10个位置显然取不到,因为数组最大索引就是9啊,所以要在 MOVE 的时候做一次容错处理:
if(mIndex >=0 && mIndex < LETTERS.length){
//MOVE的逻辑
}
这样两边都做了索引判断,其实左边不判也是无所谓的主要就是右边会出现数组角标越界。当超过了数组大小,不让走 MOVE 逻辑,就解决了这个 BUG。
3. 添加监听回调支持
通过上面两步,基本完成了一半工作了,而现在仅仅是控件知道在哪个字母,客户端还不知道呢,所以设置一个回调方法告知客户端。
private OnFastIndexSelectedListener mIndexSelectedListener;
/**
* 调用此方法,设置选择索引栏监听器
* @param indexSelectedListener
*/
public void setIndexSelectedListener(OnFastIndexSelectedListener indexSelectedListener) {
mIndexSelectedListener = indexSelectedListener;
}
public interface OnFastIndexSelectedListener{
/**
* 回调方法,同时把当前位置以及选中的字母传递出去
* @param letter
*/
void selected(int position,String letter);
}
这段代码没啥好讲的,注释很清楚。
然后在打印 log 的地方调用方法即可:
此时在客户端就可以使用这个控件了:
此时在客户端就可以使用这个控件了:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
mIndexView = (FastIndexView) findViewById(R.id.indexview);
mIndexView.setOnIndexSelectedListener(new FastIndexView.OnFastIndexSelectedListener() {
@Override
public void selected(int position, String letter) {
ToastUtils.show(getApplicationContext(),letter);
}
});
}
这里通过设置监听器,把得到的数字做乐土司打印,这个土司是自定义的单例土司,做一个单例土司的原因是即使下一个土司触发,也不会出现等待上一个土司完毕之后再执行当前土司。
此时运行程序:
效果还算可以,那么接下来就是另外一部分开发了,即对 ListView 这一端做以下处理了。
4. 把汉字转换成拼音5. 将拼音, 数字, 字母排序6. 根据拼音首字母分组
接下来把4/5/6步合并在一起讲。
- 1、汉子转为拼音:
这个使用国内的一个开源库,pinyin4j-2.5.0。这个工具类直接使用即可,就在这里浪费时间了。 - 2、把数据按照拼音首字母进行排序并且在 ListVIew 上展示出来:
首选创建一个 Person 类,用来保存 item 的数据
public class Person implements Comparable<Person>{
private String name;
private String pinyin;
public Person(String name) {
this.name = name;
this.pinyin = PinYinUtils.getyPinyin(name);
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getPinyin() {
return pinyin;
}
public void setPinyin(String pinyin) {
this.pinyin = pinyin;
}
@Override
public int compareTo(Person other) {
return pinyin.compareTo(other.pinyin);
}
}
他实现了 Comparable 接口,因为要使用 Comparable 对每个 bean 排序,排序的方式在 compareTo 里面做了实现:pinyin.compareTo(other.pinyin);注意,在 bean 里面的 pinyin字段直接调用 this.pinyin = PinYinUtils.getyPinyin(name); 做了汉子到拼音的转化。例如:杨童鞋--->YANGTONGXIE。这个工具类已经写好了,有兴趣可以下载代码查看我这里就不讲了。
然后在 Activity 中创建数据源并且排序:
for (int i = 0; i < Strings.NAMES.length; i++) {
Person person = new Person(Strings.NAMES[i]);
mPersonList.add(person);
}
//对集合排序
Collections.sort(mPersonList);
数据源有了,接下来就是设置适配器了:
适配器咱们只关心 getView 方法就好了:
首先
适配器的 item 布局:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<!--ListView的item的布局-->
<TextView
android:visibility="gone"
android:id="@+id/tv_title"
android:textSize="14sp"
android:paddingLeft="16dp"
android:text="A"
android:gravity="center_vertical"
android:background="#99cccccc"
android:layout_width="match_parent"
android:layout_height="20dp"/>
<TextView
android:id="@+id/tv_name"
android:paddingLeft="24dp"
android:text="杨童鞋"
android:gravity="center_vertical"
android:background="#FFFFFF"
android:layout_width="match_parent"
android:layout_height="45dp"/>
</LinearLayout>
这里是使用偷懒的方式,先创建出 tittle,然后在 getView 里面根据某些条件控制它的显示和隐藏,在隐藏的时候对这块布局根部不去绘制也就不会展示出来,再布局文件里面默认让 tittle 隐藏起来。
getView() 方法:
@Override
public View getView(int position, View convertView, ViewGroup parent) {
if(convertView == null){
convertView = View.inflate(parent.getContext(), R.layout.list_item,null);
}
TextView tv_title = (TextView) convertView.findViewById(R.id.tv_title);
TextView tv_name = (TextView) convertView.findViewById(R.id.tv_name);
Person person = mPersonList.get(position);
String pinyin = person.getPinyin();
String currentLetter = pinyin.charAt(0)+"";
int num = 0;//标记,1代表要显示tittle字母
if(position == 0){
num = 1;
}else{
//获取上一个item的首字母
String preLetter = mPersonList.get(position - 1).getPinyin().charAt(0) + "";
if(!TextUtils.equals(preLetter,currentLetter)){
num = 1;
}
}
tv_title.setVisibility(num == 1?View.VISIBLE:View.GONE);
tv_title.setText(currentLetter);
tv_name.setText(person.getName());
return convertView;
}
getView 里面比较难理解的摘出来:
String currentLetter = pinyin.charAt(0)+"";
int num = 0;//标记,1代表要显示tittle字母
if(position == 0){
num = 1;
}else{
//获取上一个item的首字母
String preLetter = mPersonList.get(position - 1).getPinyin().charAt(0) + "";
if(!TextUtils.equals(preLetter,currentLetter)){
num = 1;
}
}
tv_title.setVisibility(num == 1?View.VISIBLE:View.GONE);
tv_title.setText(currentLetter);
这里就是判断何时展示 tittle 与何时隐藏 tittle 的方法了:
当第0条的时候一定可以展示那么就记录标记为1,在不是第0条的时候,当前的拼音首字母如果与上一个 item 的拼音的首字母不同的时候,就让当前的 tittle 展示出来,记录 num 标志为1.
最后何时展示与隐藏就比较简单了:
tv_title.setVisibility(num == 1?View.VISIBLE:View.GONE);
因为1代表展示,0默认代表不展示的嘛。这个过程也就是根据拼音首字母分组的过程。
运行程序:
控件基本完成了,最后再把左侧控件点击与ListView结合起来就完了。
7. 将自定义的索引栏和 ListView 进行结合
mIndexView.setOnIndexSelectedListener(new FastIndexView.OnFastIndexSelectedListener() {
@Override
public void selected(int position, String letter) {
ToastUtils.show(getApplicationContext(),letter);
for (int i = 0; i < mPersonList.size(); i++) {
String currentLetter = mPersonList.get(i).getPinyin().charAt(0) + "";
if(TextUtils.equals(currentLetter,letter)){
// 当前点击的字母 == ListView中的字母。强制“跳转”到指定行索引处
mListView.setSelection(i);
break;
}
}
}
});
这里就很简单了,因为我们客户端知道当前点击了哪个字母,只要拿着这个字母去集合中匹配,集合跟 ListView 数据以及索引是一一对应的,找到后拿着这个索引调用 List 的 mListView.setSelection(i);即可。
此时运行程序:
此时控件其实已经完成的差不多了,最后再对控件做一点点稍微的完善:
完善1、点击字母让点击的字母变色:
其实只需要两行代码就能搞定了:
mPaint.setColor(i == mIndex ? Color.parseColor("#fa7829") : Color.parseColor("#888888"));
因为 mIndex 这个值是在 onTouchEvent 里面记录起来的,代表着当前选中的哪个索引位置的字母,然后要想把当前位置染色绘制改变,就需要重新调用 onDraw(),那么就在 onTouchEvent 的最后调用 invalidate(); 重绘就好了,因为 mIndex 值是当前的字母,那么 for 循环所有字母好到当前字母后,把画笔颜色修改颜色值就可以了。
完善2、土司改为自己定义的布局:
在 Activity 的布局文件里面,最后覆盖一个蒙层:
<TextView
android:visibility="gone"
android:id="@+id/tv_showletter"
android:text="A"
android:gravity="center"
android:layout_centerInParent="true"
android:background="@drawable/shape_text"
android:layout_width="90dp"
android:layout_height="65dp"/>
然后背景通过 shape 画上去的,以至于背景边缘圆滑一些。且默认是隐藏起来的
然后在 Activity 中每次回调方法里面就设置它的值:
/**
* 展示字母
* @param letter
*/
private void showLetter(String letter) {
mTvShow.setText(letter);
mTvShow.setVisibility(View.VISIBLE);
//清楚掉之前的延时操作,否则2s即使在滑动索引栏展示文本也会消失
mHandler.removeCallbacksAndMessages(null);
mHandler.postDelayed(new Runnable() {
@Override
public void run() {
mTvShow.setVisibility(View.GONE);
}
},2000);
}
每次回调后调用 showLetter 方法,让自定义布局展示出来,且 使用 Handler 延迟2s后隐藏掉自定义展示的布局。这里需要注意,每次调用前,记得把之前的消息完全移除掉,否则有可能出现滑动右侧索引栏超过两秒,出现滑动中隐藏展示布局的 bug(这个可以自行调试)。
最后运行程序看看最终的效果: