资讯详情

Android城市选择器:RecyclerView字母索引与吸顶实现

📅 2026/11/2 16:47:29 | 华诺云谱 👁 阅读
Android城市选择器:RecyclerView字母索引与吸顶实现
简介Android城市选择器是一份面向Android应用开发者的可复用组件资源界面仿照美团城市选择页设计并接入高德地图SDK完成地理位置定位能快速为电商、旅游、社交及生活服务类应用补上城市选择这一常见功能。压缩包共285个文件、约908KB以class编译产物和java源码为主体辅以xml布局与样式资源、png图片、json配置、jar依赖另含少量db城市数据与gradle构建脚本模块划分完整便于直接引入工程调用。资源已有931人学习下载。其价值在于把界面、定位与数据封装为独立模块内部接口简洁开发者通过回调即可获取用户选中的城市并能按需替换城市数据、调整UI样式省去从零编写与反复调试的成本对初中级开发者尤为省时也适合作为模块化封装的参考范例。1. 城市选择器看着简单上线后最先被投诉的往往是它城市选择器在需求文档里常常只占一行用户可选择所在城市。真正做起来才知道拼音排序、多音字、字母索引、吸顶分组、搜索联想、定位回填、热门城市配置一样都不能少其中任何一个环节出问题用户就会卡在注册或下单的第一步。这个 Android 城市选择器把这些功能拆成了几个独立模块assets 里一份城市 JSON、做拼音预处理的 Repository、多类型 RecyclerView Adapter、侧边字母索引条、吸顶 ItemDecoration。它适合两类人正缺一个能直接嵌进 App 的城市选择页的开发者以及想搞清楚“字母索引 吸顶”这套组合在 RecyclerView 上怎么落地的人。2. 架构与数据流一次选择背后的三个角色整个选择器可以拆成三个角色分清这三个角色后面改代码就不会乱。数据角色负责把一份原始城市列表读进内存做三件事补拼音、算首字母、排序最后产出一个“已经排好、带索引”的集合放在内存里复用。列表角色负责把这堆数据拍平成一维的 Row 列表交给 RecyclerView 渲染同时维护“字母 → position”的映射。交互角色只有两个入口侧边索引条的触摸事件和搜索框的文本变化它们最终都收敛成一句“滚到某个 position”或“重新提交一份数据”。这个分法的好处是换数据源只动数据角色换 UI 只动列表角色加个“最近选择”只加一种 Row 类型。下面按目录、数据、出参三块拆开看。2.1 目录结构四层分包与 assets 里的城市数据拿到工程先看目录比看 Activity 里的几百行代码高效得多。典型的分包是这样citypicker/ ├── src/main/ │ ├── assets/ │ │ └── city.json # 原始城市数据全量约几百条 │ ├── java/.../citypicker/ │ │ ├── CityPickerActivity.java # 唯一入口负责收参数、回传结果 │ │ ├── model/City.java # 城市实体 │ │ ├── model/Row.java # 扁平化后的列表行多类型包装 │ │ ├── data/CityRepository.java # 读 assets、拼音预处理、内存缓存 │ │ ├── ui/CityAdapter.java # 多类型 Adapter │ │ ├── ui/SideBar.java # 侧边字母索引条自定义 View │ │ ├── ui/StickyHeaderDecoration.java # 吸顶分组头 │ │ └── util/PinyinHelper.java # 拼音转换 多音字修正表 │ └── res/layout/activity_city_picker.xmlCityPickerActivity是唯一对外暴露的类其他都是内部实现这个边界要守住。我见过有人把CityAdapter直接暴露给业务层改数据结果列表数据和缓存数据出现两份状态选完城市再进来就乱了。保持“业务层只认识 Activity 的入参和出参”这一条后面不管怎么重构都不影响调用方。assets/city.json放原始数据而不是写成 Java 常量数组原因很实际城市数据会变换文件不用重新编译也能按渠道下发不同版本。数据格式长这样字段刻意保持少[ { name: 城市A, code: 100001 }, { name: 城市B, code: 100002 }, { name: 城市C, code: 100003 } ]name是展示用code是回传给业务的唯一标识。拼音不进 JSON因为拼音可以由name算出来写死在数据里一旦改名就得同步改两处迟早对不上。2.2 数据模型与拼音索引一次预处理解决排序问题City实体只多两个字段都标transient语义不参与序列化public class City { private String name; // 展示名 private String code; // 回传用的唯一标识 private String pinyin; // 全拼小写如「chengshia」运行时生成 private String firstLetter; // 首字母大写 A-Z非字母统一归到 # // getter/setter 省略 }接下来是整篇最核心的一段预处理它决定了列表顺序和索引条能不能对上public static void sortAndIndex(ListCity raw) { for (City c : raw) { // 1. 转全拼第二个参数传空串表示不要分隔符 String py PinyinHelper.toPinyin(c.getName()); py fixPolyphone(c.getName(), py); // 2. 多音字修正见下文 c.setPinyin(py); // 3. 取首字母非 A-Z 一律归到 # char f py.isEmpty() ? # : Character.toUpperCase(py.charAt(0)); c.setFirstLetter(f A f Z ? String.valueOf(f) : #); } // 4. 先按首字母再按全拼保证同组内也是有序的 Collections.sort(raw, (a, b) - { if (#.equals(a.getFirstLetter())) return 1; // # 永远沉底 if (#.equals(b.getFirstLetter())) return -1; int r a.getFirstLetter().compareTo(b.getFirstLetter()); return r ! 0 ? r : a.getPinyin().compareTo(b.getPinyin()); }); }这里有几处参数和逻辑值得说明。toPinyin的第二个参数是分隔符传空串得到连写的chengshia这样startsWith做前缀搜索时不用再去空格。Collections.sort用的比较器是稳定排序同一首字母内再按全拼排索引条才不会出现“点 C 进去看到的是乱序”的尴尬。多音字单独留一个修正表这是血泪经验自动转换对多音字城市名的判断基本靠猜某些读cháng的城市会被转成zhǎng直接排到 Z 组去用户点 C 永远找不到。修正表就是一个MapString, String命中就覆盖private static final MapString, String POLYPHONE new HashMap(); static { // key 是城市名value 是正确全拼数据量很小几十条足够 POLYPHONE.put(多音字城市甲, changs); POLYPHONE.put(多音字城市乙, chongq); } private static String fixPolyphone(String name, String auto) { String fixed POLYPHONE.get(name); return fixed ! null ? fixed : auto; }整个预处理只在 Repository 第一次初始化时跑一次结果缓存在内存里后面每次打开选择页都是直接读缓存。几百条数据做一次排序的耗时在个位数毫秒但如果你每次onCreate都重跑一遍加上 JSON 解析冷启动就能明显感觉到卡顿。2.3 出入参设计Intent 里到底该传什么调用方只关心两件事怎么打开、选完怎么拿结果。// 打开 public static void start(Activity activity, int requestCode) { Intent i new Intent(activity, CityPickerActivity.class); i.putExtra(EXTRA_SHOW_LOCATE, true); // 是否展示定位行 i.putExtra(EXTRA_SHOW_HOT, true); // 是否展示热门城市宫格 i.putExtra(EXTRA_TITLE, 选择城市); // 标题文案 activity.startActivityForResult(i, requestCode); } // 回传 private void finishWith(City city) { setResult(RESULT_OK, new Intent() .putExtra(EXTRA_CITY_NAME, city.getName()) .putExtra(EXTRA_CITY_CODE, city.getCode())); finish(); }只回传name和code不要把整个City对象塞进 Intent。原因有两点一是City实现了Serializable之后字段一改就容易在跨进程时踩到BadParcelableException二是业务层真正需要的就是标识和展示名多传反而诱导别人去依赖内部字段。EXTRA_TITLE这类纯展示参数走 Intent 而不是主题配置是为了让同一个选择器能在“注册页”和“收货地址页”复用出不同文案不用为了改四个字去新建一个 Activity。3. 核心模块拆解Adapter、索引条、吸顶头三件套这一章是整篇最需要照抄的部分。三个模块互相咬合Adapter 提供 position 映射SideBar 消费这个映射去滚动ItemDecoration 在上层画吸顶头。任何一处对不上用户看到的就是“点了没反应”或者“点 C 跳到 D”。3.1 多类型 Adapter把定位、热门、分组头、城市拍平成一维列表里其实有四类内容但 RecyclerView 只认 position。最稳的做法是先拍平成一个Row列表position 就是唯一真相索引映射也从这里长出来public class CityAdapter extends RecyclerView.AdapterRecyclerView.ViewHolder { public static final int TYPE_LOCATE 0; // 当前定位 public static final int TYPE_HOT 1; // 热门城市宫格 public static final int TYPE_HEADER 2; // 字母分组头 A/B/C public static final int TYPE_CITY 3; // 单个城市 private final ListRow rows new ArrayList(); private final MapString, Integer letterIndex new HashMap(); /** 全量提交定位 热门 已排序城市 */ public void submit(ListCity sorted, ListCity hot, City located) { rows.clear(); letterIndex.clear(); if (located ! null) rows.add(Row.locate(located)); if (hot ! null !hot.isEmpty()) rows.add(Row.hot(hot)); String last null; for (City c : sorted) { if (!c.getFirstLetter().equals(last)) { last c.getFirstLetter(); letterIndex.put(last, rows.size()); // 关键记住分组头的 position rows.add(Row.header(last)); } rows.add(Row.city(c)); } notifyDataSetChanged(); } Override public int getItemViewType(int position) { return rows.get(position).type; } Override public int getItemCount() { return rows.size(); } /** 索引条查询入口字母 - 分组头 position */ public Integer getPositionForLetter(String letter) { return letterIndex.get(letter); } /** 吸顶用向上找最近的、且 index position 的分组头 */ public int headerPosAbove(int position) { int best -1; for (Integer p : letterIndex.values()) { if (p position p best) best p; } return best; } }letterIndex这张表是整章的关键。它记录的是“分组头在 rows 里的下标”不是“字母在字母表里的下标”这两者的区别就是后面索引错位的根源。submit里先 clear 再重建保证搜索态和正常态共用一套数据结构不会出现“搜索完清空关键字索引条还是旧映射”的情况。headerPosAbove用遍历找最大值看着笨但letterIndex最多 27 项一次遍历的开销可以忽略换来的好处是不用额外维护一个有序 position 列表。3.2 侧边字母索引条从触摸坐标到 scrollToPositionWithOffsetSideBar是一个纯自定义 View只干两件事把触摸的 Y 坐标换算成字母下标把字母回调出去。public class SideBar extends View { private final ListString letters new ArrayList(); private float itemHeight; private int lastIndex -1; private OnLetterTouchListener listener; Override protected void onDraw(Canvas canvas) { itemHeight getHeight() * 1f / letters.size(); // 均分高度 for (int i 0; i letters.size(); i) { String letter letters.get(i); float cy itemHeight * i itemHeight / 2; // 省略paint 设置字号、居中计算、高亮当前字母 canvas.drawText(letter, getWidth() / 2f, cy, paint); } } Override public boolean onTouchEvent(MotionEvent e) { switch (e.getActionMasked()) { case MotionEvent.ACTION_DOWN: // 按下先抢事件避免父容器RecyclerView 的滑动把它吃掉 getParent().requestDisallowInterceptTouchEvent(true); // fall through case MotionEvent.ACTION_MOVE: int idx (int) (e.getY() / itemHeight); idx Math.max(0, Math.min(idx, letters.size() - 1)); if (idx ! lastIndex) { lastIndex idx; listener.onLetter(letters.get(idx)); } return true; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: lastIndex -1; getParent().requestDisallowInterceptTouchEvent(false); listener.onLetterUp(); return true; } return true; } public interface OnLetterTouchListener { void onLetter(String letter); void onLetterUp(); } }requestDisallowInterceptTouchEvent(true)这一句不能省。手指按在索引条上往下滑父容器默认会尝试拦截成滚动表现就是“索引条滑到一半卡住列表纹丝不动”。加上这句之后事件在按下期间完全归SideBar管松手再还给父容器。idx记得做clamp。手指滑出控件边界时e.getY()会变成负数或者大于高度不夹一下就会IndexOutOfBounds线上偶现崩溃大多是这个。外部接上sideBar.setOnLetterTouchListener(new SideBar.OnLetterTouchListener() { Override public void onLetter(String letter) { Integer pos adapter.getPositionForLetter(letter); if (pos null) return; // 该字母没有城市直接忽略 LinearLayoutManager lm (LinearLayoutManager) rv.getLayoutManager(); lm.scrollToPositionWithOffset(pos, 0); // offset 传 0分组头顶到顶部 } Override public void onLetterUp() { letterOverlay.setVisibility(View.GONE); // 中间那个大字母提示浮层 } });用scrollToPositionWithOffset(pos, 0)而不是scrollToPosition(pos)。后者只保证目标项可见可能是贴底出现分组头就跑到屏幕下半部分去了用户会觉得“点了但没跳到位”。offset传 0 表示让这一项的顶部和列表顶部对齐配合吸顶头看起来才自然。3.3 ItemDecoration 画吸顶分组头吸顶头不占 Adapter 的 position画在onDrawOver里最干净不会打乱letterIndexOverride public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) { final int top parent.getPaddingTop(); final int childCount parent.getChildCount(); if (childCount 0) return; int firstPos parent.getChildAdapterPosition(parent.getChildAt(0)); int headerPos adapter.headerPosAbove(firstPos); if (headerPos 0) return; // 下一个分组头顶上来时把当前吸顶头往上推 int offset 0; for (int i 0; i childCount; i) { View child parent.getChildAt(i); int pos parent.getChildAdapterPosition(child); if (pos headerPos 1 child.getTop() top headerHeight) { offset child.getTop() - (top headerHeight); // 负值 break; } } c.save(); c.clipRect(parent.getLeft(), top, parent.getRight(), parent.getBottom()); c.translate(0, offset); drawHeader(c, parent, headerPos, top); // 内部按 headerPos 取字母并绘制背景文字 c.restore(); }三处细节clipRect必须加否则吸顶头会在列表滑动到边缘时画到工具栏外面去translate的offset是负值靠“推开”实现两个分组头交替的视觉效果headerHeight要和分组头 item 的layout_height保持一致改了一边忘了另一边就会出现一条细缝或者压住第一个城市。3.4 搜索过滤汉字、全拼、首字母三路命中搜索不做单独页面直接复用submitpublic void filter(String keyword) { if (TextUtils.isEmpty(keyword)) { adapter.submit(allSorted, hotCities, locatedCity); // 恢复全量 return; } String k keyword.trim().toLowerCase(); ListCity hits new ArrayList(); for (City c : allSorted) { boolean hit c.getName().contains(keyword) // 汉字直接匹配 || c.getPinyin().startsWith(k) // 全拼前缀如 cheng || c.getPinyin().contains(k) // 全拼包含如 shia || c.getFirstLetter().equalsIgnoreCase(k); // 单字母 if (hit) hits.add(c); } adapter.submit(hits, null, null); // 搜索态不展示定位和热门 }搜索态传null给热门和定位是为了让结果列表只有一种语义——“匹配到的城市”。混着定位行会让用户困惑我搜的是这几个字为什么第一行是定位城市。4. 跑起来从工程导入到真机验证的完整步骤代码看懂只是第一步真把它跑起来还会遇到几个配置问题。这一章按顺序走一遍每一步都给出验证方法出错能立刻定位。4.1 工程导入与依赖配置如果是作为 module 引入settings.gradle里加一行然后在主 module 里依赖// settings.gradle include :app, :citypicker // app/build.gradle dependencies { implementation project(:citypicker) implementation androidx.appcompat:appcompat:1.6.1 implementation androidx.recyclerview:recyclerview:1.3.2 // 拼音库按项目已有选型替换体积敏感用轻量版多音字要求高用词库版 // implementation 你的拼音库:版本号 }minSdk至少 21因为ItemDecoration的onDrawOver和scrollToPositionWithOffset在低版本上行为差异较大。compileSdk跟着主工程走别单独拔高否则会出现“主工程编译过了选择器 module 报资源找不到”的怪现象。如果只想要 aar把 module 的build.gradle里com.android.library保持不动执行./gradlew :citypicker:assembleRelease产物在citypicker/build/outputs/aar/下。aar 方式要注意assets会一起打进去不用额外拷贝。4.2 城市数据落位与首次运行city.json必须放在citypicker/src/main/assets/下不是res/raw。放res/raw的话资源 ID 会被合并多个 module 里有同名文件时可能读到你没预期的那个。读取代码里加个兜底第一次跑就能看出数据有没有到位private ListCity loadFromAssets() { try (InputStream is context.getAssets().open(city.json)) { String json readAll(is); ListCity list new Gson().fromJson(json, new TypeTokenListCity(){}.getType()); CityRepository.sortAndIndex(list); Log.d(CityPicker, loaded list.size() cities, first list.get(0).getName()); return list; } catch (IOException e) { Log.e(CityPicker, city.json missing, e); return Collections.emptyList(); // 兜底宁可空列表也别崩 } }第一次运行看 logcat如果打出loaded 0 cities八成是 assets 目录层级错了多套了一层assets/assets/或者 module 的sourceSets被改过。4.3 端到端验证用日志把一次选择走完功能跑通不等于逻辑对。加三处日志能把整条链路串起来// 1. 数据加载 Log.d(CityPicker, load size all.size()); // 2. 索引跳转 Log.d(CityPicker, letter letter - pos pos); // 3. 结果回传 Log.d(CityPicker, select name city.getName() code city.getCode());用命令行过滤adb logcat -s CityPicker:D验证顺序建议是先滑动列表确认分组头和城市对应滑到 C 组第一个城市首字母得是 C再点索引条逐字母跳看跳过去的第一个可见项是不是对应分组头最后搜索两个字的城市名清空关键字确认列表能完整恢复。这三步过了基本功能就站住了。4.4 常用配置项怎么调需求改哪里注意隐藏定位行EXTRA_SHOW_LOCATE传 false同时别再请求定位权限换热门城市CityPickerActivity里的热门数组数组元素必须能在全量数据里找到否则点击无效改索引条字母集CityRepository生成后过滤只保留数据里真实存在的字母避免点了没反应换标题栏样式activity_city_picker.xml别改 RecyclerView 的 id代码里按 id 取5. 避坑与排查索引错位、跳组异常、数据翻车这一章记录的都是我实际踩过的按“现象 → 原因 → 解决”写遇到对得上的直接照方抓药。5.1 索引条与滚动现象点索引条上的 S列表跳到了 R 组开头往后每个字母都偏一组。原因把“字母在字母表里的下标”当成了“分组头在列表里的 position”。字母表里 A 是 0但列表前面还有定位行和热门宫格两者根本不是一个坐标系。解决一律走adapter.getPositionForLetter(letter)取映射不要在 Activity 里自己算下标。同时确保letterIndex是在submit里、拍平之后记录的。现象手指在索引条上快速滑动列表只跳了第一个字母后面全没反应。原因ACTION_MOVE被父容器拦截成了滚动。ACTION_DOWN里没调requestDisallowInterceptTouchEvent(true)或者只在SideBar内部处理了但父容器是NestedScrollView包着 RecyclerView拦截发生在更外层。解决在ACTION_DOWN里对getParent()调用一次ACTION_UP/ACTION_CANCEL里还原。如果是嵌套滚动把SideBar的父容器换成最外层的那个可滚动 View。现象跳到某个字母后吸顶头和上面的分组头重叠出现两层字母叠在一起。原因吸顶头的headerHeight和分组头 item 的实际高度不一致offset算出来的推挤距离偏小两个头短暂共存。解决把headerHeight抽成一个常量分组头 item 的layout_height引用同一个值或者用dimen改一处两边同步。别在布局里写死48dp而在代码里写50。5.2 数据与搜索现象某些多音字城市点 C 找不到点 Z 反而能找到。原因拼音库按默认读音转换把读cháng的城市转成了zhǎng首字母算成了 Z。解决在PinyinHelper里维护修正表转换完成后覆盖。加新数据时把所有首字母和实际读音对不上的城市补进表里这是唯一稳妥的办法。现象搜索框输入关键字列表筛出结果后清空输入列表只剩下几个城市回不到全量。原因filter里对allSorted做了原地Collections.sort或者直接retainAll把源数据改了。源数据一旦被污染后面的“恢复全量”恢复的其实是被改过的数据。解决filter只读allSorted把命中结果放进新的ArrayList再提交。所有对源数据的修改都只在CityRepository初始化时发生一次之后全部只读。6. 二次开发换成网络数据源与加多选真正落地时最常见的两个改造是“城市数据要走服务端下发”和“要支持多选”。换数据源的关键在 Repository 分层别去动 Adapter。做法是先读 assets 兜底渲染再异步请求网络拿到新数据后比对版本号不同才刷新public void load(LoadCallback cb) { ListCity local cache ! null ? cache : readFromAssets(); cb.onReady(local, false); // 先给一份能用的 api.fetchCities(localVersion, resp - { if (resp.version localVersion) return; // 版本没变不折腾 CityRepository.sortAndIndex(resp.cities); // 统一走同一套预处理 cache resp.cities; localVersion resp.version; cb.onReady(cache, true); // 再刷一次标记为远端数据 }); }这里有个容易忽略的点远端数据拿回来必须先过sortAndIndex。我见过直接submit(remoteList, ...)的写法后果是索引条映射建在未排序的数据上点 A 跳到一半乱序的位置。多选改造涉及三个位置列个表对照着改改造点文件要点选中状态CityAdapter维护SetString selectedCodesonBindViewHolder里按 code 判断回传结构CityPickerActivityputStringArrayListExtra(EXTRA_CITY_CODES, codes)完成按钮布局 Activity单选时点条目直接返回多选时点条目只切状态底部按钮才返回验证多选有个技巧选中 3 个城市返回上一页再重新打开选择器看是否保留上次选中。如果不保留说明你把状态存在了 Activity 局部变量里应该放到 ViewModel 或者跟调用方约定好传入。这个场景很像某些“后悔药”需求用户选完发现选错了退回来状态丢了就得从头点一遍。最后一条经验这类“列表 索引 吸顶”的控件我每次接都会先把两件事写进设计稿再动 UI——数据怎么拍平成 position以及 position 和业务标识之间怎么映射。UI 画错了改起来是分钟级映射建错了排查起来是小时级。从那以后我每次做列表类控件都强制自己先在纸上把这两张映射表画出来再写第一行Adapter。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

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

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

你可能需要的服务

订阅华诺云谱资讯周报

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

↑