效果图:
实现这个效果的大致步骤有:
1. A-Z索引的绘制.
2. 处理Touch事件.
3. 提供使用监听\回调.
4. 汉字转换成拼音.
5. 进行排序展示.
6. 进行分组.
7. 将自定义控件和ListView合体.
需要注意的地方有:
1.文本绘制的起始点
绘制文本是用Canvas的drawText(String text, float x, float y, Paint paint)方法,接收4个参数,分别是绘制的文本内容,文本开始绘制的x坐标,文本开始绘制的y坐标,绘制文本的画笔Paint;需要注意的是文本的绘制是以左下角为起点的.关于x,y坐标的获取分析如下图所示:
cellWidth和cellHeight分别是每一个字母所在的单元格的宽和高,由图可知,其实宽度就是该自定义控件的宽,高度就是该自定义控件的高度除以26,这个26就是英文字母的个数.
2.文本宽高的获取
由上图可知,我们是需要获取文本的宽高来计算绘制文本的x,y坐标的.那么如何计算文本的宽高呢?
可以通过Paint的getTextBounds(String text, int start, int end, Rect bounds)方法获取,接收4个参数,分别是要获取宽高信息的目标文本,从文本的第几个字符开始,到文本的第几个字符结束,用于封装文本宽高信息的矩形.这里特别注意的是第4个参数,Rect 矩形里面封装了left,top,right,bottom的信息,有了这4个值,那么获取宽高就变得很容易了.width=right-left,height=bottom-top;当然这些计算都无需我们操心了,Rect 对象已经封装好了,我们只需要传入一个空的Rect 作为getTextBounds方法的第4个参数,那么该方法就会返回一个带有宽高信息的Rect 对象给我们,然后调用Rect的height()和width()就可以获取到宽高值了.我们可以看下Rect的这2个方法的源码:
/** * @return the rectangle's width. This does not check for a valid rectangle * (i.e. left <= right) so the result may be negative. */ public final int width() { return right - left; } /** * @return the rectangle's height. This does not check for a valid rectangle * (i.e. top <= bottom) so the result may be negative. */ public final int height() { return bottom - top; }
这里再介绍另一种方式获取文本的宽度,只是获取宽度而已,可以调用Paint的 measureText(String text)方法,返回float值,也是可以获取文本的宽度的.
3.如何获取当前选中的字母
通过重写字母导航控件的onTouchEvent方法,计算当前的event.getY(),通过该值除以字母所在单元格的高度cellHeight,就可判断当前的手指所处第几个单元格内,这样就可以获取到一个索引,通过索引就可以找到对应的英文字母了.这里需要注意的是,需要排除手指在同一个单元格内不断触摸的情况,否则会造成通过字母索引多次回调,说到回调,这里我们是需要自定义接口保留方法,通过接口回调的方式暴露当前用户所选择的字母的.
4.数据的排序
值得一提的是,我们在ListView中展示的数据是经过排序,也就是说拼音首字母相同的中文是要归为同一组显示的,当然如果是英文字母的话就更好了,都不需要经过中文转拼音,再截取拼音首字母的过程操作了.那么排序如何实现呢,其实就是比较拼音字符串的自然顺序而已,目标bean需要实现Comparable接口,然后实现其compareTo方法,在该方法内部进行字符串的自然顺序的比较即可.
5.中文如何转字符串
这个可以借助一些三方jar包就可以实现了,本例采用的是pinyin4j-2.5.0.jar.具体实现看如下工具类:
/** * Created by mChenys on 2015/12/20. */ public class PingYingUtils { /** * 根据传入的字符串(包含汉字),得到拼音 * 拼音 -> PINGYING * 拼 音*& -> PINGYING*& 保留特殊符号,却掉空格 * * @param str 字符串 * @return */ public static String getPinyin(String str) { //拼音的格式 HanyuPinyinOutputFormat format = new HanyuPinyinOutputFormat(); format.setCaseType(HanyuPinyinCaseType.UPPERCASE); //全部大写 format.setToneType(HanyuPinyinToneType.WITHOUT_TONE); //去掉声调 StringBuilder sb = new StringBuilder(); char[] charArray = str.toCharArray(); for (int i = 0; i < charArray.length; i++) { char c = charArray[i]; // 如果是空格, 跳过 if (Character.isWhitespace(c)) { continue; } if (c >= -127 && c < 128) { // 肯定不是汉字,保留特殊字符和英文字母 sb.append(c); } else { String s = ""; try { // 通过char得到拼音集合,因为有多音字的存在,这里不考虑,直接取第一个匹配的拼音 s = PinyinHelper.toHanyuPinyinStringArray(c, format)[0]; sb.append(s); } catch (BadHanyuPinyinOutputFormatCombination e) { e.printStackTrace(); sb.append(s); } } } return sb.toString(); } }
6.如何控制ListView中Item显示字母title控件的时候所有同一组的只显示一次呢?
也就是下图这个,这个是如何做到的呢?
要控制这个只显示一次,需要在ListView的Adapter内做控制,在getView方法中需要从数据Bean中分别获取索引为position和position-1时的拼音首字母,然后对比是否是一样的,如果是一样的就说明是同一个组,如果是同一组那么就不显示字母title控件,否则就显示,通过View的setVisibility(int visibility)方法来实现.
好了分析了理论,现在来看代码吧
1.自定义控件MyQuickIndexBar
/** * Created by mChenys on 2015/12/20. */ public class MyQuickIndexBar extends View { private Paint mPaint;//绘制文本的画笔 private char[] mLetters = new char[26];//存放26个字母的字符数组 private int mCellWidth; //文本所在矩形区域的宽度 private float mCellHeight;//文本所在矩形区域的高度 public MyQuickIndexBar(Context context) { this(context, null); } public MyQuickIndexBar(Context context, AttributeSet attrs) { this(context, attrs, 0); } public MyQuickIndexBar(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(); } private void init() { //初始化画笔 mPaint = new Paint(Paint.ANTI_ALIAS_FLAG); mPaint.setColor(Color.parseColor("#8B8B8B")); mPaint.setTypeface(Typeface.DEFAULT_BOLD); mPaint.setTextSize(25); //初始化26个单词字母 for (int i = 0; i < 26; i++) { mLetters[i] = (char) ('A' + i); } } //控件大小发生变化时回调 @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { mCellWidth = getMeasuredWidth(); mCellHeight = getMeasuredHeight() * 1.0f / mLetters.length; } @Override protected void onDraw(Canvas canvas) { //确定文本的绘制的开始坐标,文本绘制的坐标是以左下角为起点的. for (int i = 0; i < mLetters.length; i++) { String text = String.valueOf(mLetters[i]); //获取文本的宽度和高度 Rect bounds = new Rect();//创建一个空矩形区域 mPaint.getTextBounds(text, 0, text.length(), bounds);//将文本绘制在空的矩区域中 float textHeight = bounds.height();//从矩形区域中获取文本的高度 float textWidth = bounds.width(); //也可以用mPaint.measureText(text);获取宽度 //计算文本的左下角x和y坐标 //x坐标:文本所在单元格宽度/2 - 文本宽度/2 int x = (int) (mCellWidth / 2.0f - textWidth / 2.0f); //y坐标:文本所在单元格的高度/2+文本高度/2 + 文本之间的间距(相邻2个文本的间距刚好是一个单元格的高度) int y = (int) (mCellHeight / 2.0f + textHeight / 2.0f + i * mCellHeight); //把选中的文本高亮显示 mPaint.setColor(selectIndex == i ? Color.parseColor("#77D1F3") : Color.parseColor("#8B8B8B")); //绘制文本 canvas.drawText(text, x, y, mPaint); } } private int lastIndex = -1; //上一次选择的位置 private int selectIndex = -1; //当前选择的位置 @Override public boolean onTouchEvent(MotionEvent event) { switch (MotionEventCompat.getActionMasked(event)) { case MotionEvent.ACTION_MOVE: case MotionEvent.ACTION_DOWN: // 获取当前触摸到的字母索引 selectIndex = (int) (event.getY() / mCellHeight); if (selectIndex >= 0 && selectIndex < mLetters.length) { // 判断是否跟上一次触摸到的一样,避免在同一个单元格内触摸时多次回调 if (selectIndex != lastIndex) { lastIndex = selectIndex; if (null != changeCallback) { changeCallback.onChange(String.valueOf(mLetters[selectIndex]), selectIndex); } } } break; case MotionEvent.ACTION_UP: lastIndex = -1; break; default: break; } //不断回调onDraw方法 invalidate(); return true; } //字母改变的回到接口 public interface OnWordsChangeCallback { void onChange(String word, int index); } private OnWordsChangeCallback changeCallback; public void setOnWordsChangeCallback(OnWordsChangeCallback changeCallback) { this.changeCallback = changeCallback; } }
2.数据Bean
/** * Created by mChenys on 2015/12/20. */ public class Person implements Comparable<Person> { public String name; //人名 public String pingYing; //拼音 public Person(String name) { this.name = name; this.pingYing = PingYingUtils.getPinyin(name); } @Override public String toString() { return "Person{" + "name='" + name + '\'' + ", pingYing='" + pingYing + '\'' + '}'; } /** * 获取数据集合 * * @return */ public static List<Person> getPersons() { List<Person> list = new ArrayList<>(); for (int i = 0; i < Constant.NAME.length; i++) { list.add(new Person(Constant.NAME[i])); } //排序 Collections.sort(list); return list; } //根据拼音排序 @Override public int compareTo(Person another) { return this.pingYing.compareTo(another.pingYing); } }
3.数据源,也就是一个字符串数组,由于数据太多,就直接贴图了,图只截了部分.
4.测试类MainActivity
public class MainActivity extends AppCompatActivity { private MyQuickIndexBar mQuickIndexBar; //自定义的字母导航条 private TextView mIndicatorView; //中间显示的提示文本 private ListView mListView; private List<Person> mData = new ArrayList<>(); //数据集合 @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); initData(); initView(); } /** * 初始数据 */ private void initData() { mData = Person.getPersons(); System.out.println(mData); } /** * 初始化View */ private void initView() { mQuickIndexBar = (MyQuickIndexBar) findViewById(R.id.quickIndexBar); mIndicatorView = (TextView) findViewById(R.id.indicatorView); mIndicatorView.setVisibility(View.GONE); //导航条的字母改变监听 mQuickIndexBar.setOnWordsChangeCallback(new MyQuickIndexBar.OnWordsChangeCallback() { @Override public void onChange(String word, int index) { //显示提示 showIndicatorView(word); //滚动ListView到选中的字母索引位置 showListViewSelected(word); } }); mListView = (ListView) findViewById(R.id.listView); mListView.setAdapter(adapter); } /** * 显示用户点击的字母索引 */ private Handler mHandler = new Handler(); private void showIndicatorView(String word) { mIndicatorView.setVisibility(View.VISIBLE); mIndicatorView.setText(word); mHandler.removeCallbacksAndMessages(null); mHandler.postDelayed(new Runnable() { @Override public void run() { mIndicatorView.setVisibility(View.GONE); } },2000); } /** * 滚动ListView到选中的字母索引位置 * * @param word */ private void showListViewSelected(String word) { for (int i = 0; i < mData.size(); i++) { Person person = mData.get(i); String target = person.pingYing.charAt(0) + ""; if (TextUtils.equals(word, target)) { // 匹配成功 mListView.setSelection(i); break; //匹配成功记得跳出循环 } } } //适配器 private BaseAdapter adapter = new BaseAdapter() { @Override public int getCount() { return mData.size(); } @Override public String getItem(int position) { return mData.get(position).name; } @Override public long getItemId(int position) { return position; } @Override public View getView(int position, View convertView, ViewGroup parent) { ViewHolder holder = null; if (convertView == null) { holder = new ViewHolder(); convertView = View.inflate(MainActivity.this, R.layout.item_list, null); convertView.setTag(holder); holder.tvName = (TextView) convertView.findViewById(R.id.tv_name); holder.tvGroupName = (TextView) convertView.findViewById(R.id.tv_groupName); holder.llGroupBar = (LinearLayout) convertView.findViewById(R.id.ll_groupBar); } else { holder = (ViewHolder) convertView.getTag(); } //显示人名 holder.tvName.setText(getItem(position)); //分组操作,如果人名的首字母相同则归为一类,只显示一个字母title //判断原理就是取当前的条目的拼音首字母和上一个条目的首字母对比,如果相同则是同一组 String currIndexWord = String.valueOf(mData.get(position).pingYing.charAt(0)); boolean isSameGroup = false; if (position > 0) { //和上一个条目的拼音首字母作对比 String lastIndexWord = String.valueOf(mData.get(position - 1).pingYing.charAt(0)); if (lastIndexWord.equals(currIndexWord)) { isSameGroup = true; } } //显示拼音首字母的title横幅,如果是不同组则显示该组的横幅 holder.llGroupBar.setVisibility(!isSameGroup ? View.VISIBLE : View.GONE); holder.tvGroupName.setText(currIndexWord); return convertView; } class ViewHolder { TextView tvName, tvGroupName; LinearLayout llGroupBar; } }; }
5.activity_main.xml
<?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:layout_width="match_parent" android:layout_height="match_parent" android:background="#EEEEEE" tools:context="mchenys.net.csdn.blog.myquickindexbar.MainActivity"> <!--listview--> <ListView android:id="@+id/listView" android:scrollbars="none" android:layout_toLeftOf="@+id/quickIndexBar" android:layout_width="match_parent" android:layout_height="match_parent" /> <!--字母导航控件--> <mchenys.net.csdn.blog.myquickindexbar.view.MyQuickIndexBar android:id="@+id/quickIndexBar" android:layout_width="30dp" android:layout_height="match_parent" android:layout_alignParentRight="true" /> <!--屏幕正中显示当前选中的字母的提示框--> <TextView android:id="@+id/indicatorView" android:layout_width="80dp" android:layout_height="80dp" android:layout_centerInParent="true" android:background="@drawable/letter_indicator_shape" android:gravity="center" android:textColor="@android:color/white" android:textSize="30sp" android:textStyle="bold" /> </RelativeLayout>
6.ListView的Item布局文件
<?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="match_parent" android:orientation="vertical"> <!--显示字母的title横幅--> <LinearLayout android:id="@+id/ll_groupBar" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical"> <TextView android:id="@+id/tv_groupName" android:layout_width="match_parent" android:layout_height="40dp" android:gravity="center_vertical" android:paddingLeft="10dp" android:text="A" android:textColor="@android:color/holo_blue_light" android:textSize="20sp" android:textStyle="bold" /> <View android:layout_width="match_parent" android:layout_height="2dp" android:background="@android:color/holo_blue_light" /> </LinearLayout> <!--显示文本--> <TextView android:id="@+id/tv_name" android:layout_width="match_parent" android:layout_height="60dp" android:gravity="center_vertical" android:paddingLeft="10dp" android:text="玉帝" android:textColor="@android:color/black" android:textSize="20sp" android:textStyle="bold" /> </LinearLayout>