资讯详情

资讯详情

Android自定义View实现字母导航栏与RecyclerView联动

简介这是一份面向Android中高级开发者的自定义View实战资源聚焦字母导航栏这一常见交互组件的完整实现。资源围绕继承LinearLayout构建AlphabetNavigationBar类展开涵盖字母列表初始化、按钮动态创建、点击事件监听、选中状态更新以及与ListView或RecyclerView联动实现smoothScrollToPosition平滑滚动等核心环节适用于联系人列表、商店分类等需要快速定位的场景。压缩包共977个文件以462个xml布局与资源、302个png图片、126个json配置、40个class及11个java源码为主另含jar、aidl、apk等构建产物整体约6.66MB目录结构完整便于对照源码理解工程组织。目前已有422人学习下载。通过这份资源读者可掌握自定义View从布局设计、事件分发到列表联动的完整链路并借鉴悬浮提示、触摸手势滑动选字母等扩展思路提升界面交互开发能力。1. 字母导航栏不是画个竖条那么简单做过联系人列表的人都知道右侧那一列 A–Z 的字母导航栏看着不起眼真动手写才发现坑不少手指按下去字母要放大、要震动、要跟随滑动切换、要跟中间列表联动松手还要回弹到正确位置。很多开发者第一次写会直接拿 LinearLayout 堆 26 个 TextView结果滑动跟手性差、点击命中区域小、字母索引和列表滚动对不上最后只能推倒重来。Android 自定义 View 实现字母导航栏本质是把「触摸事件分发 坐标换算 绘制 与 RecyclerView 联动」这四件事串起来。它适合两类人一类是正在做通讯录、城市选择、商品分类这类带索引列表的开发者另一类是已经会写 onDraw但没系统练过 onTouchEvent 与列表联动配合的人。这篇笔记按我实际落地的顺序讲先定 View 的测量与绘制再处理触摸与索引计算然后接上列表联动最后把踩过的坑摊开说。全程用原生 View RecyclerView不依赖第三方库代码可以直接抄进项目改。2. 从测量到绘制把 26 个字母稳稳排进一列2.1 先想清楚这个 View 的尺寸由谁决定字母导航栏的宽度通常固定高度撑满父容器。常见做法是在 onMeasure 里对宽度做精确处理高度用父容器给的建议值。如果直接 wrap_content宽度会变成 0 或者被某个字母撑开导致点击区域忽宽忽窄。Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int widthMode MeasureSpec.getMode(widthMeasureSpec); int heightMode MeasureSpec.getMode(heightMeasureSpec); // 宽度优先用调用方指定的值否则用预设的默认宽度 int width; if (widthMode MeasureSpec.EXACTLY) { width MeasureSpec.getSize(widthMeasureSpec); } else { width dp2px(28); // 默认 28dp够手指点也不至于太宽 } // 高度撑满父容器给的空间 int height MeasureSpec.getSize(heightMeasureSpec); if (heightMode MeasureSpec.AT_MOST) { height Math.min(height, dp2px(26 * 16)); // 兜底防止极端情况 } setMeasuredDimension(width, height); }这里 width 走 EXACTLY 分支是为了让布局文件里能写死宽度height 直接取父容器尺寸因为导航栏一般 match_parent。dp2px 是常规换算注意别在 onMeasure 里做耗时操作它会被调用多次。2.2 onDraw 里每个字母的基线怎么算26 个字母均分高度每个格子的高度是getHeight() / 26。字母要垂直居中不能直接用格子顶部加 padding得用 Paint 的 FontMetrics 算基线。private static final String[] LETTERS { A,B,C,D,E,F,G,H,I,J,K,L,M, N,O,P,Q,R,S,T,U,V,W,X,Y,Z }; Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); int itemHeight getHeight() / LETTERS.length; Paint.FontMetrics fm textPaint.getFontMetrics(); // 基线偏移让文字在格子内垂直居中 float baselineOffset (itemHeight - (fm.bottom - fm.top)) / 2 - fm.top; for (int i 0; i LETTERS.length; i) { float centerX getWidth() / 2f; float baselineY i * itemHeight baselineOffset; // 当前选中项用高亮色其余用普通色 textPaint.setColor(i currentIndex ? highlightColor : normalColor); textPaint.setTextSize(i currentIndex ? selectedTextSize : normalTextSize); // 选中项要重新算一次基线因为字号变了 if (i currentIndex) { Paint.FontMetrics selFm textPaint.getFontMetrics(); baselineY i * itemHeight (itemHeight - (selFm.bottom - selFm.top)) / 2 - selFm.top; } float textWidth textPaint.measureText(LETTERS[i]); canvas.drawText(LETTERS[i], centerX - textWidth / 2f, baselineY, textPaint); } }关键点是选中项字号变化后FontMetrics 也跟着变必须重新算基线否则放大后的字母会往上飘。这个细节我第一次写的时候没注意选中字母总是偏上调了半天才发现是基线没重算。2.3 高亮背景用圆还是圆角矩形选中字母加个背景圆是常见交互。用canvas.drawCircle最简单半径取格子高度的一半再乘 0.7 左右避免圆太大压到相邻字母。if (i currentIndex) { float radius itemHeight * 0.7f / 2f; bgPaint.setColor(highlightBgColor); canvas.drawCircle(centerX, i * itemHeight itemHeight / 2f, radius, bgPaint); }注意绘制顺序先画背景圆再画文字否则圆会盖住字母。bgPaint 要开抗锯齿不然圆边缘有毛刺。如果设计稿要求圆角矩形把 drawCircle 换成 drawRoundRect圆角半径取半径值即可。3. 触摸事件与索引换算手指落在哪个字母上3.1 onTouchEvent 里三种动作分别做什么导航栏的触摸逻辑集中在 DOWN、MOVE、UP 三个动作。DOWN 确定初始索引并触发一次回调MOVE 持续更新索引UP 或 CANCEL 结束触摸状态。Override public boolean onTouchEvent(MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: case MotionEvent.ACTION_MOVE: isTouching true; float y event.getY(); // 边界保护手指滑出 View 范围时钳制到有效区间 y Math.max(0, Math.min(y, getHeight() - 1)); int index (int) (y / (getHeight() / (float) LETTERS.length)); index Math.max(0, Math.min(index, LETTERS.length - 1)); if (index ! currentIndex) { currentIndex index; if (listener ! null) { listener.onLetterChanged(LETTERS[index], index); } invalidate(); // 触发重绘更新高亮 } return true; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: isTouching false; if (listener ! null) { listener.onTouchEnd(); } invalidate(); return true; } return super.onTouchEvent(event); }这里有个容易翻车的点MOVE 时手指可能滑出 View 的上下边界event.getY() 会变成负数或大于高度不钳制的话索引会越界直接崩在数组访问上。钳制到[0, getHeight()-1]是最省事的做法。3.2 索引换算为什么用 float 除法getHeight() / (float) LETTERS.length这个写法必须带 float 强转。如果写成getHeight() / LETTERS.length两个 int 相除会先取整格子高度算出来偏小越往下偏差越大最后一个字母 Z 可能永远点不到。这是整数除法在自定义 View 里最典型的坑。另外索引计算用(int)截断而不是四舍五入是为了让每个格子的上半部分归当前字母符合手指按压的直觉。如果想让命中更宽松可以在截断前给 y 加半个格子高度但那样相邻字母的边界会偏移一般不加。3.3 回调接口怎么设计才够用导航栏和列表联动靠回调。接口至少要有三个方法字母变化、触摸结束、触摸开始。触摸开始可以用来显示中间的大字母提示浮层。public interface OnLetterTouchListener { // 手指按下或滑动到新字母时触发 void onLetterChanged(String letter, int index); // 手指抬起用于隐藏浮层、恢复列表滚动 void onTouchEnd(); }回调里不要直接做耗时操作比如查数据库、网络请求。字母变化触发频率很高MOVE 时每个像素都可能触发一次重活要放到列表侧做防抖。我一般会在列表侧记录上一次的字母相同就跳过滚动。4. 和 RecyclerView 联动滚动与高亮的双向同步4.1 点击字母让列表跳到对应位置列表侧拿到字母后用 LayoutManager 的 scrollToPositionWithOffset 跳转。前提是数据已经按字母排好序并且能快速查到每个字母首次出现的位置。// 在 Activity 或 Fragment 中 navigationBar.setOnLetterTouchListener(new OnLetterTouchListener() { Override public void onLetterChanged(String letter, int index) { int position sectionIndexer.getPositionForSection(letter.charAt(0)); if (position ! -1) { layoutManager.scrollToPositionWithOffset(position, 0); } } Override public void onTouchEnd() { // 手指抬起后可以恢复一些被暂停的逻辑 } });sectionIndexer 是自己维护的一个字母到位置的映射通常在数据排序后构建一次。用scrollToPositionWithOffset而不是scrollToPosition是因为前者能把目标项顶到列表顶部视觉上更符合「跳到这个字母」的预期。4.2 列表滚动时反向更新导航栏高亮列表滚动时导航栏也要跟着高亮当前可见的第一个字母。给 RecyclerView 加滚动监听取第一个可见项的位置反查它属于哪个字母。recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { Override public void onScrolled(NonNull RecyclerView rv, int dx, int dy) { int firstVisible layoutManager.findFirstVisibleItemPosition(); if (firstVisible RecyclerView.NO_POSITION) return; String letter sectionIndexer.getSectionForPosition(firstVisible); navigationBar.setCurrentLetter(letter); // 只更新高亮不触发列表滚动 } });这里必须区分「用户点导航栏触发的滚动」和「用户手动滚动列表」。如果 setCurrentLetter 里又回调 onLetterChanged就会形成死循环列表滚→导航栏高亮→导航栏回调→列表滚。常见做法是给导航栏加一个fromUser标志或者让 setCurrentLetter 只改内部状态不回调。4.3 中间大字母提示浮层的显示时机手指按住导航栏时屏幕中间弹出一个大字母这是提升体验的常见做法。浮层用 TextView 加背景圆放在根布局里默认 GONE。Override public void onLetterChanged(String letter, int index) { // 更新列表滚动 int position sectionIndexer.getPositionForSection(letter.charAt(0)); if (position ! -1) { layoutManager.scrollToPositionWithOffset(position, 0); } // 显示中间浮层 overlayText.setText(letter); overlayText.setVisibility(View.VISIBLE); } Override public void onTouchEnd() { overlayText.setVisibility(View.GONE); }浮层不要每次 onLetterChanged 都重新 inflate提前在布局里放好只改文字和可见性。频繁 inflate 会导致 MOVE 时掉帧手感发涩。5. 避坑与排查那些让我返工的细节5.1 字母点击不灵敏边缘总是点不到现象手指按在导航栏最上面或最下面A 和 Z 经常没反应。原因onTouchEvent 里没有对 y 做钳制或者钳制范围写成了[0, getHeight()]而索引计算用getHeight() / 26最后一个格子实际覆盖不到最底部。解决钳制范围用[0, getHeight() - 1]索引再夹一次[0, LETTERS.length - 1]。另外检查 View 的点击区域是否被父布局裁剪给导航栏加一点 padding 或者确保父布局不设 clipChildren。5.2 滑动时高亮跳字母中间漏掉几个现象手指快速滑动高亮从 C 直接跳到 FD 和 E 没触发。原因MOVE 事件本身是离散的快速滑动时两次事件之间 y 变化很大索引自然跳变。这不是 bug是事件采样率决定的。解决如果业务要求每个字母都触发可以在两次索引之间补一个循环逐个触发回调。但大多数场景不需要列表滚动本身也是跳的。我一般不做补间只在回调里做防抖避免重复滚动同一个位置。5.3 列表滚动和导航栏高亮互相打架现象手动滚动列表时导航栏高亮乱跳甚至列表自己滚回去。原因列表滚动监听里调用了导航栏的 setCurrentLetter而 setCurrentLetter 内部又触发了 onLetterChanged 回调回调里又调 scrollToPositionWithOffset。解决setCurrentLetter 只更新内部 currentIndex 并 invalidate不触发 listener。把「用户触摸导航栏」和「程序更新高亮」两条路径彻底分开用一个布尔标志或者两个独立方法区分。5.4 选中字母放大后位置偏移现象选中的字母比其它字母明显偏上或偏下。原因字号变化后 FontMetrics 变了基线没有重新计算还在用普通字号的偏移量。解决在 onDraw 里对选中项单独算一次基线用当前字号的 FontMetrics。这个坑在 2.2 节代码里已经处理核心就是「字号变基线必重算」。5.5 导航栏挡住列表右侧内容现象列表最后一个字或者滚动条被导航栏盖住。原因导航栏浮在列表上方列表没有预留右侧空间。解决给 RecyclerView 加android:paddingEnd或者clipToPaddingfalse配合 padding让列表内容避开导航栏宽度。如果导航栏是覆盖式设计列表 item 右侧留白即可别让文字顶到导航栏下面。6. 进阶技巧把导航栏做成可复用组件6.1 用自定义属性暴露可配置项硬编码颜色和字号不利于复用。在 attrs.xml 里定义属性让使用方在布局里直接配。declare-styleable nameLetterNavigationBar attr namenormalColor formatcolor / attr namehighlightColor formatcolor / attr namehighlightBgColor formatcolor / attr namenormalTextSize formatdimension / attr nameselectedTextSize formatdimension / attr nameletters formatstring / /declare-styleable构造函数里读取这些属性给默认值兜底。letters 属性可以传 ABCDEFG 这样的字符串支持自定义字母表比如城市选择只要热门城市首字母。6.2 支持触摸震动反馈手指滑到新字母时给一个短震动手感提升明显。用 Vibrator 或者 View 的 performHapticFeedback。// 在 onLetterChanged 触发处 if (index ! currentIndex) { performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK); }performHapticFeedback 不需要震动权限比 Vibrator 省事。CLOCK_TICK 是轻反馈适合字母切换这种高频操作。注意别在 DOWN 时也震只在索引真正变化时触发。6.3 用 SectionIndexer 统一管理字母与位置映射数据侧维护一个 SectionIndexer把「字母 → 首次出现位置」和「位置 → 所属字母」两个查询都封装起来。public class LetterSectionIndexer { private final String[] sections; private final int[] positions; public LetterSectionIndexer(ListContact data) { // 假设 data 已按字母排序 MapString, Integer firstPos new LinkedHashMap(); for (int i 0; i data.size(); i) { String letter data.get(i).getFirstLetter(); if (!firstPos.containsKey(letter)) { firstPos.put(letter, i); } } sections firstPos.keySet().toArray(new String[0]); positions new int[sections.length]; for (int i 0; i sections.length; i) { positions[i] firstPos.get(sections[i]); } } public int getPositionForSection(char c) { for (int i 0; i sections.length; i) { if (sections[i].charAt(0) c) return positions[i]; } return -1; } public String getSectionForPosition(int position) { String result sections[0]; for (int i 0; i positions.length; i) { if (position positions[i]) result sections[i]; else break; } return result; } }这个类在数据变化时重建一次查询是 O(n)字母表最多 26 个性能可以忽略。如果数据量特别大可以把线性查找换成二分但一般通讯录场景没必要。6.4 一个验证联动是否正确的笨办法写完联动后我会用「慢速滑动 快速甩动 点击边缘字母」三种操作各测十遍重点看三件事导航栏高亮和列表顶部字母是否一致、松手后高亮是否停在正确位置、快速滑动有没有漏字母导致列表跳错。这个笨办法帮我抓出过好几次索引越界和回调死循环。我自己的习惯是导航栏和列表的联动逻辑一定单独抽一个类来管不写在 Activity 里。Activity 只负责把导航栏、列表、索引器三者接起来具体换算和状态判断都下沉。这样下次换个页面复用改的只是数据源联动逻辑一行不用动。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →