Android 使用RecyclerView优雅实现悬浮标题通讯录
项目地址:https://github.com/hgDendi/ContactsList
界面概览:
概要
如图,主要简单划分为两个部分:
数据源、与界面组件。
数据源主要来自手机的通讯录信息,通过ContentResolver获取。
而界面组件主要有显示列表和侧边栏。而重点在于列表的分组栏的绘制与现实,这就依靠ItemDecoration来进行实现了,这也是难点。
复用方法
FloatingBarItemDecoration传入需要绘制标题栏的position和标题String的map,目前只支持竖项、单列的列表,如需要扩展,请读完此文,明白原理后很容易实现。
IndexBar传入Label的List,通过setListener加入勾子。
FloatingBarItemDecoration
An ItemDecoration allows the application to add a special drawing and layout offset to specific item views from the adapter's data set. This can be useful for drawing dividers between items, highlights, visual grouping boundaries and more. ItemDecoration主要是用来对RecyclerView进行一些修饰,是对adapter数据集中的数据视图增加修饰或空位。经常被用来画分割线、强调效果、可见的分组边界等。
getItemOffset()
@Override public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) { super.getItemOffsets(outRect, view, parent, state); int position = ((RecyclerView.LayoutParams)) view.getLayoutParams()).getViewAdapterPosition(); outRect.set(0, mList.containsKey(position) ? mTitleHeight : 0, 0, 0); }
绘制间距,为绘制标题栏空出间隙。主要逻辑是通过当前view的position判断是否需要在上方空出矩形范围。
onDraw()
主要是进行静态标题栏等绘制,即在每组view的上方,即getItemOffset()的区域进行标题栏的绘制。
@Override public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) { super.onDraw(c, parent, state); 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(); int position = params.getViewAdapterPosition(); if (!mList.containsKey(position)) { continue; } drawTitleArea(c, left, right, child, params, position); } }
onDrawOver
实现悬浮分组栏,以及悬浮分组栏碰撞效果绘制。
对于整个列表的绘制流程,是遵循如下的顺序:
ItemDecoration#onDraw() -> ItemView的绘制 -> ItemDecoration#onDrawOver
故而在onDrawOver中实现可以满足“悬浮”,即在最上层的效果。
@Override public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) { super.onDrawOver(c, parent, state); final int position = ((LinearLayoutManager) parent.getLayoutManager()).findFirstVisibleItemPosition(); if (position == RecyclerView.NO_POSITION) { return; } View child = parent.findViewHolderForAdapterPosition(position).itemView; String initial = getTag(position); if (initial == null) { return; } //flag指示当标题栏是否发生碰撞(如开头gif图中指示的) boolean flag = false; if (getTag(position + 1) != null && !initial.equals(getTag(position + 1))) { if (child.getHeight() + child.getTop() < mTitleHeight) { //与restore()对应,表示下面translate平移坐标系只对绘制当前标题栏生效 c.save(); flag = true; //translate使发生碰撞时,两个标题栏紧贴,制造出挤开的效果(dy<0,表示绘制偏下) c.translate(0, child.getHeight() + child.getTop() - mTitleHeight); } } c.drawRect(parent.getPaddingLeft(), parent.getPaddingTop(), parent.getRight() - parent.getPaddingRight(), parent.getPaddingTop() + mTitleHeight, mBackgroundPaint); c.drawText(initial, child.getPaddingLeft() + mTextStartMargin, parent.getPaddingTop() + mTitleHeight - (mTitleHeight - mTextHeight) / 2 - mTextBaselineOffset, mTextPaint); if (flag) { c.restore(); } }
IndexBar
IndexBar是侧边栏的实现,是采用的自定义View的形式。
FontMatrics
在此之前,介绍一个概念FontMatrics,是表征字体的一个矩阵。
定义BaseLine为Text的起始点(类似英文五线谱的baseline)
drawText传入的纵坐标值也为BaseLine所在的纵坐标,而非矩形区域的左下角的纵坐标(这点很重要,否则在开发者模式中开启布局边界会发现字体和边界错乱)
主要有以下几个属性:
- Top (<0)
- Ascent可能的最小值(绝对值最大)
- Ascent (<0)
- 字体最高处距BaseLine的距离
- Descent (>0)
- 字体最低处距BaseLine的距离
- Bottom (>0)
- Descent可能的最大值
- Leading
- 间距,用于多行文字显示时的距离
在此例中我们用来计算每个text的高度,以此作为测量View高度的参数。很多时候可以选择不加leanding值, 因为单行多行时候的leading值都为0.(不知道什么时候可以取到非0的值)
Paint.FontMetrics fm = mPaint.getFontMetrics(); float singleHeight = fm.bottom - fm.top + fm.leading;
onMeasure()
计算View的长宽。
@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); setMeasuredDimension(measureWidth(widthMeasureSpec), measureHeight(heightMeasureSpec)); } private int measureWidth(int widthMeasureSpec) { int result; int specMode = MeasureSpec.getMode(widthMeasureSpec); int specSize = MeasureSpec.getSize(widthMeasureSpec); if (specMode == MeasureSpec.EXACTLY) { result = specSize; } else { result = getSuggestedMinWidth(); if (specMode == MeasureSpec.AT_MOST) { result = Math.min(result, specSize); } } return result; } //获取建议的最小宽度,尽量保证不会出现显示不下的情况(极端情况下仍会显示不下) private int getSuggestedMinWidth() { String maxLengthTag = ""; for (String tag : mNavigators) { if (maxLengthTag.length() < tag.length()) { maxLengthTag = tag; } } return (int) (mPaint.measureText(maxLengthTag) + 0.5); } private int measureHeight(int heightMeasureSpec) { int result; int specMode = MeasureSpec.getMode(heightMeasureSpec); int specSize = MeasureSpec.getSize(heightMeasureSpec); if (specMode == MeasureSpec.EXACTLY) { result = specSize; } else { Paint.FontMetrics fm = mPaint.getFontMetrics(); float singleHeight = fm.bottom - fm.top + fm.leading; //这个mLetterSpacingExtra是疏密程度,是自定义属性,默认1.4 mBaseLineHeight = fm.bottom * mLetterSpacingExtra; result = (int) (mNavigators.size() * singleHeight * mLetterSpacingExtra); if (specMode == MeasureSpec.AT_MOST) { result = Math.min(result, specSize); } } return result; }
onDraw()
负责绘制
protected void onDraw(Canvas canvas) { super.onDraw(canvas); int height = getHeight(); int width = getWidth(); //高度为0,可能是因为传入参数为空,则不予显示 if (height == 0) { return; } int singleHeight = height / mNavigators.size(); //遍历绘制Text for (int i = 0; i < mNavigators.size(); i++) { float xPos = width / 2 - mPaint.measureText(mNavigators.get(i)) / 2; float yPos = singleHeight * (i + 1); if (i == mFocusIndex) { canvas.drawText(mNavigators.get(i), xPos, yPos - mBaseLineHeight, mFocusPaint); } else { canvas.drawText(mNavigators.get(i), xPos, yPos - mBaseLineHeight, mPaint); } } }
DispatchTouchEvent()
处理交互事件,主要是监听UP、CANCEL、DOWN、MOVE,其中以DOWN做为起点,CANCEL、UP做为终点,其他为中间状态。以TAG的焦点变更和事件的开始、结束做为重绘的触发点。
@Override public boolean dispatchTouchEvent(MotionEvent event) { final float y = event.getY(); final int formerFocusIndex = mFocusIndex; final OnTouchingLetterChangeListener listener = mOnTouchingLetterChangeListener; final int c = calculateOnClickItemNum(y); switch (event.getAction()) { case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: mFocusIndex = -1; invalidate(); listener.onTouchingEnd(mNavigators.get(c)); break; case MotionEvent.ACTION_DOWN: listener.onTouchingStart(mNavigators.get(c)); default: if (formerFocusIndex != c) { if (c >= 0 && c < mNavigators.size()) { listener.onTouchingLetterChanged(mNavigators.get(c)); mFocusIndex = c; invalidate(); } } break; } return true; } /** * @param yPos * @return the corresponding position in list */ private int calculateOnClickItemNum(float yPos) { int result; //计算当前触摸点属于哪个TAG,超出边界按照边界值返回(尤其在MOVE的时候很容易滑出边界) result = (int) (yPos / getHeight() * mNavigators.size()); if (result >= mNavigators.size()) { result = mNavigators.size() - 1; } else if (result < 0) { result = 0; } return result; }
ContactsUtils
主要是负责获得缩写,其中英文字符就直接获得英文字符,中文字符通过比对GB2312得到英文缩写
对于中文获得缩写的核心思想如下,是通过比对GB2312值得到中文中声母,继而获得缩写情况。
//GB2312中简体中文的起止,判断范围 private static int BEGIN = 45217; private static int END = 63486; /** * 各声母第一个汉字 * {i、u、v} 不做声母 */ private static char[] chartable = {'啊', '芭', '擦', '搭', '蛾', '发', '噶', '哈', '击', '喀', '垃','妈', '拿', '哦', '啪', '期', '然', '撒', '塌', '挖', '昔', '压', '匝'}; private static char[] initialtable = {'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K','L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'W', 'X', 'Y', 'Z'}; //此table是各声母对应的起始GB值,与initialtable对应 private static int[] table = new int[chartable.length + 1]; static { for (int i = 0; i < chartable.length; i++) { table[i] = gbValue(chartable[i]); } table[chartable.length] = END; } //计算char对应的gb值 private static int gbValue(char ch) { String str = "" + ch; try { byte[] bytes = str.getBytes("GB2312"); if (bytes.length < 2) { return 0; } return (bytes[0] << 8 & 0xff00) + (bytes[1] & 0xff); } catch (Exception e) { return 0; } }
ContactsManager
负责通讯录信息的获取,此处只取了电话号码和联系人名称,使用的是ContentResolver进行查询
@NonNull public static ArrayList<ShareContactsBean> getPhoneContacts(Context mContext) { ArrayList<ShareContactsBean> result = new ArrayList<>(0); ContentResolver resolver = mContext.getContentResolver(); Cursor phoneCursor = resolver.query(ContactsContract.CommonDataKinds.Phone.CONTENT_URI, new String[]{ContactsContract.CommonDataKinds.Phone.NUMBER, ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME}, null, null, null); if (phoneCursor != null) { while (phoneCursor.moveToNext()) { String phoneNumber = phoneCursor.getString(0).replace(" ", ""); String contactName = phoneCursor.getString(1); result.add(new ShareContactsBean(contactName, phoneNumber)); } phoneCursor.close(); } //对结果进行排序,这个排序方法写在bean中 Collections.sort(result, new Comparator<ShareContactsBean>() { @Override public int compare(ShareContactsBean l, ShareContactsBean r) { return l.compareTo(r); } }); return result; }