Android 自定义 View 实战:200 行代码实现通讯录字母导航栏 简介面向有一定安卓基础的开发者通过实现字母导航栏掌握自定义View的完整流程适用于通讯录、城市列表、商品分类等快速索引场景也可作为课程设计或毕设选题的参考。压缩包仅6.66MB共977个文件以XML布局、PNG图片、JSON配置为主同时包含Java源码、class字节码、jar库、AIDL接口及可直接运行的APK示例工程结构相当完整便于按需取用。已有423人学习下载。内容从继承LinearLayout创建AlphabetNavigationBar开始覆盖字母列表生成、按钮点击监听、构造函数初始化等核心步骤并实现与ListView/RecyclerView的联动借助getPositionForSection和smoothScrollToPosition完成快速定位同时涉及View的测量、布局、绘制等生命周期要点并提供悬浮提示、触摸滑动等扩展思路。开发者可复用源码结构与实现逻辑快速迁移到联系人、城市索引等实际项目中提升列表交互体验。1. 用 200 行代码解决通讯录滑不到底的老大难手机通讯录里存了八百多个联系人每次想找“张”开头的名字拇指在列表上反复搓都搓不到目标区域搓到手指发酸还是错过。这不是手机性能问题是列表缺少一个精准定位的锚点。字母导航栏A-Z index bar就是贴在右侧的那一列 26 个字母按下“Z”直达 Z 区松手列表停住。这个项目就是一套 Android 自定义 View 的完整实现不依赖第三方库一个 Kotlin 类继承 View重写 onTouchEvent 完成按下、滑动、松开的整条链路再通过一个回调接口把当前字母抛给 RecyclerView 去 smoothScrollToPosition。适合两类人照抄一是应用里需要联系人、城市选择、歌曲列表这类长列表索引进来的业务开发二是想把自定义 View 的 measure、draw、touch 三件套一次过明白的进阶学习者。2. 架构设计继承 View 重绘还是组合 ViewGroup2.1 技术选型三种自定义 View 路线的取舍实现字母导航栏业界常见有三条路线取舍点分别在绘制成本、事件分发难度和灵活性上。第一种是直接继承 View在 onDraw 里把 26 个字母逐个画出来。这是最“原生”的做法也是本项目选的路线。它的好处是整块区域完全由自己掌控宽高、字母颜色、按压背景、圆角矩形都能在 draw 阶段精确控制触摸事件也只有这一个 View 在响应不存在事件被子 View 拦截的问题。缺点是所有字母的点击区域判定都要自己算但这恰恰是学习 hit test碰撞检测的好素材。第二种是继承 ViewGroup里面放 26 个 TextView靠 layout 摆放位置。这种写法的上手门槛低TextView 自带文字居中触摸时通过 child 的 bounds 判断点击了哪个。但问题也明显26 个子 View 意味着 26 次 measure 和 layout性能上有冗余而且当你想给导航栏加按压变色、粘性气泡这类效果时要处理的位置关系比直接绘制复杂得多。第三种是组合 View把字母导航栏作为 RecyclerView 的 header/footer 或者悬浮层来加不重写绘制只是把系统控件摆个造型。这个方案只适合“看起来像导航栏”的静态展示交互体验基本谈不上。所以我在拆这个项目时最重要的是看清它的核心思路一个 View一管 Paint一组字母坐标计算外加四个触摸事件分支。没有层级嵌套没有资源文件依赖逻辑链非常干净。2.2 项目结构与主流程一个类撑起整条交互链路整个工程的结构很小但麻雀虽小五脏俱全拆开看是这样的app/ ├── src/main/ │ ├── java/com/example/indexbar/ │ │ ├── IndexBarView.kt # 导航栏核心类继承 View │ │ ├── OnLetterChangedListener.kt # 回调接口 │ │ └── MainActivity.kt # 演示页绑定列表联动 │ ├── res/layout/ │ │ ├── activity_main.xml # RecyclerView IndexBarView │ │ └── item_contact.xml # 列表项布局 │ └── res/values/ │ └── attrs.xml # 自定义属性文字大小、颜色等IndexBarView.kt 是绝对主角主流程可以分成三段第一段是绘制准备在 onMeasure 里确定 View 的宽高在 onDraw 里循环 26 个字母算坐标并画出来第二段是事件处理onTouchEvent 里按下时记录索引滑动时实时更新当前字母抬起时通知列表定位第三段是回调接口把当前字母通过 Listener 暴露给外部去操作 RecyclerView。MainActivity 只负责把 IndexBarView 和 RecyclerView 接起来给 IndexBarView 设置监听器在回调里根据字母找到列表里第一个以该字母开头的位置然后 smoothScrollToPosition。整个数据流是单向的手指 → IndexBarView → Listener → RecyclerView。这里有一个容易想岔的点很多新手会纠结“导航栏怎么知道每个字母对应列表的哪个位置”其实导航栏不需要知道。它只负责告诉你“手指现在按在哪个字母上”具体这个字母对应列表哪个 index是 Activity 里根据数据源算出来的。职责分离是这个项目最值得抄的设计之一。3. 核心绘制拆解字母怎么算坐标、怎么画上去3.1 OnMeasure给导航栏一个合理的宽高自定义 View 的第一步不是画而是量尺寸。onMeasure 决定了这个 View 在布局里占多大地方。导航栏的宽高不能直接写死因为不同机型屏幕密度不同写死 40dp 在低密度手机上显得太大在高密度上又可能太小。这里用了一个常见的测量套路宽高都根据模式来区分处理。override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { // 宽度如果没有明确指定默认 40dp val defaultWidth dp2px(40f) val widthMode MeasureSpec.getMode(widthMeasureSpec) val widthSize MeasureSpec.getSize(widthMeasureSpec) val finalWidth when (widthMode) { MeasureSpec.EXACTLY - widthSize MeasureSpec.AT_MOST - minOf(defaultWidth, widthSize) else - defaultWidth } // 高度如果没有明确指定默认撑满父布局剩余空间 val defaultHeight resources.displayMetrics.heightPixels val heightMode MeasureSpec.getMode(heightMeasureSpec) val heightSize MeasureSpec.getSize(heightMeasureSpec) val finalHeight when (heightMode) { MeasureSpec.EXACTLY - heightSize MeasureSpec.AT_MOST - minOf(defaultHeight, heightSize) else - heightSize } setMeasuredDimension(finalWidth, finalHeight) }测量逻辑的核心是 MeasureSpec 的三种模式EXACTLY 表示父布局给了确定的尺寸比如 layout_width 写成 40dp 或 match_parent这种情况直接用父布局给的数值AT_MOST 对应 wrap_content意思是父布局说“你最多不能超过这个值”我们需要取默认值和约束值的最小值UNSPECIFIED 表示父布局没给任何限制直接用默认值。上面这段代码里有一个细节默认高度直接取了屏幕高度像素值这是暗示这个导航栏“默认应该填满可用的纵向空间”。因为字母导航栏在实际使用中通常贴着屏幕右边从顶部到底部这样 26 个字母才能均匀分布在整个可用高度上。如果你在布局里给它 wrap_content 又不做处理它可能只显示很小一块这就是很多新手第一次跑出来字母挤成一团的原因。3.2 OnDraw26 个字母的坐标与绘制细节测量完了接下来就是绘制。onDraw 阶段要做的事情分两步先算出每个字母的中心坐标然后调用 canvas.drawText 把它画出来。这里的核心算法是“均分”。override fun onDraw(canvas: Canvas) { super.onDraw(canvas) if (letters.isEmpty()) return val itemHeight height / letters.size.toFloat() paint.textSize textSize paint.isAntiAlias true paint.color textColor for (index in letters.indices) { // 字母中心 Y每个字母占据一格中心点就是该格子垂直方向的中点 val centerY itemHeight * index itemHeight / 2f // 获取字母的宽度和高度用于计算 baseline保证文字垂直居中 val textBounds Rect() paint.getTextBounds(letters[index], 0, letters[index].length, textBounds) val textWidth textBounds.width() val textHeight textBounds.height() // X 方向水平居中 val centerX (width - textWidth) / 2f // 关键点drawText 的 y 参数是 baseline不是文字中心 // 需要通过中心点反推 baseline 的位置 val baselineY centerY - (textBounds.top textBounds.bottom) / 2f if (index currentIndex) { paint.color selectedTextColor // 画选中的高亮背景 canvas.drawRoundRect( 0f, itemHeight * index itemHeight / 2f - itemHeight / 2f, width.toFloat(), itemHeight * index itemHeight / 2f itemHeight / 2f, 8f, 8f, bgPaint ) } else { paint.color textColor } canvas.drawText( letters[index], index, 1, centerX, baselineY, paint ) } }这段代码里最容易被忽略的是 baselineY 的计算。canvas.drawText 的 y 参数并不是文字矩形的中心而是字体 baseline基线的位置。直接传 centerY 画出来的字会整体偏上因为 baseline 在字母底部附近文字的实际主体在 baseline 往上的区域。所以要先用 getTextBounds 拿到文字的上下边界再通过centerY - (top bottom) / 2把中心点换算成 baseline 位置。还有一个细节是 drawRoundRect 的背景绘制范围选中的字母会有一个圆角矩形的底色这个矩形的 top 和 bottom 分别是当前格子的上下边界left 是 0right 是 width意味着高亮背景横贯整个导航栏宽度视觉上就是按下字母时那一格整条亮起来。圆角半径 8f 对应 8px在高分辨率屏上偏小更好的做法是用 dp 转换后的值这个在避坑章节里再展开。字母数据源 letters 在构造函数里初始化默认是 A 到 Z 的 26 个大写字母。实际项目中可能存在只有部分字母有数据的情况导航栏仍然显示全量 26 个字母但点击到没有对应数据的字母时列表回调里需要加一个兜底判断否则 smoothScrollToPosition 会越界崩溃。再补一个参数说明textSize 和 textColor 是构造时从自定义属性里读出来的分别对应 attrs.xml 里定义的 indexBarTextSize 和 indexBarTextColor。这两个值在绘制前赋值给 paint避免 onDraw 里频繁 setTextSize 带来的性能损耗。paint.isAntiAlias true 是打开抗锯齿字母边缘会更平滑尤其在高密度屏幕上不开抗锯齿的话小字号字母的锯齿感特别明显。4. 触摸交互实现按下、滑动、松开的完整事件链4.1 onTouchEvent 的事件分发与碰撞检测触摸是这个导航栏的灵魂。前面画的 26 个字母只是静态画面真正让导航栏“活”起来的是 onTouchEvent 里的三个动作ACTION_DOWN、ACTION_MOVE、ACTION_UP。每次手指位置变化都要根据当前 Y 坐标算出命中了第几个字母然后触发重绘和回调。override fun onTouchEvent(event: MotionEvent): Boolean { val y event.y val itemHeight height / letters.size.toFloat() when (event.actionMasked) { MotionEvent.ACTION_DOWN - { // 按下根据 Y 坐标计算字母索引并强制进入按下状态 currentIndex (y / itemHeight).toInt().coerceIn(0, letters.size - 1) isPressed true invalidate() // 触发重绘让高亮背景显示 listener?.onLetterChanged(letters[currentIndex]) return true } MotionEvent.ACTION_MOVE - { // 滑动不断更新当前字母并且实时回调 val newIndex (y / itemHeight).toInt().coerceIn(0, letters.size - 1) if (newIndex ! currentIndex) { currentIndex newIndex invalidate() listener?.onLetterChanged(letters[currentIndex]) } return true } MotionEvent.ACTION_UP - { // 松开恢复非按下状态调用回调通知列表定位 isPressed false currentIndex -1 invalidate() listener?.onLetterUp() // 关键松开后父容器也可以处理手势 parent?.requestDisallowInterceptTouchEvent(false) return true } MotionEvent.ACTION_CANCEL - { // 取消系统层面打断触摸比如被来电打断 isPressed false currentIndex -1 invalidate() return true } } return super.onTouchEvent(event) }事件逻辑的核心是索引换算(y / itemHeight).toInt()。假设导航栏高度是 1000px26 个字母itemHeight 约 38.5px手指按在 500px 的位置500 / 38.5 12.98toInt 取整为 12对应第 12 个字母 MA 是第 0 个。这里用了 coerceIn 做边界截断防止手指滑出导航栏上下边缘时计算出负数或超过 25 的非法索引。ACTION_MOVE 里有个性能优化点只有当 newIndex 和 currentIndex 不同时才执行 invalidate 和回调。如果手指在同一格内滑动索引没变就不需要重绘这避免了 onDraw 被高频调用。很多性能差的导航栏实现就是没做这个判断导致手指在单个字母上滑动时整个 View 疯狂重绘。ACTION_UP 里把 currentIndex 重置为 -1同时 invalidate是为了让高亮背景消失导航栏恢复初始状态。这里有个细节是调用parent?.requestDisallowInterceptTouchEvent(false)这行代码的意思是“我不再阻止父容器拦截事件了”。如果导航栏嵌在 ScrollView 或竖向滑动的布局里按下时调用 true 阻止父容器滑动抬起时恢复 false 让父容器重新获得事件控制权。不加这行的话可能会遇到“手指从导航栏滑到列表上列表突然不能滑了”的怪问题。4.2 对外回调用接口解耦列表联动导航栏把自己的逻辑做完后怎么把当前字母告诉外面的列表答案是通过接口回调。这是解开 View 和列表耦合的正确姿势。interface OnLetterChangedListener { // 按下或滑动过程中字母变化时回调 fun onLetterChanged(letter: String) // 手指抬起时回调用于列表执行最终定位 fun onLetterUp() }在 IndexBarView 里定义一个可空类型的 listener 属性setListener 方法负责赋值。MainActivity 里这样接indexBarView.setListener(object : OnLetterChangedListener { override fun onLetterChanged(letter: String) { // 根据字母找到列表中的第一个匹配项 val position contactData.indexOfFirst { it.pinyinFirstLetter letter.lowercase() } if (position 0) { recyclerView.smoothScrollToPosition(position) } } override fun onLetterUp() { // 松手后可以做一些 UI 收尾比如隐藏中间的字母气泡提示 } })这里值得注意的是 onLetterChanged 和 onLetterUp 两个回调的职责划分。onLetterChanged 在手指滑动过程中高频触发适合做实时预览——比如在屏幕中间弹一个大号字母浮层或者轻量级的定位动作。onLetterUp 只在手指离开屏幕时触发一次适合做最终定位和 UI 收尾。MainActivity 里的定位逻辑用的是 indexOfFirst它的含义是“找出列表中第一个满足条件的元素位置”条件是联系人姓名的首字母拼音等于当前按下的字母。这里做了一次 lowercase() 是因为导航栏默认大写而拼音首字母数据通常存小写不一致就会导致匹配不上这个坑我在避坑章节里详细展开。接口设计上还有一个经验不要在 onLetterChanged 里做耗时操作比如数据库查询或者大数据过滤。它每滑动一格就触发一次高频回调里一旦有耗时操作UI 就会明显掉帧。一个合格的做法是 onLetterChanged 里只更新浮层文字等 onLetterUp 再执行真正的数据定位。5. 避坑指南字母偏移、误触与数据对不齐坑一字母整体偏上第一印象就翻车现象26 个字母画出来后整列文字明显偏上第一眼就觉得不对劲但说不清哪里不对。原因drawText 的 y 参数是 baseline 而不是文字中心。baseline 在字母底部居中公式用错了就整体上移。还有一种情况是用了(height - letters.size * textHeight) / 2当起始 y再逐个累加 textHeight这样算出来的起始点会忽略字母自身 ascent/descent 的不对称性。解决用 getTextBounds 拿到当前字母的 Rect再用centerY - (rect.top rect.bottom) / 2f反推 baseline。这个公式对大小写字母、带声调字符都适用算一次就能保证垂直居中。坑二手指滑出导航栏区域后导航栏“卡住”了现象手指从导航栏滑到 RecyclerView 区域导航栏高亮背景不消失再滑动时列表不响应。原因onTouchEvent 返回 true 之后这个 View 就接管了整条手势事件流。手指滑出边界后Action 仍然是 MOVE导航栏内部没有做“越界处理”同时没有调用 requestDisallowInterceptTouchEvent(false)父容器的滑动被一直压着。解决在 ACTION_MOVE 里加一个条件判断当手指的 Y 坐标超出 [0, height] 区间时把 currentIndex 重置并调用 parent.requestDisallowInterceptTouchEvent(false)。另外在 ACTION_UP 里别忘了把 isPressed 置回 false并重置 currentIndex 为 -1。坑三列表定位永远差一个现象点击 L列表定位到了 N 的位置点击 A列表完全不响应。原因数据源里的拼音首字母和导航栏字母大小写不一致。导航栏默认 A-Z 大写而 pinyinFirstLetter 字段可能存的是小写直接用比较必然对不上。另一个常见问题是列表数据没有按拼音排序indexOfFirst 找到的“第一个”匹配项可能排在整个列表的后半部分所以视觉上定位“不准”。解决比较时统一转成小写再比较同时确保数据源已经按拼音字段排好序。如果你用系统自带的中文排序要注意 Collator 的默认 Locale 可能导致排序结果和字母索引不一致建议对联系人姓名的拼音做过一次预计算存进列表。坑四小屏手机上字母重叠现象屏幕高度只有 480dp26 个字母每个分到十几像素文字都重叠在一起。原因itemHeight height / 26 的计算没有下限约束。当可用高度小于某个阈值时字母的实际绘制高度大于格子高度自然会重叠。解决给导航栏设置一个最小高度比如 minHeight 26 * textSize * 1.2。更彻底的办法是在 onMeasure 里做判断如果计算出的 itemHeight 小于 textSize 的 1.5 倍就把 textSize 缩小到itemHeight / 1.2f。这样小屏上字体自动缩小虽然字小但至少不重叠。注意这里不能用 dp2px 的值直接做除法要基于实测的字体 metrics 来算。坑五圆角背景在高分辨率屏上变成“直角”现象选中字母的圆角矩形背景在 2x 屏上能看到圆角在 3x 屏上几乎变成直角。原因drawRoundRect 的圆角半径是像素值写死 8f。3x 屏下一个 dp 等于 3px8px 只相当于 2.7dp圆角弧度被缩放后视觉上就不明显了。解决用 dp2px 换算后设置圆角半径。在你的工具类里加一个扩展函数 dptopx(context, dp) dp * context.resources.displayMetrics.density 0.5f然后再传入 drawRoundRect。如果你想统一处理也可以在 onSizeChanged 里计算一次半径存为成员变量避免 onDraw 里频繁换算。6. 二次开发加一个粘性气泡指示器顺便解决手势冲突拿到源码后最常见的改造需求是加“悬浮气泡”——就是手指按在导航栏上时屏幕中间出现一个大号字母浮层。这个需求不需要改绘制核心只需要在 IndexBarView 里加一个浮层状态同时把字母传入浮层 View 显示。具体改法是在 IndexBarView 内部增加一个 BubbleView 引用在 ACTION_DOWN 和 ACTION_MOVE 的 branch 里调用 bubbleView.animate().alpha(1f)根据当前字母 setText然后让气泡跟随手指的 Y 坐标在屏幕上的投影位置移动。最简单的实现是给 Activity 的根布局 addView 一个 TextView用 absolute 坐标定位这样不需要跨 Window 操作。手势冲突的处理技巧是导航栏区域只有 40dp 宽手指很容易滑出去进入 RecyclerView 的领地。一个合格的处理是给导航栏监听器增加一个 onTouchSlop 判断——当手指超出边界超过系统 touchSlop一般是 8dp时收起气泡并释放手势。用 ViewConfiguration.get(context).scaledTouchSlop 拿到这个阈值不要自己写死 10px 之类的值。如果你的改造目标是让导航栏支持“跳过空白字母”也就是某个字母没有任何联系人不显示逻辑也不复杂在设置数据源时用集合过滤掉空字母同时维护一个 map 记录“字母到列表位置的映射关系”onLetterChanged 时直接查 map不再 indexOfFirst 去遍历整个列表。这个改动后导航栏的高度分割就不再用 26 等分而是用实际字母个数onMeasure 和 onDraw 里的数字都改成动态值就行。做完改动怎么验证我一般会先在模拟器上跑三件事第一步是快速滑动观察气泡是否跟手重点看 ACTION_MOVE 的触发频率是否均匀第二步是点按边缘字母 A 和 Z确认边界索引没有越界也没有数组越界崩溃第三步是把系统字体调到最大确认字母不重叠。跑完这三步再上真机因为触控采样率不同模拟器跟手不代表真机跟手。这个项目拆到最后核心收获其实不是那 200 行代码本身而是它把自定义 View 的测量、绘制、事件处理、回调通信这四个环节串成了一条完整的链路。我最早做类似功能时直接在 Activity 里写了一个自定义 onDraw 的内部类事件处理和列表定位全堆在一个文件里结果后来要加气泡效果改一处崩三处。从那以后我凡是碰到底层绘制的东西都强制先设计好 View 和业务之间的接口再动手这次拆完这个导航栏项目我对这个习惯的信任又深了一层。希望这篇笔记能帮你少走几步弯路跑起来之后试着自己加一个气泡浮层手感会完全不一样。本文还有配套的精品资源点击获取