arvinljw

  • 主页
  • Android
  • Java
  • Python
  • 技术杂谈
所有文章 关于我

arvinljw

  • 主页
  • Android
  • Java
  • Python
  • 技术杂谈

Android学习之联系人列表

2018-07-12

对于联系人列表,几乎也是比较常见的功能,说简单吧,好像又不是一下就能写完的,今天终于抽空开始完善这个功能点。整个过程比较重要的技术点:

  • 通过ContentProvider获取联系人数据
  • SideBar导航的绘制以及联动列表
  • 粘性头部列表的实现

效果

废话不说,先看效果~

sample.gif

注:其中为了不泄漏好友的联系方式,手机号全被替换了

目前这样的效果,对于列表的功能基本差不多了,对于进一步的功能包括:

  • 联系人的添加和删除
  • 搜索联系人
  • 粘性头部对于GridLayoutManager的适配

但是这两个功能不影响这次联系人列表的实现。

功能

不管在做任何事,我们都要确定我们要做的是什么,那么我们先来看一下这次需要实现的功能包括哪些:

  • 获取联系人数据,并按照拼音顺序排列
  • SideBar绘制并滑动时联动列表移动到相应位置
  • 粘性头部列表

实现方案

  • 使用ContentProvider获取数据,其中包含排序
  • 自定义View,绘制字母,重写onTouchEvent事件,监听滑动到哪个位置并提供回调
  • 列表使用RecyclerView实现,粘性头部使用RecyclerView.ItemDecoration实现

在做实现上边的功能之前,第一个我以前在项目中做过,第二个虽然没有做过但是对于自定义View这个部分看起来还是比较容易的。第三个,虽然以前有了解过ItemDecoration但是没有实际操作过,缺乏经验,但是好在网上已有不少解决方案,其中我参考了这些文章:

RecyclerView探索之通过ItemDecoration实现StickyHeader效果

RecyclerView 悬浮/粘性头部——StickyHeaderDecoration

【Android】RecyclerView:打造悬浮效果

首先感谢这几位作者的分享。

实现

ContentProvider获取联系人数据

其实对于ContentProvider来说,是系统提供的用于应用之间共享数据使用的方式,获取数据的方式就像执行sql语句和访问服务器接口一样,将目标地址确定,再增加查询条件,正确的访问就能得到想要的数据。

这篇重点不在如何获取数据,所以就直接上代码了(之后也会有文章介绍ContentProvider的详细使用,这部分的内容也比较多)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class ContactHelper {
private static final String[] PROJECTION = {
ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME,
ContactsContract.CommonDataKinds.Phone.NUMBER,
ContactsContract.CommonDataKinds.Phone.SORT_KEY_PRIMARY,
};

public static List<ContactEntity> getContacts(Context context) {
List<ContactEntity> list = new ArrayList<>();
ContentResolver cr = context.getContentResolver();
Cursor cursor = null;
try {
cursor = cr.query(ContactsContract.CommonDataKinds.Phone.CONTENT_URI, PROJECTION,
null, null, ContactsContract.CommonDataKinds.Phone.SORT_KEY_PRIMARY);
HashMap<String, ContactEntity> contactIdMap = new HashMap<>();
if (cursor != null) {
cursor.moveToFirst(); // 游标移动到第一项
for (int i = 0; i < cursor.getCount(); i++) {
cursor.moveToPosition(i);
//对应PROJECTION中name和number的位置
String name = cursor.getString(0);
String number = cursor.getString(1);
if (!contactIdMap.containsKey(number)) {
ContactEntity contact = new ContactEntity();
contact.setName(name);
contact.setPinyinName(HanziToPinyin.getPinYin(name));
contact.setPhoneNum(number);
list.add(contact);
contactIdMap.put(number, contact);
}
}
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if (cursor != null) {
cursor.close();
}
}
return list;
}
}

也就是说通过getContacts的方法就能获取到联系人数据了,并且是按照名字拼音排序。

自定义SideBar

自定义View三部曲,先明确有哪些属性需要自定义,然后重写onMeasure方法,然后重写onDraw方法,这样就把样式基本确定了。当然这里涉及到了事件操作所以还加了一步重写onTouchEvent方法。最后就是直接在布局中引入即可。

属性定义

话不多说哦,直接上代码,简洁明了。

1
2
3
4
5
<declare-styleable name="SideBarView">
<attr name="sidebar_letter_size" format="dimension"/>
<attr name="sidebar_letter_color" format="color|reference"/>
<attr name="sidebar_letter_selected_color" format="color|reference"/>
</declare-styleable>

可以有看到,包括了,字母的大小,颜色,以及字母被选中时的颜色。

然后在代码中获取属性。

1
2
3
4
5
6
7
8
9
10
11
public SideBarView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);

TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.SideBarView);
letterSize = typedArray.getDimension(R.styleable.SideBarView_sidebar_letter_size, sp2px(context, DEFAULT_SIZE));
letterColor = typedArray.getColor(R.styleable.SideBarView_sidebar_letter_color, DEFAULT_COLOR);
letterSelectedColor = typedArray.getColor(R.styleable.SideBarView_sidebar_letter_selected_color, DEFAULT_SELECTED_COLOR);
typedArray.recycle();

//其他代码...
}

方法重写

onMeasure方法

主要就是需要注意在使用wrap_content时的大小。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int defaultWidth = (int) (letterSize + getPaddingLeft() + getPaddingRight());
int defaultHeight = (int) (letterSize * LETTERS.length);
setMeasuredDimension(getDefaultSize(defaultWidth, widthMeasureSpec), getDefaultSize(defaultHeight, heightMeasureSpec));
}

public static int getDefaultSize(int size, int measureSpec) {
int result = size;
int specMode = MeasureSpec.getMode(measureSpec);
int specSize = MeasureSpec.getSize(measureSpec);
switch (specMode) {
case MeasureSpec.UNSPECIFIED:
case MeasureSpec.AT_MOST:
result = size;
break;
case MeasureSpec.EXACTLY:
result = specSize;
break;
}
return result;
}

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
letterHeight = (getHeight() - getPaddingTop() - getPaddingBottom()) / LETTERS.length;
}

其中在onSizeChanged的时候计算每一个字母的高度,这里也不在细说onMeasure的重写方法,具体可以学习一下扔物线大神的文章:

HenCoder UI 部分 2-1 布局基础

HenCoder UI 部分 2-2 全新定义 View 的尺寸

onDraw方法

首先这里是把所有字母都显示出来了,在有数据的时候去匹配,只有当这个字母存在是才会存在有效的联动。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
for (int i = 0; i < LETTERS.length; i++) {
if (selectLetterPos == i && letterMap.get(LETTERS[i])) {
paint.setColor(letterSelectedColor);
} else {
paint.setColor(letterColor);
}
float xPos = (getWidth() - paint.measureText(LETTERS[i])) / 2;
float yPos = letterHeight * i + (getStatusBarHeight(getContext()) + letterHeight - paint.measureText(LETTERS[i])) / 2;
canvas.drawText(LETTERS[i], xPos, yPos, paint);
}
}

可以看到其实就是一个文本的绘制,每个字母在每个等分的高度中居中绘制。

onTouchEvent方法

这里其实就是主要监听ACTION_MOVE事件,然后计算y的大小,判断是在字母数组的哪个位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
@Override
public boolean onTouchEvent(MotionEvent event) {
float x = event.getX();
float y = event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
downX = x;
downY = y;
if (x >= 0 && x <= getWidth()) {
intercept = true;
chooseLetter(y);
}
break;
case MotionEvent.ACTION_MOVE:
if (intercept) {
chooseLetter(y);
}
break;
case MotionEvent.ACTION_UP:
if (Math.abs(x - downX) < touchSlop && Math.abs(y - downY) < touchSlop) {
performClick();
}
resetStatus();
break;
case MotionEvent.ACTION_CANCEL:
resetStatus();
break;
}
return intercept;
}

private void resetStatus() {
downX = 0;
downY = 0;
intercept = false;
if (onLetterSelectListener != null) {
//表示取消
onLetterSelectListener.onLetterSelected("", -1);
}
}

private void chooseLetter(float y) {
int pos = (int) (y / letterHeight);
if (pos >= 0 && pos < LETTERS.length && selectLetterPos != pos) {
Log.w(TAG, LETTERS[pos]);
selectLetterPos = pos;
invalidate();
if (onLetterSelectListener != null) {
onLetterSelectListener.onLetterSelected(LETTERS[pos], pos);
}
}
}

