现在联系人列表基本都是按照字母或者拼音来进行分类,右边有一排字母供用户快速定位到指定的字母位置,效果图如下:
OK,输入的联系人类型可能有很多种,比如汉字、英文、数字、特殊符号等等,其中汉字会转化成拼音,完后和英文一起进行分类,分类的原则是首字母排序,而数字、特殊符号等,统一放到“#”分类中,下面来看具体的实现。
1. 最右边字母栏的实现
我们先来看最右边的这一排字母,这个布局很简单,从上到下,第一个是一个箭头,用来滑动到联系人列表的顶部,下面依次是26个英文字母,最后是一个#字符,那实现这样一个布局,最简单的方法肯定就是自定义一个LinearLayout了,如果在xml里面一个个画那就太蛋疼了,源码如下:
LetterView.java
public class LetterView extends LinearLayout {
private Context mContext;
private CharacterClickListener mListener;
public LetterView(Context context, AttributeSet attrs) {
super(context, attrs);
mContext = context;
setOrientation(VERTICAL);
initView();
}
private void initView() {
addView(buildImageLayout());
for (char i = 'A'; i <= 'Z'; i++) {
final String character = i + "";
TextView tv = buildTextLayout(character);
addView(tv);
}
addView(buildTextLayout("#"));
}
private TextView buildTextLayout(final String character) {
LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT, 1);
TextView tv = new TextView(mContext);
tv.setLayoutParams(layoutParams);
tv.setGravity(Gravity.CENTER);
tv.setClickable(true);
tv.setText(character);
tv.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
if (mListener != null) {
mListener.clickCharacter(character);
}
}
});
return tv;
}
private ImageView buildImageLayout() {
LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT, 1);
ImageView iv = new ImageView(mContext);
iv.setLayoutParams(layoutParams);
iv.setBackgroundResource(R.mipmap.arrow);
iv.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
if (mListener != null) {
mListener.clickArrow();
}
}
});
return iv;
}
public void setCharacterListener(CharacterClickListener listener) {
mListener = listener;
}
public interface CharacterClickListener {
void clickCharacter(String character);
void clickArrow();
}
}
首先,在构造函数中,我们声明了setOrientation为VERTICAL,也就是垂直布局,完后在initView中构造我们的布局,第一步先是调用buildImageLayout方法,也就是构造我们最上面的箭头,这里很简单了,不多说,注意LinearLayout.LayoutParams这里的最后一个参数是weight的值,这里和下面都设成1,就能保证我们所有字母和箭头以及#都能平均的分配布局的高度。
这里的CharacterClickListener是我们定义的一个接口,它用来在字母、箭头或#被点击时回调,其中clickCharacter方法是点击字母或#时候的回调,而clickArrow是点击箭头时的回调。完后调用addView方法,就将箭头加入到LinearLayout布局中来。
接下来,一个for循环,将A到Z的26个英文字母都加入,这个和上面类似的,最后加上#,这个最右边一排的布局就算完成了。
2. 主布局的实现
联系人的列表可以有多种实现方式,用ListView,ExpandableListView,RecyclerView甚至自定义都可以实现,我们这里采用相对比较新的RecyclerView来实现,那主布局就很简单了,源码如下:
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<android.support.v7.widget.RecyclerView
android:id="@+id/contact_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
/>
<com.example.contactview.LetterView
android:id="@+id/letter_view"
android:layout_width="30dp"
android:layout_height="match_parent"
android:layout_alignParentRight="true"
/>
</RelativeLayout>
一个相对布局,在第一步中定义好的LetterView放在最右边,完后再加上一个RecyclerView就OK
3. MainActivity的实现
MainActivity也比较简单,往RecyclerView的Adapter中填充数据,并定义点击第一步中箭头、字母或#时的回调即可,源码如下:
MainActivity.java
public class MainActivity extends Activity {
private RecyclerView contactList;
private String[] contactNames;
private LinearLayoutManager layoutManager;
private LetterView letterView;
private ContactAdapter adapter;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
contactNames = new String[] {"张三丰", "郭靖", "黄蓉", "黄老邪", "赵敏", "123", "天山童姥", "任我行", "于万亭", "陈家洛", "韦小宝", "$6", "穆人清", "陈圆圆", "郭芙", "郭襄", "穆念慈", "东方不败", "梅超风", "林平之", "林远图", "灭绝师太", "段誉", "鸠摩智"};
contactList = (RecyclerView) findViewById(R.id.contact_list);
letterView = (LetterView) findViewById(R.id.letter_view);
layoutManager = new LinearLayoutManager(this);
adapter = new ContactAdapter(this, contactNames);
contactList.setLayoutManager(layoutManager);
contactList.addItemDecoration(new DividerItemDecoration(this, DividerItemDecoration.VERTICAL_LIST));
contactList.setAdapter(adapter);
letterView.setCharacterListener(new LetterView.CharacterClickListener() {
@Override
public void clickCharacter(String character) {
layoutManager.scrollToPositionWithOffset(adapter.getScrollPosition(character), 0);
}
@Override
public void clickArrow() {
layoutManager.scrollToPositionWithOffset(0, 0);
}
});
}
}
这里我们将要显示的联系人,放在一个字符串数组里面,完后设置Recycler的LayoutManager为LinearLayoutManager,默认方向为垂直,完后设置分割线为DividerItemDecoration,这个在谷歌官方的Sample中已经提供实现了,我们直接copy过来,地址为https://android.googlesource.com/platform/development/+/master/samples/Support7Demos/src/com/example/android/supportv7/widget/decorator/DividerItemDecoration.java,我们简单看下它的实现:
DividerItemDecoration.java
public class DividerItemDecoration extends RecyclerView.ItemDecoration {
private static final int[] ATTRS = new int[]{
android.R.attr.listDivider
};
public static final int HORIZONTAL_LIST = LinearLayoutManager.HORIZONTAL;
public static final int VERTICAL_LIST = LinearLayoutManager.VERTICAL;
private Drawable mDivider;
private int mOrientation;
public DividerItemDecoration(Context context, int orientation) {
final TypedArray a = context.obtainStyledAttributes(ATTRS);
mDivider = a.getDrawable(0);
a.recycle();
setOrientation(orientation);
}
public void setOrientation(int orientation) {
if (orientation != HORIZONTAL_LIST && orientation != VERTICAL_LIST) {
throw new IllegalArgumentException("invalid orientation");
}
mOrientation = orientation;
}
@Override
public void onDraw(Canvas c, RecyclerView parent) {
if (mOrientation == VERTICAL_LIST) {
drawVertical(c, parent);
} else {
drawHorizontal(c, parent);
}
}
public void drawVertical(Canvas c, RecyclerView parent) {
final int left = parent.getPaddingLeft();
final int right = parent.getWidth() - parent.getPaddingRight();
final int childCount = parent.getChildCount();
for (int i = 0; i < childCount; i++) {
final View child = parent.getChildAt(i);
final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child
.getLayoutParams();
final int top = child.getBottom() + params.bottomMargin +
Math.round(ViewCompat.getTranslationY(child));
final int bottom = top + mDivider.getIntrinsicHeight();
mDivider.setBounds(left, top, right, bottom);
mDivider.draw(c);
}
}
public void drawHorizontal(Canvas c, RecyclerView parent) {
final int top = parent.getPaddingTop();
final int bottom = parent.getHeight() - parent.getPaddingBottom();
final int childCount = parent.getChildCount();
for (int i = 0; i < childCount; i++) {
final View child = parent.getChildAt(i);
final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child
.getLayoutParams();
final int left = child.getRight() + params.rightMargin +
Math.round(ViewCompat.getTranslationX(child));
final int right = left + mDivider.getIntrinsicHeight();
mDivider.setBounds(left, top, right, bottom);
mDivider.draw(c);
}
}
@Override
public void getItemOffsets(Rect outRect, int itemPosition, RecyclerView parent) {
if (mOrientation == VERTICAL_LIST) {
outRect.set(0, 0, 0, mDivider.getIntrinsicHeight());
} else {
outRect.set(0, 0, mDivider.getIntrinsicWidth(), 0);
}
}
}
首先在构造函数中,获取一个Drawable对象,这里使用的是android.R.attr.listDivider,这是Android源码里就有的,我们不需要自己来定义,完后根据接收的方向参数,来定义是水平还是垂直方向,在onDraw中,根据方向来调用不同的方法来画分割线,我们这里因为是垂直方向,所以调用的是drawVertical方法。
首先根据parent的属性,获取left和right,因为是垂直方向,所以所有分割线的left和right都是一样的,接下来获取child的数量,完后for循环,在循环中,获取child,完后获取child的LayoutParams,通过LayoutParams,我们就可以计算分割线的top和bottom,分割线是在child的下面的,所以对于分割线的top来说,首先要获取child的bottom位置,完后加上可能存在的bottomMargin,最后加上可能存在的y轴相对偏移量,这就是最终的分割线的top,而分割线的bottom则直接用top加上分割线的高度即可。最后通过setBounds方法来设定分割线的边界,最后画在canvas上面。
drawHorizontal方法是类似的,不再赘述。而最后的getItemOffsets方法,是用来定义child之间的偏移量的,我们来看一段英文:
We need to provide offsets between list items so that we’re not drawing dividers on top of our child views. getItemOffsets is called for each child of your RecyclerView.
所以这个offsets,就是用来定义child之间的间距的,对于垂直方向来说,这个间距就是分割线的高度,第一个child上面间距是为0的,所以这里我们的top为0,而将bottom赋值为分割线的高度。
设置完分割线,我们最后要设置Recycler的adapter对象,这个最后讲,完后就是实现点击箭头、字母或者#时的回调方法,这里我们调用了scrollToPositionWithOffset方法,它的第一个参数是要滑动到Recycler的第几个child,而第二个参数是相对第几个child的top的偏移值,这里我们滑动到指定child,不偏移,所以第二个参数就是0了。
4. Adapter的实现
Adapter是这里要实现的核心部分,观察实现效果,字母和具体的联系人,是两种布局,所以我们的Recycler中,也要定义两种布局,先上代码:
ContactAdapter.java
public class ContactAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
private LayoutInflater mLayoutInflater;
private Context mContext;
private String[] mContactNames; // 联系人名称字符串数组
private List<String> mContactList; // 联系人名称List(转换成拼音)
private List<Contact> resultList; // 最终结果(包含分组的字母)
private List<String> characterList; // 字母List
public enum ITEM_TYPE {
ITEM_TYPE_CHARACTER,
ITEM_TYPE_CONTACT
}
public ContactAdapter(Context context, String[] contactNames) {
mContext = context;
mLayoutInflater = LayoutInflater.from(context);
mContactNames = contactNames;
handleContact();
}
private void handleContact() {
mContactList = new ArrayList<>();
Map<String, String> map = new HashMap<>();
for (int i = 0; i < mContactNames.length; i++) {
String pinyin = Utils.getPingYin(mContactNames[i]);
map.put(pinyin, mContactNames[i]);
mContactList.add(pinyin);
}
Collections.sort(mContactList, new ContactComparator());
resultList = new ArrayList<>();
characterList = new ArrayList<>();
for (int i = 0; i < mContactList.size(); i++) {
String name = mContactList.get(i);
String character = (name.charAt(0) + "").toUpperCase(Locale.ENGLISH);
if (!characterList.contains(character)) {
if (character.hashCode() >= "A".hashCode() && character.hashCode() <= "Z".hashCode()) { // 是字母
characterList.add(character);
resultList.add(new Contact(character, ITEM_TYPE.ITEM_TYPE_CHARACTER.ordinal()));
} else {
if (!characterList.contains("#")) {
characterList.add("#");
resultList.add(new Contact("#", ITEM_TYPE.ITEM_TYPE_CHARACTER.ordinal()));
}
}
}
resultList.add(new Contact(map.get(name), ITEM_TYPE.ITEM_TYPE_CONTACT.ordinal()));
}
}
@Override
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
if (viewType == ITEM_TYPE.ITEM_TYPE_CHARACTER.ordinal()) {
return new CharacterHolder(mLayoutInflater.inflate(R.layout.item_character, parent, false));
} else {
return new ContactHolder(mLayoutInflater.inflate(R.layout.item_contact, parent, false));
}
}
@Override
public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
if (holder instanceof CharacterHolder) {
((CharacterHolder) holder).mTextView.setText(resultList.get(position).getmName());
} else if (holder instanceof ContactHolder) {
((ContactHolder) holder).mTextView.setText(resultList.get(position).getmName());
}
}
@Override
public int getItemViewType(int position) {
return resultList.get(position).getmType();
}
@Override
public int getItemCount() {
return resultList == null ? 0 : resultList.size();
}
public class CharacterHolder extends RecyclerView.ViewHolder {
TextView mTextView;
CharacterHolder(View view) {
super(view);
mTextView = (TextView) view.findViewById(R.id.character);
}
}
public class ContactHolder extends RecyclerView.ViewHolder {
TextView mTextView;
ContactHolder(View view) {
super(view);
mTextView = (TextView) view.findViewById(R.id.contact_name);
}
}
public int getScrollPosition(String character) {
if (characterList.contains(character)) {
for (int i = 0; i < resultList.size(); i++) {
if (resultList.get(i).getmName().equals(character)) {
return i;
}
}
}
return -1; // -1不会滑动
}
}
首先定义了一个枚举,里面有两种类型,ITEM_TYPE_CHARACTER表示字母,ITEM_TYPE_CONTACT表示具体的联系人。完后在handleContact方法里来对联系人进行排序和分类的操作。
将传过来的联系人字符串数组,通过Utils.getPingYin方法进行一次处理,这个方法如下:
Utils.java
public class Utils {
public static String getPingYin(String inputString) {
HanyuPinyinOutputFormat format = new HanyuPinyinOutputFormat();
format.setCaseType(HanyuPinyinCaseType.LOWERCASE);
format.setToneType(HanyuPinyinToneType.WITHOUT_TONE);
format.setVCharType(HanyuPinyinVCharType.WITH_V);
char[] input = inputString.trim().toCharArray();
String output = "";
try {
for (char curchar : input) {
if (java.lang.Character.toString(curchar).matches("[\\u4E00-\\u9FA5]+")) {
String[] temp = PinyinHelper.toHanyuPinyinStringArray(curchar, format);
output += temp[0];
} else {
output += java.lang.Character.toString(curchar);
}
}
} catch (BadHanyuPinyinOutputFormatCombination e) {
e.printStackTrace();
}
return output;
}
}
这里用到了一个第三方的jar包,pinyin4j-2.5.0.jar,它可以在中文和拼音之间进行转换,官方网址为: http://pinyin4j.sourceforge.net/,我们这里传过来的联系人可能是中文,为了进行排序和分组,我们必须先将他们转化为拼音,getPingYin就是实现这样一个功能,其中要注意的是,正则表达式"[\\u4E00-\\u9FA5]+",这里是判断是否是中文,是中文我们才进行转换,不是则不处理。
转换后,我们将拼音存入mContactList里,注意,我们同时还要将值存入一个map里,这个map的key是转换后的值,value是转换之前的值,需要这个map是因为我们在联系人列表中最终显示的是中文,而不是拼音,拼音只是用来排序和分组用的。
完后,我们使用一个ContactComparator来对联系人进行排序,源码如下:
ContactComparator.java
public class ContactComparator implements Comparator<String> {
@Override
public int compare(String o1, String o2) {
int c1 = (o1.charAt(0) + "").toUpperCase().hashCode();
int c2 = (o2.charAt(0) + "").toUpperCase().hashCode();
boolean c1Flag = (c1 < "A".hashCode() || c1 > "Z".hashCode()); // 不是字母
boolean c2Flag = (c2 < "A".hashCode() || c2 > "Z".hashCode()); // 不是字母
if (c1Flag && !c2Flag) {
return 1;
} else if (!c1Flag && c2Flag) {
return -1;
}
return c1 - c2;
}
}
这是自定义的一个Comparator,首先我们获取首字母,因为排序是按照首字母来排序的,完后转换为大写,这是避免同样的首字母,因为大小写不同而排序,完后获取hashCode,也就是ascii码,比如A的ascii码为65,完后,我们判断是否是字母,这是为了处理特殊情况,比如123,或者特殊字符这样的联系人,这些联系人全部划分到#中,而在效果图中,#分组是在最下面的,所以在Comparator中,它们比所有的字母都要大,最后,如果两个都是字母,则直接通过ascii码值来进行比较即可。
排序完成后,我们就要来进行分组了,首先,我们来定义一个Contact对象,源码如下:
Contact.java
public class Contact implements Serializable {
private String mName;
private int mType;
public Contact(String name, int type) {
mName = name;
mType = type;
}
public String getmName() {
return mName;
}
public int getmType() {
return mType;
}
}
这个实体类很简单,就2个参数,联系人姓名和分类,这个分类就是我们上面定义的枚举中的类型,具体的联系人类型为ITEM_TYPE_CONTACT,而分组的字母以及#的类型为ITEM_TYPE_CHARACTER,注意,mContactList中是不包含字母和#的,所以我们定义了两个ArrayList,其中resultList用来放包括字母和#的最终的结果,而characterList用来放字母和#
循环排序后的mContactList,首先判读characterList中是否有这个字母,如果没有,则在characterList中加入,同时在resultList中加入上面定义的Contact对象,也就是分组的字母对象,而如果不是字母,那说明是特殊字符或者数字这些特殊情况,我们需要判断此时在characterList是否已经有#了,如果没有要加上,当然,也要在resultList中加上#的分组,最后,加上具体的联系人,经过这个操作后,resultList中就是我们最终的结果了。
接下来,在onCreateViewHolder中,我们根据枚举的类型,返回了两种不同的ViewHolder,两种布局文件如下:
item_character.xml
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/character"
android:layout_width="match_parent"
android:layout_height="40dp"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:paddingLeft="20dp"
android:background="#CCC"/>
item_contact.xml
<?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:padding="15dp"
android:gravity="center_vertical">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@mipmap/ic_launcher"/>
<TextView
android:id="@+id/contact_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="15dp"
android:textSize="20sp"
android:padding="5dp"
/>
</LinearLayout>
这都很简单,不多说,接下来,在onBindViewHolder方法中,我们就可以根据上面得到的resultList来赋值了。
完后,getItemViewType方法中,我们根据我们定义的枚举类型,来区分不同的布局。至于getItemCount和两个ViewHolder的定义,都很简单,也不说了。
最后,我们提供了一个getScrollPosition方法,这个方法是用来点击最右边的字母栏时进行滑动RecyclerView的,我们根据传进来的参数,也就是点击的具体字母或#,判断在characterList中是否存在,如果不存在,说明这个字母没有对应的联系人,直接返回-1,也就不会滑动RecyclerView,如果存在,则我们找出它具体的位置,也就是position返回,在MainActivity中就可以根据这个position来滑动RecyclerView了。
OK,整个的流程都分析完了,大家如果需要自己扩展,比如点击联系人能修改联系人的备注名称等等,在理解了的基础上都会比较简单了。