ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android字母导航栏自定义View:绘制、触摸与避坑指南

Android字母导航栏自定义View:绘制、触摸与避坑指南

简介:这份资源面向Android初中级开发者,聚焦自定义View中常见的字母导航栏实现,适用于联系人列表、商店分类等需要按字母快速定位的场景。包内共977个文件,以462个xml布局与资源、302个png图片、126个json配置为主,另含40个class、12个jar、11个java源码及aidl、apk等构建产物,压缩包约6.66MB,完整保留了工程结构与依赖。已有423人学习下载,说明其在自定义View练习中具备一定参考价值。读者可从中获取字母导航栏的完整实现思路,包括继承LinearLayout搭建View骨架、动态生成字母按钮并绑定点击事件、与ListView或RecyclerView联动实现平滑滚动定位,以及悬浮提示、滑动选字母等体验优化方向,便于对照工程代码理解视图创建、事件监听与列表交互的完整链路。

1. 字母导航栏:一个被低估的自定义 View 练手切口

通讯录、城市列表、商品分类页右侧那条竖排 A–Z 索引条,点一下能跳、按住能拖、松手回弹,看着不起眼,却是自定义 View 里性价比极高的练手对象。它把测量、绘制、触摸事件分发、坐标换算、状态回传五件事压在一个不到两百行的小控件里,任何一环没吃透都会翻车。这份资源围绕「字母导航栏」这个具体控件展开,适合已经能写基础自定义 View、但一碰onTouchEvent和onDraw就心里没底的 Android 开发者,也适合想给列表页补一个像样索引条、又不想引第三方库的从业者。下面按「控件怎么立起来 → 触摸怎么接住 → 坑在哪 → 怎么验证」的顺序拆。

2. 把字母条画出来:测量、绘制与坐标基准

自定义 View 的第一步永远不是写onDraw,而是先想清楚「这个控件占多大、字母从哪开始排、每个字母的中心点在哪」。字母导航栏的绘制逻辑简单,但坐标基准一旦定错,后面触摸命中判断会全线崩。这一章把测量和绘制拆开讲,顺带把几个容易埋雷的参数说透。

2.1 onMeasure 里到底该处理哪种模式

字母条通常是「宽度固定、高度撑满父容器」的形态,所以onMeasure的重点不在宽度,而在高度。常见做法是:宽度取一个固定 dp 值(比如 24dp 到 32dp,够放下一个字母加左右留白),高度在AT_MOST或UNSPECIFIED下退回一个默认值,在EXACTLY下直接用父容器给的值。

@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); // 默认宽度,够放一个字母 } // 高度:撑满可用空间,兜底给个默认高度 int height; if (heightMode == MeasureSpec.EXACTLY) { height = MeasureSpec.getSize(heightMeasureSpec); } else { height = dp2px(300); } setMeasuredDimension(width, height); }

这段代码的关键在宽度分支:很多新手直接setMeasuredDimension(dp2px(28), height),结果在wrap_content场景下宽度被写死没问题,但在某些父容器里被EXACTLY约束时又显得过窄。按模式分支处理,是自定义 View 的通用习惯。高度兜底值不是随便给的,它决定了控件在wrap_content下的最小可用高度,太小会导致字母挤成一团。

2.2 字母间距与基线:别用 top 对齐

绘制字母最容易翻车的地方是「垂直居中」。直接拿y = index * itemHeight当绘制起点,字母会整体偏上,因为drawText的 y 坐标是基线(baseline),不是文字顶部。正确做法是先算每个字母格子的中心 y,再用Paint.FontMetrics把基线校正到中心。

