资讯详情

Android字母索引栏Kotlin实现与RecyclerView吸顶联动

📅 2026/11/3 20:33:22 | 华诺云谱 👁 阅读
Android字母索引栏Kotlin实现与RecyclerView吸顶联动
做联系人列表、城市选择、商品分类这类页面绕不开一个东西——Android字母索引栏。我在过去几年里前后写过四五个版本从最早的Java实现到最近的Kotlin重写踩过的坑能凑一箩筐。手头这个Kotlin版本是前阵子给一个通讯录模块做重构时整理的代码量不大但把汉字转拼音、分组排序、自定义View绘制、触摸坐标映射、RecyclerView联动、吸顶分组头这几块完整串了起来。它能解决的问题很具体几千条数据塞进列表用户想找张姓联系人不可能靠手指一路滑得有个A到Z的快速定位入口。适合谁看写过自定义View但没做过索引栏的做过但跳转总是错位的还有那些直接抄了网上代码结果滑动手感一塌糊涂的。下面我按自己的实现顺序把设计思路、核心代码、踩坑记录全部摊开讲。1. 字母索引栏的设计取舍与整体思路1.1 索引栏到底解决什么问题先把需求说透。一个几千条的联系人列表用户的核心诉求是快速定位到某个姓。传统的滚动查找在大数据量下体验极差而搜索框又要求用户能准确输入。索引栏的价值在于提供一个空间换时间的入口右侧一列26个字母加一个#用户按到哪个字母列表就滚到对应分组的第一条。这个交互模式在通讯录里已经被验证了十几年用户教育成本为零。但它不只是一个点击跳转的按钮组。真正做起来你会发现它牵扯的东西不少数据必须先按拼音首字母分组排序否则索引和列表对不上触摸事件要处理滑动而不是单纯点击因为用户会按住字母往下拖拖动过程中要有选中态反馈和气泡提示让用户知道当前停在哪个字母滚动定位要精确到分组头不能把分组头滚出屏幕最后还得处理分组头的吸顶效果让用户滚动列表时始终知道自己在哪个字母段里。我把这套东西拆成了三层数据层负责拼音转换、分组、排序、建立字母到位置的映射视图层负责绘制字母列、处理选中态交互层负责触摸事件、位置计算、与列表联动。三层分开写后期改需求的时候不用牵一发动全身。1.2 为什么坚持自定义View而不是用现成方案市面上确实有一些封装好的索引栏组件但我还是选择自己写原因有三个。第一是可控性。索引栏的视觉细节特别多——字母间距、字号、选中颜色、气泡样式、按下时的背景每个项目的设计稿都不一样用别人的组件改起来比自己写还费劲。第二是触摸手感。这是最关键的索引栏的触摸区域通常只有20到30dp宽手指按上去很容易偏现成组件往往不做热区扩展用户按半天没反应。自己做可以自由控制命中区域和防抖逻辑。第三是联动方式。有的项目用RecyclerView有的用ListView有的甚至是自定义的滚动容器现成组件往往绑定了某一种耦合太深。自己写一个索引栏核心代码其实不到两百行。onMeasure算一下每个字母占多高onDraw把字母画出来onTouchEvent把Y坐标换算成索引剩下都是业务逻辑。投入产出比很高。我建议每个做列表页的开发者都手写一遍写完对触摸事件和坐标系的理解会上一个台阶。1.3 Kotlin重写的实际收益从Java迁到Kotlin最直观的收益是代码量少了三分之一。原来那些匿名内部类、getter/setter、空判断现在几行就搞定了。但更重要的收益在可空性和函数类型上。索引栏有一个经典问题回调监听器没设置的时候空指针。Java时代要写一堆if (listener ! null)Kotlin里直接声明成可空函数类型((Int, String) - Unit)?调用时一个?.invoke()就解决了。另外Kotlin的LinkedHashMap和集合操作符在处理分组排序时特别顺手groupByTo、sortedWith、compareBy这几个组合起来原来几十行的排序逻辑现在五六行就能写完而且可读性更好。还有一个隐性收益扩展函数。我给View写了个dp2px的扩展给String写了个firstLetterOrSharp的扩展代码组织上清爽很多。下面所有的代码示例都是KotlinJava开发者看起来应该没什么障碍。2. 数据层分组、排序与首字母提取2.1 汉字转拼音的方案选型这是整个功能里最容易被低估的部分。字母索引栏能不能用八成取决于拼音转换准不准。我见过几个方案各有取舍。方案一查表法。把常用汉字的拼音首字母做成一张映射表存在HashMapChar, Char里。优点是快纯内存查表几千条数据毫秒级完成缺点是表要自己维护生僻字覆盖不全遇到表里没有的字只能落到#分组。适合联系人姓名这种常见字占绝大多数的场景。方案二Unicode区间判断。利用GB2312编码里汉字按拼音排序的特性通过编码区间反推首字母。这个方案不需要额外的表代码量极小但只对GB2312里的6763个汉字有效超出范围的生僻字会出错而且多音字完全没法处理。现在不推荐用了。方案三引入拼音转换库。业界有成熟的开源库专门做这件事支持全拼、首字母、多音字词组识别。优点是准确率高、维护成本低缺点是会增加APK体积首次加载有一定初始化开销。对于联系人这种对准确性要求高的场景我倾向于用这个。我最终的选择是方案三为主方案一兜底。用库做转换转换失败或者返回空的时候走一个内置的常用字首字母表再失败就归到#组。这样既保证了准确率又不会因为某个生僻字导致整个分组崩掉。这里提醒一句选库的时候重点看它是否支持词组多音字比如重庆的重要读chong而不是zhong单字转换的库在这个点上会翻车。2.2 分组排序的完整实现拿到拼音之后下一步是把列表按首字母分组。这里有个容易忽略的细节组内也要排序。用户点到Z分组期望看到的是张、赵、周按拼音顺序排列而不是原始数据的插入顺序。先定义数据模型。我习惯把原始数据和派生数据分开避免每次排序都重新算拼音data class ContactItem( val id: Long, val name: String, val pinyin: String, // 全拼用于组内排序 val firstLetter: Char // 首字母用于分组 )转换和分组的逻辑封装成一个工具类object ContactGrouper { fun group(rawList: ListContactItem): LinkedHashMapChar, ListContactItem { val result LinkedHashMapChar, MutableListContactItem() rawList.forEach { item - val key normalizeLetter(item.firstLetter) result.getOrPut(key) { mutableListOf() }.add(item) } // 组内按全拼排序 result.values.forEach { list - list.sortWith(compareBy({ it.pinyin }, { it.name })) } // 分组按字母顺序排#永远放最后 val sorted LinkedHashMapChar, ListContactItem() result.keys.sortedWith(letterComparator).forEach { key - sorted[key] result.getValue(key) } return sorted } private val letterComparator ComparatorChar { a, b - when { a # - 1 b # - -1 else - a.compareTo(b) } } private fun normalizeLetter(letter: Char): Char { return when { letter in A..Z - letter letter in a..z - letter.uppercaseChar() else - # } } }这里用LinkedHashMap而不是普通HashMap是因为要保持插入顺序否则分组头的顺序会乱。getOrPut是Kotlin标准库里的方法等价于有就取、没有就创建再放进去比Java里那套containsKey加get的写法干净得多。组内排序用compareBy链式比较先比全拼全拼相同再比原名。这一步看起来多余实际上很有必要——同名同姓的联系人在通讯录里很常见不加重排序的话每次刷新顺序可能都不一样用户会觉得列表在跳。2.3 特殊字符、多音字与空数据的兜底真实数据永远比想象中脏。我处理过一批数据里面混着英文名、数字昵称、表情符号、甚至纯空格。这些如果没处理好轻则分组错乱重则直接崩溃。我的处理策略是三层兜底。第一层原始字符串为空或者全是空白字符直接归到#组并且给一个未知的显示名。第二层首字符是英文字母直接大写作为分组键是数字或者符号归#组。第三层首字符是汉字走拼音转换转换结果为空同样归#。多音字的问题更隐蔽。比如单作为姓氏读shan但转换库默认可能返回dan。这种情况没有完美的通用解法我的做法是维护一张姓氏多音字修正表在转换完成后做一次覆盖。表不用大覆盖常见的几十个姓氏就够了比如单解仇查区朴这些。这是个笨办法但比让用户看到错误分组要好。还有一个容易被忽略的点#组的排序位置。有的设计稿要求#在最前面有的要求在最后。我默认放最后因为#组通常是兜底数据用户关注度低。这个做成可配置项通过自定义属性传进去。3. 自定义View测量、绘制与选中态3.1 onMeasure里的宽度高度计算索引栏的尺寸计算看起来简单实际上有几个坑。宽度不能写死得考虑不同字号和不同语言环境高度要平分给所有字母但整除不尽的时候会出现累积误差。先看代码class IndexBar JvmOverloads constructor( context: Context, attrs: AttributeSet? null, defStyleAttr: Int 0 ) : View(context, attrs, defStyleAttr) { private val letters ABCDEFGHIJKLMNOPQRSTUVWXYZ# private var letterHeight 0f override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) val defaultWidth dp2px(28f).toInt() val width resolveSize(defaultWidth, widthMeasureSpec) val height measuredHeight val availableHeight height - paddingTop - paddingBottom letterHeight availableHeight.toFloat() / letters.length setMeasuredDimension(width, height) } }这里有两个关键点。第一resolveSize的使用。它会把我们期望的宽度和父容器给的测量规格做一次协商如果父容器是wrap_content就用默认宽度如果是固定值或者match_parent就遵循父容器。这是自定义View的标准写法不用它的话在某些布局里会被压扁。第二letterHeight的计算必须减去padding。我见过不少实现直接拿height / 26结果加了padding之后字母整体偏移最下面那个#被裁掉一半。letterHeight用Float类型保存避免整除导致的误差累积——26个字母分1000px每个38.46px用Int会丢精度画到后面几个字母位置就明显偏了。提示如果你的索引栏背景是透明的宽度可以适当放宽到32dp甚至36dp扩大触摸热区。视觉上看起来还是那么细但手指按上去的容错率高很多。3.2 onDraw的绘制细节与对象复用onDraw是每一帧都会调用里面任何对象分配都会增加GC压力。索引栏虽然简单但在快速滑动时也是每秒几十帧的调用频率该省的还是要省。private val textPaint TextPaint(Paint.ANTI_ALIAS_FLAG).apply { textAlign Paint.Align.CENTER textSize dp2px(11f) color Color.parseColor(#8A8A8E) } private val selectedPaint TextPaint(textPaint).apply { color Color.parseColor(#007AFF) isFakeBoldText true } override fun onDraw(canvas: Canvas) { val centerX (paddingLeft width - paddingRight) / 2f letters.forEachIndexed { index, c - val paint if (index selectedIndex) selectedPaint else textPaint val baseline paddingTop letterHeight * index letterHeight / 2f - (paint.ascent() paint.descent()) / 2f canvas.drawText(c.toString(), centerX, baseline, paint) } }这里的核心是基线的垂直居中计算。drawText的y坐标不是文字的中心是基线位置。要真正居中得用(ascent descent) / 2来修正。ascent是负数descent是正数两者相加除以2得到的就是基线到视觉中心的偏移量。不加这个修正字母会明显偏上。另一个优化点是Paint复用。我声明了两个TextPaint普通态和选中态各一个onDraw里只做切换不做创建。selectedPaint通过拷贝构造函数从textPaint派生改个颜色加个粗体就行不用重复设置。还有个小细节c.toString()其实每次都在创建字符串对象。严格来说应该预先把letters转成ArrayStringonDraw里直接取。26个字母量不大但在极端情况下比如快速滑动加频繁重绘还是能省一点。我在自己的项目里做了这个预转换代码里为了简洁保留了toString()。3.3 选中态与气泡提示的实现用户按住字母往下滑的时候如果没有反馈会不知道自己当前停在哪。标准的做法是选中的字母高亮同时在屏幕中央弹出一个大号的气泡显示当前字母。字母高亮很简单onDraw里判断index selectedIndex就换画笔。气泡稍微麻烦一点有两种实现路径。路径一用PopupWindow或者TextView浮层。在索引栏外部盖一个圆角背景的TextView手指按下时显示抬起时隐藏。优点是可以直接用XML定义样式圆角、阴影、字体都好调缺点是要处理Window的定位和生命周期而且如果索引栏在Fragment里浮层的宿主Activity可能已经销毁。路径二直接画在索引栏的父容器上。在索引栏所在布局里放一个TextViewvisibility默认GONE回调里控制显示隐藏和文字内容。这是我最常用的方式简单可靠没有任何Window相关的坑。indexBar.onLetterChanged { index, letter - bubbleView.text letter bubbleView.visibility View.VISIBLE recyclerView.scrollToPositionWithOffset(positionMap[letter] ?: 0, 0) } indexBar.onLetterReleased { bubbleView.visibility View.GONE }回调里同时做了两件事更新气泡、滚动列表。这里滚动用的是scrollToPositionWithOffset而不是scrollToPosition因为前者可以指定偏移量能让分组头正好贴在列表顶部。positionMap是提前算好的字母到Adapter位置的映射下一节会讲怎么建。注意气泡的显示和隐藏要跟随触摸状态不能在ACTION_MOVE里反复设置VISIBLE那样会触发多次重绘。判断selectedIndex变化了再更新没变就跳过。4. 触摸交互坐标映射与事件处理4.1 onTouchEvent的事件处理链路索引栏的触摸逻辑比想象中要细。用户可能只是点一下也可能按住一直滑还可能滑出索引栏的范围再滑回来。这几种情况都要正确处理。private var isTouching false override fun onTouchEvent(event: MotionEvent): Boolean { when (event.action) { MotionEvent.ACTION_DOWN - { isTouching true parent?.requestDisallowInterceptTouchEvent(true) handleTouch(event.y) } MotionEvent.ACTION_MOVE - { if (isTouching) handleTouch(event.y) } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL - { isTouching false selectedIndex -1 invalidate() onLetterReleased?.invoke() parent?.requestDisallowInterceptTouchEvent(false) } } return true } private fun handleTouch(y: Float) { val index ((y - paddingTop) / letterHeight).toInt() .coerceIn(0, letters.length - 1) if (index ! selectedIndex) { selectedIndex index invalidate() onLetterChanged?.invoke(index, letters[index].toString()) } }requestDisallowInterceptTouchEvent(true)这行是必须的。如果索引栏的父容器是RecyclerView或者ScrollView不加这行的话手指一滑动父容器就会把事件拦截走索引栏只能收到一个ACTION_DOWN然后就被取消。这是新手最常踩的坑之一。coerceIn用来做边界收敛。用户的手指可能滑到索引栏外面去Y坐标变成负数或者超过高度不收敛的话数组越界直接崩。收敛之后滑出去和滑到第一个/最后一个字母效果一样符合直觉。ACTION_UP和ACTION_CANCEL都要处理。CANCEL的情况很容易漏——比如来电、弹窗、多指操作系统会发CANCEL而不是UP。漏了的话选中态会一直卡在那里不消失。4.2 索引计算的边界与手感调优handleTouch里的索引计算看起来很直白但有几个地方值得抠。第一减paddingTop。如果索引栏设置了顶部padding直接用y / letterHeight会算偏。正确的做法是先减掉padding再除。第二整除误差。letterHeight是Float26个字母累加到最后可能差个零点几像素。这个量级人眼看不出但如果用户的手指正好落在两个字母的交界处可能会在相邻两个索引之间反复横跳体感上是抖。我的处理方式是给索引加一个防抖阈值新索引和当前索引的差值必须超过0才切换这本来就成立所以真正要防的是手指微颤导致来回切。实际测试下来只要letterHeight不是特别小低于10px人的手指抖动不足以跨越一个字母的高度问题不大。第三触摸热区扩展。索引栏视觉宽度28dp其实有点窄。我的做法是重写getHitRect把命中矩形向外扩10dpoverride fun getHitRect(outRect: Rect) { super.getHitRect(outRect) outRect.left - dp2px(12f).toInt() outRect.right dp2px(12f).toInt() }这样用户手指稍微偏一点也能按到。注意这个扩展只影响ViewGroup的分发判断实际的onTouchEvent坐标还是相对View的所以不影响索引计算。左右都扩是为了兼容索引栏贴左边缘的布局。第四滑动手感。iOS的索引栏有一个特性滑到边缘时会有阻尼感不会直接跳。Android上实现这个成本比较高而且在RecyclerView上做阻尼滚动的适配很麻烦。我的取舍是不做阻尼但保证快速滑动时的响应速度。用scrollToPositionWithOffset而不是smoothScrollToPosition前者是瞬时跳转后者有动画快速滑动时会积压一堆动画请求看起来一顿一顿的。4.3 与RecyclerView的联动定位索引到最后要落到列表上。这里的关键是字母到Adapter位置的映射。假设Adapter是多类型的每个分组有一个分组头ViewType0下面是若干内容项ViewType1。那么Z分组对应的位置不是groups.keys.indexOf(Z)而是前面所有分组的内容项数量加上分组头数量。private fun buildPositionMap(groups: LinkedHashMapChar, ListContactItem): MapChar, Int { val map HashMapChar, Int() var position 0 groups.forEach { (letter, items) - map[letter] position position items.size 1 } return map }1就是分组头占的那一格。这个逻辑看起来简单但一旦分组结构变了比如加了最近联系人、群组这种特殊分组位置计算就得跟着改。我的做法是把映射的构建收进Adapter内部对外只暴露一个getPositionForLetter(letter): Int方法索引栏不关心内部结构。滚动调用val targetPosition adapter.getPositionForLetter(letter) (recyclerView.layoutManager as LinearLayoutManager) .scrollToPositionWithOffset(targetPosition, 0)用LinearLayoutManager的scrollToPositionWithOffset而不是RecyclerView.scrollToPosition原因是后者只能保证目标项可见可能停在屏幕底部而前者能把目标项精确对齐到顶部。索引栏的语义就是我要看这个字母的开头对齐到顶部才符合预期。提示如果列表设置了clipToPaddingfalse和顶部paddingscrollToPositionWithOffset的offset要设成padding的负值否则分组头会被padding顶下去。这个细节在实现吸顶效果时尤其要注意。5. 列表联动与吸顶效果的完整落地5.1 多类型Adapter的分组头处理前面提到Adapter是多类型的这里展开说下结构。一个分组Header加若干内容ItemHeader展示字母Item展示联系人。数据源在Adapter内部扁平化成一个ListAny或者用密封类sealed class来表示。sealed class ContactRow { data class Header(val letter: Char, val count: Int) : ContactRow() data class Content(val item: ContactItem) : ContactRow() } fun flat(groups: LinkedHashMapChar, ListContactItem): ListContactRow { val rows mutableListOfContactRow() groups.forEach { (letter, items) - rows.add(ContactRow.Header(letter, items.size)) items.forEach { rows.add(ContactRow.Content(it)) } } return rows }用密封类的好处是getItemViewType和onBindViewHolder里的when分支能穷尽编译器会检查有没有漏掉类型比用Int常量加if-else安全得多。扁平化之后位置映射就更简单了rows.indexOfFirst { it is ContactRow.Header it.letter letter }可以直接拿到位置。但indexOfFirst是O(n)的如果是高频调用还是提前建好Map更稳妥。这里有个性能优化点避免每次索引变化都重新扁平化。数据变化时才重建rows和位置映射索引滚动时只做查询。我把这个逻辑放在submitList里用DiffUtil做增量更新。DiffUtil在分组结构变化时可能计算出比较复杂的差异如果不追求动画效果可以直接notifyDataSetChanged简单粗暴但够用。5.2 吸顶效果与滚动监听吸顶Sticky Header是索引栏的标配。用户滚动列表时当前分组的字母要固定在顶部直到下一个分组把它顶上去。实现方式有两种。第一种是ItemDecoration在onDrawOver里绘制悬浮的头部。这种方式不侵入Adapter性能好是主流做法。第二种是在RecyclerView外面盖一个View监听滚动实时更新内容。这种方式实现简单但容易有延迟感。ItemDecoration的实现要点class StickyHeaderDecoration( private val rowsProvider: () - ListContactRow ) : RecyclerView.ItemDecoration() { private val headerPaint TextPaint(Paint.ANTI_ALIAS_FLAG).apply { textSize 14f color Color.parseColor(#333333) } private val bgPaint Paint().apply { color Color.parseColor(#F2F2F7) } override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { val topChild parent.getChildAt(0) ?: return val topPosition parent.getChildAdapterPosition(topChild) if (topPosition RecyclerView.NO_POSITION) return val rows rowsProvider() val currentHeader findHeaderForPosition(rows, topPosition) ?: return // 检查下一个Header是否要把当前Header顶上去 var offset 0f val nextChild parent.getChildAt(1) if (nextChild ! null) { val nextPos parent.getChildAdapterPosition(nextChild) if (nextPos ! RecyclerView.NO_POSITION rows.getOrNull(nextPos) is ContactRow.Header topChild.bottom currentHeaderHeight ) { offset (topChild.bottom - currentHeaderHeight).toFloat() } } c.save() c.translate(0f, offset) c.drawRect(0f, 0f, parent.width.toFloat(), currentHeaderHeight.toFloat(), bgPaint) c.drawText( currentHeader.letter.toString(), dp2px(16f), currentHeaderHeight / 2f - (headerPaint.ascent() headerPaint.descent()) / 2f, headerPaint ) c.restore() } }这段代码的关键是顶部顶推逻辑。判断条件是两个下一个可见项是Header且当前第一个可见项的底部已经进入了Header的高度范围。满足时把悬浮Header向上平移形成被顶走的效果。offset是负数平移后下一个Header正好覆盖上来。currentHeaderHeight通常是30dp到40dp要和Adapter里真实Header的布局高度保持一致否则会有错位。我一般定义成一个常量两边引用同一个值。这里还有个坑onDrawOver在滚动过程中会高频调用里面不能有耗时操作rowsProvider()如果每次都做一次flat就废了必须是已经算好的缓存。6. 常见问题与排查技巧实录6.1 跳转错位类问题症状点到L列表滚到了K的位置或者干脆滚到屏幕最上面。这个问题的成因十有八九是位置映射没把分组头算进去。很多实现直接用groups.keys.indexOf(letter)当位置但实际Adapter里每组前面多了一个Header位置整体偏移了。数据量小的时候看不出来偏移一两个位置视觉上差不多数据量一大就明显错位。排查方法在scrollToPositionWithOffset之前打一行日志输出目标位置和该位置对应的数据看看是不是Header。如果是Content说明映射没错如果是别的字母的Header或者Content说明映射错了。还有一种情况是**#组的位置算错**。#组的键不是字母比较逻辑不同如果排序逻辑里没处理好#可能跑到中间去位置映射自然就乱了。症状滑动索引栏时列表不滚动只在高亮字母变化。这是事件没接上。检查两件事一是onLetterChanged回调有没有正确赋值Kotlin的可空函数类型很容易忘记赋值然后?.invoke()静默跳过二是scrollToPositionWithOffset的调用时机如果RecyclerView还没layout完成调用会被忽略。可以在回调里加日志确认执行到了。6.2 触摸与绘制类问题症状按住索引栏往下滑滑到一半突然不动了列表开始跟着滚。百分之百是父容器把事件拦截了。requestDisallowInterceptTouchEvent(true)没加或者加的位置不对。注意要在ACTION_DOWN里加而不是ACTION_MOVE里——事件一旦被父容器拦截子View根本收不到MOVE。另外如果索引栏外面还套了NestedScrollView或者SwipeRefreshLayout可能还要处理一下嵌套滚动的问题。症状字母显示不全最下面的#看不到了。通常是高度计算没减padding或者是onMeasure里用了固定的高度。检查letterHeight的计算公式以及setMeasuredDimension传入的高度是不是measuredHeight。还有一个隐蔽的原因是父布局给了match_parent但自身高度不够索引栏被压扁了。症状快速滑动时选中态闪烁。handleTouch里每次index变化都invalidate()本身没问题。但如果onLetterChanged回调里做了耗时操作比如查数据库、更新整个列表就会导致绘制卡顿看起来像闪烁。检查回调里有没有同步的耗时逻辑有的话改成异步或者缓存。6.3 问题速查表现象大概率原因排查动作点字母列表跳错位置位置映射没算分组头打印目标位置的数据类型滑动索引栏列表不动回调未赋值或时机过早检查回调赋值和layout状态滑到一半事件丢失父容器拦截触摸补requestDisallowInterceptTouchEvent字母底部被裁切高度计算未减padding核对letterHeight公式快速滑动选中态闪烁回调内有同步耗时操作用日志打点看耗时特殊字符全归到#拼音转换返回空未兜底加空值判断和默认分组同名联系人顺序乱跳组内排序未加次级比较补compareBy的第二个key吸顶头部和真实头部错位两处高度常量不一致统一成同一个dp值气泡不消失未处理ACTION_CANCEL补CANCEL分支心得索引栏的bug有八成集中在事件和位置这两件事上。遇到问题的第一反应应该是打日志看事件有没有进来、位置算得对不对而不是盯着绘制代码看。我见过太多人在onDraw里找了一下午最后发现是回调没连上。7. 性能优化与细节打磨7.1 高频滑动下的绘制优化索引栏本身很轻onDraw里只有26次drawText正常情况下不会成为瓶颈。但在低端机上如果配合复杂的列表项还是能感受到差异。优化方向有三个。第一减少onDraw里的对象分配。前面提到的c.toString()、Rect、Path这些能复用就复用。预先生成ArrayStringonDraw里直接索引取值。第二关闭不必要的抗锯齿。如果字母很小、颜色很淡关掉ANTI_ALIAS_FLAG视觉上几乎没差别但渲染开销能降一些。第三合理使用setLayerType。索引栏是静态内容加少量高亮变化不需要硬件层反而会占用显存。别看到性能优化就想着开硬件加速这个场景不适用。列表侧的优化更重要。scrollToPositionWithOffset是同步的如果目标位置离当前位置很远LinearLayoutManager会重新布局这个开销在几千条数据下是可感知的。实测下来从A跳到Z在2000条数据的列表上大约有几十毫秒的卡顿。如果要求极致流畅可以考虑提前预布局或者把列表按分组拆成多个子列表。不过对于大多数场景这点卡顿可以接受。7.2 用户体验细节最后聊几个容易被忽略但影响体感的细节。索引栏的淡入淡出。手指按下时索引栏整体加深一点背景抬起时恢复这个反馈让用户知道我按到了。用setBackgroundColor在按下和抬起时切换即可注意配合ACTION_CANCEL。首字母高亮的持久性。有些实现是手指抬起后高亮立即消失有些是保持到下一次滚动。我倾向于抬起后保持高亮因为用户刚跳完列表顶部就是这个字母的分组头高亮着能形成视觉呼应知道我现在在这个组。列表滚动到其他分组时再更新高亮这需要在RecyclerView的滚动监听里同步索引栏的selectedIndex。无障碍支持。TalkBack用户没法用手势滑索引栏得靠朗读。给索引栏加contentDescription说明它是字母快速定位栏并且在每个字母的触摸位置设置虚拟节点。这块工作量不小但如果产品有无障碍要求得做。横屏和折叠屏适配。横屏时高度变矮26个字母可能挤在一起。可以在onMeasure里判断如果letterHeight小于某个阈值比如14dp就用两列排布或者允许滚动。折叠屏展开后宽度变化索引栏不能跟着变宽要限制最大宽度。动态字母表。不是所有场景都需要26个字母。比如城市列表通常只有热门字母联系人列表才需要全量。把字母表做成可配置的属性通过typedArray读取不传就用默认的26加#。这个小设计让组件复用性提升不少我在三个不同模块里用了同一份代码只改了这个配置。个人信息这块我踩过最深的坑是数据刷新时的选中态丢失。用户滑到W分组这时候后台同步了一次通讯录列表notifyDataSetChanged索引栏的selectedIndex还是上次的值但列表已经回到顶部了。解决方式是在数据刷新后根据当前列表第一个可见项反推字母同步给索引栏。这个逻辑我写在了Adapter的registerAdapterDataObserver里onChanged回调触发时执行一次同步。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