简介这份资源面向Android中高级开发者聚焦自定义View实战围绕字母导航栏这一常见交互组件展开适用于联系人列表、商店分类等需要按首字母快速定位的场景。压缩包共977个文件以462个xml布局与资源、302个png图片、126个json配置为主另含40个class、12个jar、11个java源码及aidl、apk等构建产物整体约6.66MB结构完整便于对照工程目录理解自定义View的创建、事件监听、UI布局与列表联动。已有423人学习下载。资源涵盖AlphabetNavigationBar的继承与初始化、字母按钮动态添加、点击回调处理以及与ListView/RecyclerView配合的getSectionForPosition、getPositionForSection和smoothScrollToPosition滚动定位思路并涉及悬浮提示与触摸手势扩展方向可帮助读者掌握从视图搭建到数据联动的完整实现路径适合作为自定义View练习与项目参考。1. 字母导航栏不是画个字母条从通讯录侧边索引的真实需求说起通讯录、城市列表、商品分类页里右侧那条竖着的字母索引条用户滑动时手指一碰就能跳到对应分组中间还会弹出一个大字母提示当前落在哪一段。这个交互看着简单真动手做的时候问题全冒出来字母条要跟着列表滚动状态联动、触摸要精确命中某个字母、手指滑出控件边界还得继续响应、快速滑动时不能漏掉字母、列表数据为空或分组稀疏时字母条不能乱跳。字母导航栏这个需求本质是把「触摸事件分发 坐标换算 列表定位 视觉反馈」四件事串成一条闭环任何一环没处理好用户就会觉得「点了没反应」或者「跳错位置」。这套方案面向两类人一类是要交 Android 课程设计或毕设、需要把自定义 View 讲清楚的学生另一类是要在真实项目里落地侧边索引的开发者。下面从需求拆解、技术选型、总体设计一路写到模块实现和验收中间穿插我踩过的坑照着改就能跑。2. 需求拆解与技术选型为什么用自定义 View 而不是 RecyclerView 的 ItemDecoration2.1 先把交互需求翻译成可测量的指标动手写代码之前我一般会把「字母导航栏」拆成几条能验收的硬指标否则做到一半容易跑偏。这个项目场景下合格的做法至少覆盖以下几点需求项具体指标验收方式字母绘制26 个字母 可选「#」分组等间距垂直排列截图比对字母不重叠不越界触摸命中手指落在某字母区域内准确返回该字母索引逐字母点击日志输出索引滑动联动手指在字母条上滑动实时切换高亮字母连续滑动观察高亮跟随越界响应手指滑出控件左右边界仍持续响应滑出后继续上下移动列表定位点击字母后列表滚动到对应分组首个 item对比分组头位置中央提示触摸时屏幕中央弹出大字母松手消失视觉确认 延时消失空数据保护无数据或分组缺失时不崩溃、不乱跳空列表、单分组测试这些指标里「越界响应」和「空数据保护」是最容易被忽略的两条也是答辩时最容易被追问的地方。把指标先定下来后面每个模块写什么、测什么就有依据了。2.2 三种实现路线的取舍实现字母导航栏常见三条路一是纯自定义 View 自己画字母、自己处理触摸二是用 RecyclerView 的 ItemDecoration 在右侧绘制三是直接堆一排 TextView 放进 LinearLayout。第三条最省事但最不灵活字母间距、高亮、中央提示都得额外拼触摸越界也难处理基本只适合静态展示。ItemDecoration 方案能复用列表的滚动体系但触摸事件仍然要单独接管绘制和触摸逻辑被拆到两个地方维护起来别扭。我一般选纯自定义 View。原因很直接字母条的绘制、触摸命中、状态回调是一个内聚的整体放在一个 View 里职责清晰对外只暴露「当前选中哪个字母」的回调宿主 Activity 或 Fragment 拿到回调去驱动列表滚动即可。耦合度低复用性强换个列表控件也能接。代价是要自己处理 Measure、Draw、Touch 三件套但这恰恰是自定义 View 课程设计要考察的核心能力写透了反而是加分项。2.3 关键参数先定死选型定了先把几个影响手感和视觉的参数拎出来做成可配置属性避免写死在代码里字母文字大小默认 12sp随控件高度自适应但设下限防止过小。字母间距由控件高度除以字母数量动态计算保证铺满。高亮色与普通色高亮用主题色普通用次级文字色。中央提示框直径约为屏幕宽度的 1/4圆角矩形背景字母居中。触摸判定容差字母区域高度的一半作为命中半径避免字母间隙点不中。把这些参数抽成自定义属性attrs.xml既方便调也方便在答辩时说明「这个控件是可配置的」比硬编码体面得多。3. 总体架构设计一个自定义 View 加一条回调链3.1 模块划分与职责边界整个方案拆成四块职责不重叠SideBarView自定义 View负责测量自身尺寸、绘制字母、处理触摸事件、计算当前命中的字母索引、绘制中央提示。它不知道列表的存在只对外抛回调。字母索引回调接口定义onLetterChanged(String letter, int index)和onTouchEnd()两个方法前者在字母切换时触发后者在手指抬起时触发用于隐藏提示。宿主容器Activity/Fragment实现回调收到字母后在自己的数据源里找到该字母对应的分组起始位置驱动列表滚动。数据分组层把原始列表按首字母排序并分组生成「字母 → 起始 position」的映射表供宿主快速定位。这样分层的好处是SideBarView 可以单独抽成库给别的页面用数据分组逻辑也能独立测试。答辩时讲清楚「View 只管交互定位交给宿主」逻辑链就立住了。3.2 触摸到滚动的完整数据流一次完整的交互是这样走的手指按下 → SideBarView 的onTouchEvent拿到 y 坐标 → 用(y - paddingTop) / 单字母高度算出索引 → 索引变化时回调onLetterChanged→ 宿主查映射表得到目标 position → 调用列表的scrollToPositionWithOffset或smoothScrollToPosition→ 列表滚动到分组头。手指抬起 → 回调onTouchEnd→ 中央提示延时消失。这条链上最容易断的是「索引换算」和「映射表查找」两处。索引换算要处理边界y 小于顶部、大于底部时钳制到首尾字母映射表要处理「某字母没有对应数据」的情况跳过或就近取下一个有数据的字母。这两点在实现章节会展开。3.3 为什么中央提示要独立于字母条绘制中央提示框的位置在屏幕中央而字母条在屏幕右侧两者不在同一个坐标系里。如果硬在 SideBarView 里画就得把 View 的绘制范围扩展到全屏或者用getLocationOnScreen换算坐标容易出错。我一般的做法是SideBarView 只负责回调「当前字母变了」中央提示交给宿主用一个独立的 TextView 或自定义小 View 来显示位置固定在屏幕中央显示和隐藏由回调驱动。这样职责更干净提示框的样式也能随便改不影响字母条本身。4. 核心模块实现测量、绘制、触摸与定位的代码落地4.1 onMeasure 与字母布局计算自定义 View 第一步是把尺寸算对。字母条通常是固定宽度、高度撑满父容器所以onMeasure里宽度取建议值或固定 dp高度用MeasureSpec.getSize拿父容器给的高度。Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int widthMode MeasureSpec.getMode(widthMeasureSpec); int widthSize MeasureSpec.getSize(widthMeasureSpec); // 宽度固定值优先否则取建议最小值 int width (widthMode MeasureSpec.EXACTLY) ? widthSize : getPaddingLeft() defaultWidth getPaddingRight(); // 高度撑满父容器 int height MeasureSpec.getSize(heightMeasureSpec); setMeasuredDimension(width, height); }宽度这里有个细节如果父容器给的是AT_MOST直接用widthSize会让字母条占满整行所以要退回到defaultWidth。高度一般父容器会给EXACTLY直接取 size 即可。测量完之后在onSizeChanged里根据可用高度和字母数量算出单字母高度singleHeight (height - paddingTop - paddingBottom) / letters.size()这个值后面绘制和触摸命中都要用缓存成成员变量避免重复计算。4.2 onDraw 绘制字母与高亮态绘制逻辑就是遍历字母列表逐个drawText当前选中的字母换高亮色。文字基线要对齐每个字母格子的垂直中心否则字母会偏上。Override protected void onDraw(Canvas canvas) { for (int i 0; i letters.size(); i) { String letter letters.get(i); // 高亮色 vs 普通色 paint.setColor(i currentIndex ? highlightColor : normalColor); // 计算文字基线格子中心 文字高度的一半偏移 float centerY paddingTop singleHeight * i singleHeight / 2f; float baseline centerY - (paint.descent() paint.ascent()) / 2f; float x (getWidth() - paint.measureText(letter)) / 2f; canvas.drawText(letter, x, baseline, paint); } }(paint.descent() paint.ascent()) / 2f这个偏移是文字垂直居中的标准写法ascent是负值两者相加除以二得到文字视觉中心相对基线的偏移。少了这一步字母会整体偏上视觉上很别扭。高亮态除了换色还可以加粗或放大字号但要注意放大后基线会变得重新算我一般只换色稳。4.3 onTouchEvent 的命中计算与越界处理触摸是这套方案的核心。要点有三个按下和移动都要响应、越界后继续响应、抬起时复位。Override public boolean onTouchEvent(MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: case MotionEvent.ACTION_MOVE: // 请求父容器不要拦截保证滑动不中断 getParent().requestDisallowInterceptTouchEvent(true); float y event.getY(); // 钳制到有效范围防止越界算出非法索引 y Math.max(paddingTop, Math.min(y, getHeight() - paddingBottom)); int index (int) ((y - paddingTop) / singleHeight); index Math.max(0, Math.min(index, letters.size() - 1)); if (index ! currentIndex) { currentIndex index; invalidate(); // 触发重绘高亮 if (listener ! null) { listener.onLetterChanged(letters.get(index), index); } } return true; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: if (listener ! null) { listener.onTouchEnd(); } return true; } return super.onTouchEvent(event); }几个关键点解释一下。requestDisallowInterceptTouchEvent(true)是血泪经验字母条通常嵌在可滚动容器里如果不请求父容器别拦截手指一滑动父容器就把事件抢走了字母条只收到一个 DOWN 就没了表现为「滑不动」。Math.max/min的钳制是为了处理手指滑出控件顶部或底部的情况不钳制的话索引会算出负数或超出范围直接数组越界崩溃。ACTION_CANCEL也要处理否则某些场景下手指抬起收不到 UP中央提示会一直挂着不消失。4.4 宿主侧的分组映射与列表定位SideBarView 只抛字母真正让列表跳过去的是宿主。宿主需要一张「字母 → position」的映射表构建方式是在数据分组时顺手记下来// 构建字母到列表位置的映射 MapString, Integer letterPositionMap new HashMap(); int position 0; for (Group group : groupList) { letterPositionMap.put(group.getLetter(), position); position group.getItems().size() 1; // 1 是分组头占一行 }收到回调后查表定位Override public void onLetterChanged(String letter, int index) { Integer targetPos letterPositionMap.get(letter); if (targetPos ! null) { layoutManager.scrollToPositionWithOffset(targetPos, 0); } // 中央提示显示当前字母 centerTip.setText(letter); centerTip.setVisibility(View.VISIBLE); } Override public void onTouchEnd() { // 延时隐藏避免闪烁 centerTip.postDelayed(() - centerTip.setVisibility(View.GONE), 300); }这里用scrollToPositionWithOffset而不是smoothScrollToPosition是因为快速滑动字母条时平滑滚动会排队、跟不上手指直接定位更跟手。如果产品要求有滚动动画可以只在点击时用平滑滚动滑动时用直接定位两者分开处理。映射表里1是因为分组头占了一个 item 位置这个偏移量算错列表就会停在分组头的上一行表现为「差一行」很常见。5. 避坑与排查字母导航栏最容易翻车的五个点5.1 滑动时字母条突然不响应现象手指按在字母条上开始滑动滑到一半字母条不再高亮事件像是被抢走了。原因字母条嵌在 ScrollView 或 RecyclerView 里父容器在 MOVE 阶段拦截了事件。解决在ACTION_DOWN时调用getParent().requestDisallowInterceptTouchEvent(true)并在ACTION_UP/CANCEL时恢复为 false把事件控制权还回去。5.2 点击字母跳错位置或差一行现象点「B」列表停在「A」分组末尾或者停在分组头上面一行。原因映射表里的 position 没把分组头占位算进去或者分组头用了独立的 item 类型但偏移量没同步。解决构建映射表时明确「position 指向分组头本身还是分组首个内容项」全篇统一用scrollToPositionWithOffset(pos, 0)时确认 pos 是分组头位置让分组头吸顶。5.3 空数据或分组稀疏时崩溃现象列表为空时点字母条直接崩或者某些字母没有对应数据时列表乱跳。原因索引换算没做边界钳制映射表查不到时没判空。解决触摸换算时用Math.max/min钳制索引查映射表时判null查不到就跳过或就近取下一个有数据的字母绝不直接get。5.4 中央提示不消失或闪烁现象松手后大字母一直挂着或者快速滑动时提示框疯狂闪。原因只处理了ACTION_UP没处理ACTION_CANCEL或者每次字母变化都重新postDelayed导致延时任务堆积。解决UP 和 CANCEL 都触发隐藏隐藏用removeCallbacks先清掉旧任务再postDelayed保证只有一个延时任务在跑。5.5 字母文字偏上或重叠现象字母整体偏上或者字母多时上下挤在一起。原因绘制基线没做文字垂直居中偏移或者单字母高度算错导致间距不足。解决基线用centerY - (ascent descent) / 2计算单字母高度用可用高度除以字母数量字母数量多时适当缩小字号并设下限保证不重叠。6. 验收与答辩怎么证明这条字母条是「能用」的验收这套东西我一般分三步走每步都有可观察的输出答辩时直接演示比嘴上说管用。第一步是单元级验证。把 SideBarView 单独放进一个空 Activity不接列表只打日志。逐字母点击看onLetterChanged回调的字母和索引是否一一对应手指从顶部滑到底部看索引是否连续递增不跳号手指滑出控件左右边界再上下移动看是否仍持续回调。这一步能抓出触摸命中和越界处理的问题日志就是证据。第二步是集成验证。接上真实列表和分组数据重点测四个场景正常多分组数据、空列表、只有一个分组、某几个字母没有数据。每个场景点几个字母确认列表停在分组头、中央提示正确显示和消失。空列表和稀疏分组是答辩高频追问点提前测过就不慌。第三步是边界与手感验证。快速连续滑动字母条看列表是否跟手、有没有卡顿或跳错在列表滚动过程中点字母条看是否正常响应旋转屏幕或切换深色模式看字母颜色和提示框是否适配。手感这东西没法量化但「跟手不跟手」演示一遍评委就能感觉到。答辩时有个技巧不要一上来就讲代码先演示交互让评委看到「点哪跳哪、滑动跟手、中央提示正常」再回头讲onMeasure怎么算、onTouchEvent怎么钳制、映射表怎么建。先给结果再给原理说服力强得多。另外准备好回答「为什么不用 ItemDecoration」「越界怎么处理」「空数据怎么办」这三个问题基本就稳了。我自己的习惯是每次做完这类触摸交互控件都强制在真机上用手指快速划十几遍模拟器上滑得再顺真机上的事件分发和触摸采样率都可能不一样翻车往往就翻在这。从那以后我每次交付前都强制走一遍「空数据 快速滑动 越界」三连测希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?