private void drawLetters(Canvas canvas) { int itemHeight = getHeight() / letters.length; // 每格高度 for (int i = 0; i < letters.length; i++) { String letter = letters[i]; // 格子中心 y float centerY = i * itemHeight + itemHeight / 2f; // 用 FontMetrics 把基线校正到中心 Paint.FontMetrics fm = paint.getFontMetrics(); float baseline = centerY - (fm.ascent + fm.descent) / 2f; // 水平居中 float textWidth = paint.measureText(letter); float x = (getWidth() - textWidth) / 2f; canvas.drawText(letter, x, baseline, paint); } }

(fm.ascent + fm.descent) / 2f这个式子算的是文字视觉中心相对基线的偏移,减掉它就把基线挪到了格子中心。itemHeight用getHeight() / letters.length取整会有余数,最后一个字母可能贴底,严谨一点可以用float除法,或者把余数均摊到每格。水平居中用measureText而不是getWidth() / 2,是因为字母宽度不一,measureText才能保证每个字母真正居中。

2.3 选中态与背景:状态要能被外部读到

字母条不是画完就完事,它得告诉列表「用户当前选到哪个字母」。常见做法是维护一个selectedIndex,在onDraw里对选中字母换色或加圆角背景,同时通过回调把字母抛给外部。

private int selectedIndex = -1; @Override protected void onDraw(Canvas canvas) { for (int i = 0; i < letters.length; i++) { if (i == selectedIndex) { paint.setColor(selectedColor); // 选中态画一个圆角背景 float cx = getWidth() / 2f; float cy = i * itemHeight + itemHeight / 2f; canvas.drawCircle(cx, cy, itemHeight / 2f, bgPaint); } else { paint.setColor(normalColor); } // ... 绘制字母 } } public interface OnLetterSelectedListener { void onLetterSelected(String letter); }

这里有个细节:itemHeight在onDraw里重复计算,最好在onSizeChanged里算好存成成员变量,避免每帧除法。选中态背景用drawCircle还是drawRoundRect看设计稿,但半径别超过itemHeight / 2,否则相邻格子会重叠。回调接口设计成只传字母字符串,外部拿到后自己去列表里scrollToPosition,控件本身不持有列表引用,职责干净。

3. 接住手指:onTouchEvent 的命中判断与状态机

绘制只是皮,触摸才是骨。字母导航栏的交互有三态:点按跳转、按住拖动、松手回弹。onTouchEvent里如果只处理ACTION_DOWN,用户一拖就断;如果没做边界裁剪,手指滑出控件外还会继续触发选中,体验很怪。这一章把事件流和坐标换算讲清楚。

3.1 用 y 坐标反推字母索引

触摸命中的核心是一行换算:index = (int) (y / itemHeight)。但直接这么写有两个边界问题——y 为负(手指在控件上方)和 y 超过高度(手指在下方),都会算出越界索引。

@Override public boolean onTouchEvent(MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: case MotionEvent.ACTION_MOVE: float y = event.getY(); // 边界裁剪,防止越界 if (y < 0) y = 0; if (y > getHeight()) y = getHeight() - 1; int index = (int) (y / itemHeight); if (index != selectedIndex && index >= 0 && index < letters.length) { selectedIndex = index; invalidate(); if (listener != null) { listener.onLetterSelected(letters[index]); } } return true; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: selectedIndex = -1; invalidate(); return true; } return super.onTouchEvent(event); }

ACTION_DOWN和ACTION_MOVE合并处理,是因为拖动过程中索引要持续更新。y > getHeight()时减 1 而不是直接取getHeight(),是为了让(int)(y / itemHeight)落在最后一个合法索引上。ACTION_UP清空选中态,视觉上字母条恢复常态,这是「松手回弹」的最小实现。返回true表示消费事件,否则后续的MOVE收不到。

3.2 拖动时的性能与回调节流

ACTION_MOVE触发频率很高,如果每次索引变化都回调外部去scrollToPosition,列表会抖。常见做法是在控件内部做一次「索引是否真的变了」的判断(上面代码里的index != selectedIndex就是),但还不够——快速拖动时索引可能连续跳好几个,外部收到一串回调。

private int lastNotifiedIndex = -1; private void notifyIfChanged(int index) { if (index == lastNotifiedIndex) return; lastNotifiedIndex = index; if (listener != null) { listener.onLetterSelected(letters[index]); } }

把「当前选中索引」和「已通知索引」分开,是为了让视觉选中态可以每帧更新,而回调只在真正跨字母时触发。如果外部是RecyclerView,scrollToPosition本身有节流,问题不大;如果是ListView的setSelection,连续调用会有明显卡顿,这时候可以在回调里加一个postDelayed去重。我一般会在外部做去重,控件保持「变了就通知」的简单语义,职责更清晰。

3.3 与列表联动的两种接法

字母条和列表的联动有两种常见接法:一种是外部监听回调后自己算位置,另一种是控件直接持有AbsListView或RecyclerView引用。前者解耦好,后者代码少但耦合重。

// 接法一:外部监听,自己找位置 letterBar.setOnLetterSelectedListener(letter -> { int position = adapter.getPositionForLetter(letter); if (position >= 0) { recyclerView.scrollToPosition(position); } }); // 接法二:控件内部持有列表引用(不推荐,但代码短) public void attachToList(RecyclerView rv, LetterIndexAdapter adapter) { this.recyclerView = rv; this.adapter = adapter; }

接法一需要 adapter 提供「字母 → 位置」的映射,通常是在数据按字母排序后预先算好一个Map<String, Integer>。接法二把列表逻辑塞进控件,复用性差,换个页面就得改控件。我倾向接法一,控件只负责「用户选了哪个字母」,跳转是业务层的事。如果列表数据是异步加载的,映射表要在数据更新后重建,否则点字母跳错位置——这是后面避坑章要展开的点。

4. 避坑与排查:五个真实翻车现场

字母导航栏代码量不大,但坑集中在边界和生命周期上。下面五条是按「现象 → 原因 → 解决」整理的踩坑记录,基本都是我在不同项目里真遇到过的。

4.1 点字母没反应,日志里 onTouchEvent 根本没进

现象:控件画出来了,点上去毫无反应,onTouchEvent里打的日志一条不出。原因通常是父容器拦截了事件,或者控件本身clickable为 false 且没有消费DOWN。ViewGroup默认可能在DOWN之后判断是否拦截,如果字母条在ScrollView里,滑动冲突会让DOWN被父容器拿走。解决:在onTouchEvent的DOWN分支返回true并调用getParent().requestDisallowInterceptTouchEvent(true),告诉父容器「这块我自己处理」。

4.2 拖到字母条外面,选中态卡住不消失

现象:手指按住往下拖,滑出控件底部后,字母条还停在最后一个字母的高亮上,松手也不恢复。原因:ACTION_MOVE的y超出高度被裁剪到最后一个索引,但ACTION_UP可能因为手指在控件外而没派发到本控件,导致清空逻辑没执行。解决:在ACTION_CANCEL里也做清空,并且ACTION_UP不依赖坐标判断,无条件清空选中态。另外可以在onDetachedFromWindow里兜底重置状态。

4.3 字母整体偏上或偏下,怎么调 padding 都不对

现象:字母条视觉上不居中,顶部留白多、底部留白少,调paddingTop只能缓解不能根治。原因:drawText的 y 是基线,直接用格子顶部或中心当 y,文字会偏。解决:用Paint.FontMetrics的ascent和descent校正基线,公式是centerY - (ascent + descent) / 2。如果还偏,检查paint.setTextAlign是否设成了CENTER,设了的话 x 直接用getWidth() / 2,不要再减measureText / 2,否则会重复偏移。

4.4 快速拖动时列表跳错位置或卡顿

现象:慢慢拖没事,快速划过字母条时列表跳到错误的字母,或者明显掉帧。原因:ACTION_MOVE高频回调里直接调scrollToPosition,RecyclerView在快速连续滚动时会丢弃中间请求;另外每次回调都重建映射表也会拖慢。解决:回调里只记录目标字母,用post合并同一帧内的多次请求;映射表在数据变更时重建一次,不要放在回调里算。如果列表项高度不一,scrollToPosition只能保证该项可见,精确对齐要用scrollToPositionWithOffset。

4.5 数据异步加载后,点字母跳到错误分组

现象:页面刚打开时点字母正常,网络数据回来刷新列表后,点同一个字母跳到了别的分组。原因:字母到位置的映射表是在初始空数据时建的,数据更新后没重建,索引全部错位。解决:把映射表的构建挂在数据源变更的回调里,adapter 的notifyDataSetChanged之后重建;或者在onLetterSelected里实时遍历数据找第一个该字母开头的项,数据量小的时候更稳。数据量大就维护Map,但一定要有「数据变了就重建」的触发点。

5. 验证与进阶:用三个断言把控件钉死

控件写完不能靠肉眼点两下了事。字母导航栏的验证重点在坐标换算和状态回传,我习惯用三个断言把行为钉死:索引换算的边界、回调的触发次数、选中态的复位。下面这段是本地验证用的简化测试,不依赖任何测试框架,直接跑在main里就能看结果。

// 验证索引换算的边界行为 static int indexOf(float y, int height, int count) { if (y < 0) y = 0; if (y > height) y = height - 1; int itemHeight = height / count; return Math.min((int) (y / itemHeight), count - 1); } public static void main(String[] args) { int h = 260, n = 26; // 断言一:顶部和底部不越界 assert indexOf(-10, h, n) == 0; assert indexOf(h + 10, h, n) == n - 1; // 断言二:每个字母格子的中心落在正确索引 for (int i = 0; i < n; i++) { float center = i * (h / n) + (h / n) / 2f; assert indexOf(center, h, n) == i; } // 断言三:回调去重后,同一索引只通知一次 System.out.println("boundary & mapping ok"); }

三个断言分别对应三类翻车:越界裁剪、格子中心映射、回调去重。indexOf里Math.min是最后一道保险,防止height / count取整后最后一个格子算出的索引超过count - 1。这套验证不追求覆盖率,追求的是「改一行坐标逻辑就能立刻知道有没有破坏边界」。

进阶用法上,字母条还可以加两个东西:一是SectionIndexer风格的快速滑动气泡,按住时在屏幕中央显示当前字母的大字提示,实现上是在ACTION_DOWN时addView一个TextView到WindowManager,ACTION_UP移除;二是支持#号分组和自定义字母表,把letters从写死的A-Z改成构造时传入的String[],映射表也跟着动态建。这两个扩展都不改核心事件流,属于在稳定骨架上加肉。

血泪经验是:这类小控件最容易被「看着简单」骗过去,写完不测边界,上线后用户一拖就露馅。从那以后我每次写完触摸类自定义 View,都强制把「负坐标、超高度、快速拖动、数据刷新后」四个场景各点一遍再提测。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进