简介:Android城市选择器是一套面向Android开发者的可复用城市选择组件,模仿美团城市选择界面,适合电商、旅游、社交和生活服务类应用快速加入城市切换功能。压缩包共285个文件、约908KB,包含164个class、31个java源码、40个xml布局与资源、22个png图片,以及json配置、jar依赖和db数据等,覆盖界面资源、定位逻辑与模块调用所需文件,目录结构便于按功能查找。借助高德地图SDK完成地理位置定位,组件以独立模块和简洁API提供选择回调,开发者可参考源码调整城市数据、UI样式和交互细节。已有931人学习下载,适合需要快速集成城市选择能力或研究仿美团界面实现的初中级Android开发者参考。
1. 城市选择器不是“选个城市”:一个被低估的 Android 交互组件
做 App 的都遇到过这种需求:用户注册要选城市、电商下单要填收货地、租房按城市筛房源。看起来只是点几下屏幕的事,真动手写才发现一堆麻烦——城市数据从哪儿来?拼音索引和首字母悬浮怎么对齐?定位到的城市和用户手选的冲突怎么办?我见过不少项目直接在业务代码里塞一个AlertDialog加几个字符串,第二年新来的人维护时差点把键盘摔了。
Android 城市选择器就是在这种背景下被反复造轮子的组件。这份资源打包了一套可直接集成到 Android 工程里的城市选择器,核心能力包括城市列表、热门城市、字母索引侧栏、定位城市展示和关键词过滤。它的价值不在于算法多复杂,而在于把交互细节和数据组织方式整理成一套结构,让业务层只关心“拿到 cityId 和 cityName”,不用去管 UI 状态怎么流转。适合谁?正在做电商、出行、本地生活类模块的 Android 开发者,尤其是要在短期内同时处理定位选择、手动搜索和首字母快速定位三种入口的同学。
2. 界面结构与索引机制:先看懂城市列表是怎么拼出来的
2.1 列表数据的三种视图形态
城市选择器的界面在用户眼里是一根列表,在代码里其实是三种视图形态复用同一份数据源:首字母索引视图(A-Z 排序,带吸附头)、热门城市网格(固定九宫格或两行网格)、搜索结果视图(关键词匹配后的扁平列表)。这三者不是三套数据,而是同一份List<City>在不同状态下的投影。
常见做法是建一个CityGroup模型,每个分组持有首字母和该字母下的城市集合:
public class CityGroup { public String letter; // 首字母,如 "B" public List<City> cities; // 该字母下的城市 public boolean isHot; // 是否为热门城市分组 public CityGroup(String letter, List<City> cities) { this.letter = letter; this.cities = cities; } }letter不参与业务逻辑,只是渲染节头部的标识;cities是实际可点击项。热门分组通过isHot标记区分,渲染时换成网格布局。用分组结构的好处是侧栏索引的实现变得很直接——侧栏字母列表正是从letter字段派生的,两者天然同步,不会出现侧栏有 Z 而列表里没有 Z 开头的城市这种“翻车”情况。
2.2 拼音首字母的生成与降级策略
城市显示名多半是“杭州”“乌鲁木齐”这类中文,界面要按 A-Z 排序就得拿到拼音首字母。这里有个玄学问题:Android 自带的Collator只负责中文排序(按拼音或笔画,取决于 Locale),不直接给首字母。所以实际工程里通常两条路:
第一条是数据预处理,把每个城市的首字母写死在 JSON 或数组中,{"name":"杭州","pinyin":"hangzhou","letter":"H"}。第二条是运行时用第三方拼音库转换。我一般倾向于第一条,原因是体积换确定性:城市总量撑死几百条,硬编码或预装进 assets 也就几十 KB,而拼音库要处理多音字(比如“长安”的 cháng 和 zhǎng、“厦门”的 xià 和 shà),运行时转换容易翻车。
如果确实要用运行时转换,必须写降级逻辑——遇到转不出首字母的极端情况,把城市归到#分组,而不是抛异常或落到空分组里:
private String resolveLetter(City city) { String py = city.getPinyin(); if (py == null || py.trim().isEmpty()) { return "#"; // 无法识别首字母时统一归档,保证列表完整 } char c = py.charAt(0); if (c >= 'a' && c <= 'z') { return String.valueOf(c).toUpperCase(Locale.ROOT); } return "#"; }这里Locale.ROOT别改成Locale.CHINA,否则在某些机型上可能产生带区域变体的字符,后续做equals比较时对不上。这是血泪经验换来的一行固定写法。
2.3 侧栏索引与列表的联动
侧栏字母悬浮在右侧,用户上滑时跟着走,点击字母时列表跳到对应分组。实现有两种:重写RecyclerView的addItemDecoration画悬浮头,或者用LinearLayoutManager.scrollToPositionWithOffset精确滚动。前者负责视觉,后者负责定位,两个都要。
点击侧栏字母后,先找到该字母在分组列表里的索引,然后算目标位置。如果你用的是展开的扁平列表,索引就是可见位置;如果用的是分组结构,需要再加上头部占用的偏移:
private void scrollToLetter(String letter) { int groupIndex = -1; for (int i = 0; i < groups.size(); i++) { if (letter.equals(groups.get(i).letter)) { groupIndex = i; break; } } if (groupIndex < 0) return; // 计算该分组在扁平列表中的起始位置:前序所有分组条目数 + 分组头 int position = 0; for (int i = 0; i < groupIndex; i++) { position += groups.get(i).cities.size() + 1; // +1 为分组头 } layoutManager.scrollToPositionWithOffset(position, 0); }注意+1这个偏移量,它对应分组头。少加就会跳错一行,用户点击“H”却停在“G”的最后一个城市上,这种错位在小屏设备上非常明显。另外scrollToPositionWithOffset第二个参数是像素级的顶部偏移,如果界面里列表上方还有搜索框,要把搜索框高度减进去,否则目标分组会藏在搜索栏下面。
2.4 布局文件与关键视图
界面的骨架不复杂,一个垂直容器放搜索框、当前城市、热门城市、带索引的列表,再加一个右侧字母栏:
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> <EditText android:id="@+id/et_search" android:layout_width="match_parent" android:layout_height="wrap_content" android:hint="输入城市名或拼音" /> <androidx.recyclerview.widget.RecyclerView android:id="@+id/rv_city" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" /> <com.example.CityIndexBar android:id="@+id/index_bar" android:layout_width="24dp" android:layout_height="match_parent" android:layout_gravity="end" /> </LinearLayout>RecyclerView用0dp + weight=1占满剩余空间是最稳的,别给固定高度。索引栏宽度给 24dp 到 32dp 之间,太窄手指点不准,太宽遮挡列表内容。CityIndexBar是自定义 View,负责绘制字母和在onTouchEvent里把触点换算成字母索引。搜索框的inputType记得设成text,不要开textUri,否则部分输入法会把空格吃掉,导致“呼和 浩特”这类带空格的城市名搜不到。
3. 定位与搜索:交互入口的冲突如何处理
3.1 定位城市的展示与降级
城市选择器里通常有个“当前定位”区域,显示定位到的城市名,旁边一个“重新定位”按钮。这里的关键不是定位本身(那是地图 SDK 的事),而是定位结果如何与用户选择合并。
常见做法是城市选择器对外只暴露一个OnCitySelectedListener,定位成功时也不直接回调业务方,而是把定位城市作为“当前城市”显示在顶部,用户点了才算选中。原因是定位有失败率,直接回调会让业务层收到一个没经过用户确认的城市,后面下单地址就错乱了。
public interface OnCitySelectedListener { void onCitySelected(City city, boolean fromLocate); } // 定位成功后仅更新顶部展示,不触发业务回调 public void onLocateResult(String cityName) { currentCity.setText(cityName); currentCity.setTag(cityName); }fromLocate这个布尔值很有用,业务方可以用它区分“用户主动翻列表选的”和“点了定位城市条目选的”,比如前者可以静默保存,后者可能需要二次确认。定位失败时顶部显示“定位失败,点击重试”,不要显示空白,空白会让用户以为界面卡死了。
3.2 搜索过滤的匹配策略
搜索框的过滤逻辑要同时支持中文名和拼音。用户输入“hz”也能搜到“杭州”,输入“zhou”也能搜到,这靠的是每个城市对象上挂的拼音字段。
public List<City> filter(String keyword) { if (keyword == null || keyword.trim().isEmpty()) { return new ArrayList<>(); } String kw = keyword.trim().toLowerCase(Locale.ROOT); List<City> result = new ArrayList<>(); for (City city : allCities) { boolean nameHit = city.getName().contains(keyword.trim()); boolean pinyinHit = city.getPinyin() != null && city.getPinyin().toLowerCase(Locale.ROOT).contains(kw); // 同时匹配全拼和首字母缩写,覆盖 "hangzhou" 和 "hz" 两种输入 boolean abbrHit = city.getPinyinAbbr() != null && city.getPinyinAbbr().toLowerCase(Locale.ROOT).startsWith(kw); if (nameHit || pinyinHit || abbrHit) { result.add(city); } } return result; }nameHit用原始大小写的中文,pinyinHit统一小写后比较,避免大小写敏感导致“Hangzhou”搜不到。abbrHit用startsWith而不是contains,因为缩写匹配是前缀式的——输入“hz”应命中“杭州”,但输入“z”不应该把“杭州”也带出来,否则结果太泛会让用户困惑。搜索无结果时给个明确的空状态提示,不要只显示一片白。
3.3 数据持久化与最近访问城市
用户选过的城市要不要记住?如果产品有“最近访问”模块,就需要持久化。简单的方案是SharedPreferences存一个城市 ID 列表,限制长度 5 到 8 条,超出就淘汰最旧的。注意城市名称是会变的(行政区划调整),所以存 ID 为主、名称为辅,读取时用 ID 去数据源里反查当前名称,查不到就丢弃这条记录。如果本地数据源里没有 ID 字段,那就用城市名做键,但要接受“改名后记录失效”这个代价,别为了兼容去写复杂的模糊匹配,收益不成正比。
3.4 与业务层的解耦
好的城市选择器应该能独立成一个 module 或一个Activity,通过Intent传参、通过onActivityResult回传。传参时只需要一个“是否显示定位”的开关和一个“当前选中城市”的初始值,回传时给cityId、cityName、provinceName三个字段就够了。业务层不需要知道选择器内部用的是 RecyclerView 还是 ListView,也不需要知道侧栏怎么画。接口越窄,被复用到别的项目里就越省事,这一点在跨项目搬代码时体会最深。
4. 避坑排查:五个真实翻车场景
4.1 侧栏字母与列表分组对不上
现象:侧栏显示 26 个字母,列表里只有 23 个分组,点击某些字母没反应。
原因:侧栏字母是从静态字符串 “ABCDEFGHIJKLMNOPQRSTUVWXYZ” 硬编码生成的,而列表分组是从数据里实际统计出来的,两者数据源不一致。某些字母下恰好没有城市,就出现“有索引无内容”。
解决:侧栏字母必须由列表分组数据动态生成,遍历groups取letter去重排序。宁可侧栏少几个字母,也不要出现点击落空。
4.2 首字母悬浮头跳动或错位
现象:滑动列表时,顶部悬浮的字母头一闪一闪,或者停留在错误的位置。
原因:ItemDecoration里计算悬浮头位置时,没有考虑下一个分组头即将顶上来时的临界判断,导致悬浮头在两组之间的切换时机算错。
解决:在onDrawOver里先判断下一个分组头的顶部是否已经到达悬浮头位置,到了就让悬浮头跟着上移,用if (nextTop < headerHeight)做上推逻辑。这类错位通常是 1 到 2 像素的误差被放大了,逐帧打印坐标能很快定位。
4.3 定位权限缺失导致界面卡住
现象:进入页面后顶部一直显示“定位中”,等半天没有结果。
原因:这里的问题往往不在权限申请代码本身,而是定位 SDK 的回调在权限被拒后没有返回失败状态,界面就一直停在 loading。
解决:在调用定位前先检查权限是否授予,拒绝时立即把顶部状态置为“定位失败,点击重试”,并给重试按钮重新触发权限申请。不要依赖 SDK 的失败回调兜底,那个回调不一定按预期触发。
4.4 搜索输入法弹出后列表被遮挡
现象:点搜索框弹出键盘,下面的城市列表只剩一条缝,用户看不清结果。
原因:Activity 的windowSoftInputMode默认是adjustPan,整个窗口被推上去,列表可视区域被压缩。
解决:在AndroidManifest.xml里给城市选择页设置android:windowSoftInputMode="adjustResize",让列表高度自适应剩余空间;同时把搜索框固定在列表上方,搜索状态下把侧栏隐藏掉,减少视觉干扰。
4.5 快速滑动时索引栏触点漂移
现象:手指在侧栏快速上下滑动,选中的字母和手指实际位置差了一两格。
原因:onTouchEvent里用event.getY()直接除以字母高度,但当栏内加了 padding 或顶部偏移时没有扣除,导致换算比例偏移。
解决:先减去索引栏的顶部 padding,再除以单个字母的绘制高度,最后做Math.max(0, Math.min(index, letters.size() - 1))的越界保护。越界保护很关键,手指滑出控件边界时,未经保护的索引会越界抛异常。
5. 把城市选择器接进真实工程:几个值得养成的习惯
集成阶段最容易忽略的是数据源的组织方式。我一般会把城市数据放在assets/cities.json,应用启动时异步读取一次,解析成List<City>后缓存到内存。别每次打开选择器都重新读文件,几百条 JSON 在小内存机型上也能感觉到卡顿。
[ {"id": "330100", "name": "杭州", "pinyin": "hangzhou", "abbr": "hz", "letter": "H", "hot": true}, {"id": "110100", "name": "北京", "pinyin": "beijing", "abbr": "bj", "letter": "B", "hot": true} ]字段上id用于业务关联,pinyin用于搜索,abbr用于首字母缩写匹配,letter用于分组,hot标记热门。把abbr单独存一列而不是运行时截取,是为了处理“重庆”这种 chóngqìng 但缩写习惯上更常写作 cq 的情况——直接截首字取 c、第二个字取 q 也能得到 cq,但遇到“长沙”(changsha,缩写 cs)和“长春”(changchun,也常写 cc)时,规则化截取不一定符合各地习惯,预存一列能省掉后续函数维护。
验证集成是否到位,我通常做三步:一是断网打开选择器,确认列表和搜索可用,定位区域显示失败态;二是用两台不同分辨率的设备滑动侧栏,确认点击落点一致;三是把系统语言切成英文,确认拼音搜索仍能返回结果,中文城市名不出现乱码。第三步很多人会漏,toLowerCase时没带Locale.ROOT的话,土耳其语环境下 “I” 转小写会变成 “ı”,虽然是极端情况,但测试成本很低。
如果项目里已经有全局的城市数据服务,城市选择器应该只负责 UI 和交互,数据通过构造参数或接口注入。硬编码数据源看似省事,等到城市列表要更新时才发现得改代码重新发版,这个坑很常见。组件边界画清楚,后面换数据源、换定位实现时,改动都只落在一个地方。
从那以后我每次接这类选择器组件,都会先跑一遍断网、多分辨率、切语言这三项,哪怕业务方催得再紧也不跳过——城市选错一个,后面的地址、运费、配送范围全跟着错,这种错误上线后修复的成本远高于测试十分钟。希望帮到你。
本文还有配套的精品资源,点击获取