@Override
public boolean performClick() {
return super.performClick();
}

这里代码比较长,核心逻辑其实就是在chooseLetter方法中,表示是哪个字母在按下和滑动时被选中。

注:同时重写了performClick方法,虽然这里用不到点击方法,但是也实现一下正确的重写方案,因为重写了onTouchEvent方法没有去重写performClick和在其中调用performClick方法的话就会让使用无障碍功能是点击失效。因为这个在实现无障碍功能时遇到过这样的坑,最后使用adb命令解决

这样基本就实现了这个自定义View,其中还提供了滑动到某个字母的回调。

布局使用

1
2
3
4
5
6
7
8
9
10
11
12
<net.arvin.androidstudy.contentprovider.contact.SideBarView
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/sidebar"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:background="@android:color/white"
android:paddingLeft="4dp"
android:paddingRight="4dp"
app:sidebar_letter_color="@android:color/darker_gray"
app:sidebar_letter_selected_color="@color/colorPrimaryDark"
app:sidebar_letter_size="12sp"
/>

这个地方没啥好说的。

粘性头部实现

ItemDecoration

上边提到的题篇文章说的挺不错的了,其中有张图特别有用,借用一下:

层级关系

这四个部分是对界面展示有影响的,背景在最底层,上层布局的会覆盖。其中重要的是在ItemDecoraction中onDraw方法和onDrawOver方法把RecyclerView的itemView夹在中间,也就是说,itemView会覆盖onDraw绘制的内容,onDrawOver会覆盖itemView的内容。还有一个重要的概念就是ItemDecoraction包含了一个偏移量,可以使得itemView不遮挡onDraw绘制的内容,也可以使得onDrawOver不遮挡itemView的内容。

概念逻辑性的东西说完之后,再来看实现。

实现

实现逻辑比不难,细想一下,可以知道其实就是将数据分组,在分组的第一个需要加上绘制的header,然后为了让header悬停的话,屏幕上的第一个itemView也肯定需要绘制header。所以我们定义的偏移量就应该是header的大小,然后就是绘制header,按照这个逻辑去实现,发现header被另一个header替换的时候并不是顶上去的,而是被覆盖了。样子可以去这里看,可能有防盗链,直接引用看不到。

这时候其实只需要再加一个逻辑,就是如果这个item是屏幕的第一个也是这个分组的最后一个时,它的header高度应该在滑动中逐渐减小。核心逻辑就是header的绘制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
@Override
public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
super.onDrawOver(c, parent, state);
//因为RecyclerView是复用item的,所以这个数量就是屏幕内能显示出来的item的数量
int childCount = parent.getChildCount();
for (int i = 0; i < childCount; i++) {
drawHeaderRect(c, parent, i);
}
}

/**
* @param c 画布
* @param parent recyclerView
* @param pos 屏幕中itemView的位置
*/
private void drawHeaderRect(Canvas c, RecyclerView parent, int pos) {
if (mCallback == null) {
return;
}
View view = parent.getChildAt(pos);
int realPos = parent.getChildAdapterPosition(view);
GroupInfo groupinfo = mCallback.getGroupInfo(realPos);
int left = parent.getPaddingLeft();
int right = parent.getWidth() - parent.getPaddingRight();
int top;
int bottom;
if (pos != 0) {
if (groupinfo.isFirstViewInGroup()) {//如果是分组的第一个
top = view.getTop() - mHeaderHeight;
bottom = view.getTop();
} else {
//不是屏幕的第一个并且不是分组的第一个就不需要绘制header
return;
}
} else {
top = parent.getPaddingTop();//如果是屏幕中的第一个,就应该在父容器的顶部
if (groupinfo.isLastViewInGroup()) {//如果这时候它又是这个分组的最后一个,他就会被下一个分组顶上去
int realTop = view.getBottom() - mHeaderHeight;
if (realTop <= top) {
top = realTop;
}
}
bottom = top + mHeaderHeight;
}
View sectionView = mCallback.getSectionView(realPos);
sectionView.measure(View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED));
mHeaderHeight = sectionView.getMeasuredHeight();
sectionView.setDrawingCacheEnabled(true);
sectionView.layout(left, top, right, bottom);
c.drawBitmap(sectionView.getDrawingCache(), left, top, null);
}

而绘制的核心就是在于上下左右位置的计算,这个就和自定义ViewGroup中重写layout方法类似。其中核心的判断点都有注释。

这样大体功能基本实现。其中还有一些小技巧哦,在分组和滑动联动时有用到。

其他技巧

分组信息

1
2
3
4
5
6
7
8
9
10
11
12
13
private void groupInfo() {
SideBarView.clearLetterMap();
index = new HashMap<>();
keys = new ArrayList<>();
for (int i = 0; i < items.size(); i++) {
String ch = dealLetter(i);
if (!index.containsKey(ch)) {
index.put(ch, i);
SideBarView.putLetterIn(ch);
keys.add(ch);
}
}
}

借助HashMap,将联系人姓名拼音的字母和在所有数据中的位置对应起来,然后使用List将联系人数据中出现的字母保存起来,可直接找到当前字母的上一个和下一个字母是什么。其中用到了汉字转拼音的工具类,借用了张涛大神总结的工具类。同时也把SideBarView中的所有字母是否在联系人数据中是否出现设置了。

分组中保存了分组的标题文本,在分组中的位置,以及分组的长度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Override
public GroupInfo getGroupInfo(int position) {
String id = dealLetter(position);
GroupInfo info = new GroupInfo(id);
info.setPosition(position - index.get(id));
int i = keys.indexOf(id);
if (i < keys.size() - 1) {
String str = keys.get(i + 1);
info.setGroupLength(index.get(str) - index.get(id));
} else {
info.setGroupLength(items.size() - index.get(id));
}
return info;
}

联动–列表滚动到指定项

有两种方式:参考了这篇文章

没有动画滚动
1
2
3
contactList.scrollToPosition(pos);
LinearLayoutManager mLayoutManager = (LinearLayoutManager) contactList.getLayoutManager();
mLayoutManager.scrollToPositionWithOffset(pos, 0);

这种方式比较简单。

动画平滑滚动

这种方式复杂一些。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
* 1、顺滑的滑动到指定位置
*/
private void smoothMoveToPosition(RecyclerView mRecyclerView, final int position) {
int firstItem = mRecyclerView.getChildLayoutPosition(mRecyclerView.getChildAt(0));
int lastItem = mRecyclerView.getChildLayoutPosition(mRecyclerView.getChildAt(mRecyclerView.getChildCount() - 1));
if (position < firstItem) {
//在屏幕上方,直接滚上去就是顶部
mRecyclerView.smoothScrollToPosition(position);
} else if (position <= lastItem) {
//在屏幕中,直接滚动到相应位置的顶部
int movePosition = position - firstItem;
if (movePosition >= 0 && movePosition < mRecyclerView.getChildCount()) {
//粘性头部,会占据一定的top空间,所以真是的top位置应该是减去粘性header的高度
int top = mRecyclerView.getChildAt(movePosition).getTop() - sectionDecoration.mHeaderHeight;
mRecyclerView.smoothScrollBy(0, top);
}
} else {
//在屏幕下方,需要西安滚动到屏幕内,在校验
mRecyclerView.smoothScrollToPosition(position);
mToPosition = position;
mShouldScroll = true;
}
}

//2、设置滚动坚挺,调整位置
contactList.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
super.onScrollStateChanged(recyclerView, newState);
//其他代码...

//校验流畅的滚动的位置
if (mShouldScroll && newState == RecyclerView.SCROLL_STATE_IDLE) {
mShouldScroll = false;
smoothMoveToPosition(recyclerView, mToPosition);
}
}
}

通过这两步配置,在使用时直接调用smoothMoveToPosition方法,即可平滑的滚动到指定项。

当然还有一个屏幕中的那个文本的显示,也完全是在sidebar联动回调和列表滚动监听中获取当前的要显示的文本作为展示。就不多说了。

总结

对于这个功能的实现,虽然不难,但是包含的知识点还是不少,个人觉得通过这样的方式,对于我的提升是很有帮助的,不在对于每个知识点无从下手,要把知识运用到实际中去才是硬道理。

最后再次附上项目代码,这个项目会包含很多功能点,对于不同的功能点都在不同的包中。

源码

  • Android

扫一扫,分享到微信

微信分享二维码
MySQL基础语句
git常用操作
  1. 1. 效果
    1. 1.1. 功能
    2. 1.2. 实现方案
  2. 2. 实现
    1. 2.1. ContentProvider获取联系人数据
    2. 2.2. 自定义SideBar
      1. 2.2.1. 属性定义
      2. 2.2.2. 方法重写
        1. 2.2.2.1. onMeasure方法
        2. 2.2.2.2. onDraw方法
        3. 2.2.2.3. onTouchEvent方法
      3. 2.2.3. 布局使用
    3. 2.3. 粘性头部实现
      1. 2.3.1. ItemDecoration
      2. 2.3.2. 实现
    4. 2.4. 其他技巧
      1. 2.4.1. 分组信息
      2. 2.4.2. 联动–列表滚动到指定项
        1. 2.4.2.1. 没有动画滚动
        2. 2.4.2.2. 动画平滑滚动
  3. 3. 总结
Like Issue Page
Error: Comments Not Initialized
Login with GitHub
Styling with Markdown is supported
Powered by Gitment
© 2019 arvinljw
Hexo Theme Yilia by Litten
  • 所有文章
  • 关于我

tag:

  • Android
  • 项目经验
  • Java
  • 《Effective Java》
  • 开源库
  • 源码分析
  • 《Thinking in Java》
  • 技术杂谈
  • sql
  • FFmpeg交叉编译
  • Python
  • Scrapy爬虫
  • 数据分析
  • git操作

    缺失模块。
    1、请确保node版本大于6.2
    2、在博客根目录(注意不是yilia根目录)执行以下命令:
    npm i hexo-generator-json-content --save

    3、在根目录_config.yml里添加配置:

      jsonContent:
        meta: false
        pages: false
        posts:
          title: true
          date: true
          path: true
          text: false
          raw: false
          content: false
          slug: false
          updated: false
          comments: false
          link: false
          permalink: false
          excerpt: false
          categories: false
          tags: true
    

  • Mac下交叉编译FFmpeg3.4.5出Android的so包

    2019-01-10

    #技术杂谈#FFmpeg交叉编译

  • Android开源之PermissionHelper

    2018-09-19

    #Android#开源库

  • Java之类和接口

    2018-09-03

    #Java#《Effective Java》

  • Android JNI开发系列之Java与C相互调用

    2018-08-31

    #Android

  • Android JNI开发系列之配置

    2018-08-29

    #Android

  • Android学习之RecyclerView.ItemDecoration实践

    2018-07-26

    #Android#开源库

  • MySQL基础语句

    2018-07-18

    #技术杂谈#sql

  • Android学习之联系人列表

    2018-07-12

    #Android

  • git常用操作

    2018-07-04

    #技术杂谈#git操作

  • Android学习之ConstraintLayout

    2018-06-26

    #Android

  • Java之字符串

    2018-06-25

    #Java#《Thinking in Java》

  • Java之类的通用方法

    2018-06-23

    #Java#《Effective Java》

  • Python抓取QQ音乐歌单并分析

    2018-06-20

    #Python#Scrapy爬虫#数据分析

  • Java之接口和内部类

    2018-06-14

    #Java#《Thinking in Java》

  • Java之多态

    2018-06-12

    #Java#《Thinking in Java》

  • Python生成词云

    2018-06-11

    #Python

  • Java之复用类

    2018-06-06

    #Java#《Thinking in Java》

  • Java之操作符、流程控制、初始化和清理以及访问权限控制

    2018-06-05

    #Java#《Thinking in Java》

  • Java之对象的创建和销毁

    2018-06-03

    #Java#《Effective Java》

  • Java之一切皆对象

    2018-05-30

    #Java#《Thinking in Java》

  • Android源码分析之PhotoView

    2017-12-28

    #Android#源码分析

  • Android开源之SocialHelper

    2017-12-01

    #Android#开源库

  • Android动态更换桌面图标

    2017-10-10

    #Android

  • Android之ViewPager使用

    2017-06-05

    #Android

  • Android代码混淆

    2017-04-10

    #Android

  • Android消息机制

    2017-02-20

    #Android

  • Android之Activity总结

    2016-11-19

    #Android

热爱Android开发
喜欢学习新技术...

有问题可以给我发邮件~