news 2026/10/16 15:09:22

Android城市选择器:自绘WheelView实现省市区三级联动

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Android城市选择器:自绘WheelView实现省市区三级联动

简介:面向Android开发者的城市选择器组件,界面模仿美团App,基于高德地图API实现地理定位,适用于电商、旅游、社交、生活服务等需要地区选择的项目,尤其适合希望快速集成城市列表与定位功能的团队或个人开发者。资源以ZIP压缩包提供,共285个文件,整体仅908KB,包含164个class编译文件、31个java源文件、40个xml布局与资源描述、22个png图标素材,以及少量JSON数据、数据库、依赖jar和Gradle配置等,源码与构建产物结构清晰,便于直接集成或二次开发。该资源在CSDN已有929人学习下载,作者为xiaojye。借助模块化设计和简洁API,开发者可快速完成初始化、数据配置、选择器展示与城市回传;预览中可见CityPickerActivity、样式与属性定义等模块,配合示例代码可降低实现门槛,避免重复搭建基础功能,适合Android初、中级开发者学习参考并快速接入项目。

1. 城市选择器:看着简单,落地才知道要拆三层

做收货地址页的时候,产品扔过来一句「就加个城市选择器,跟通讯录那种滚轮差不多」。真正落地才发现,这个看起来几十行能搞定的组件,要跑得稳,得把自定义绘制、惯性滚动、三级联动、数据时序全捋一遍,代码量轻松破千。这篇文章拆的正是这样一个 Android 城市选择器:省市区三级滚轮联动,支持默认值回显、热门城市、数据源替换,能直接接进注册、下单、收货地址等场景。适合想照着跑通,再做搜索、定位这类二开功能的 Android 开发者。

2. 技术选型与模块结构:三条滚轮背后的分层设计

城市选择器表面上是三个滚轮叠在一起,实际拆开是四件事:可滚动且能回弹的轮子、省市区树形数据、负责联动编排的控制器、承载它的弹窗。整套组件我习惯分成 data、widget、picker 三层,轮子不感知业务,数据不依赖 UI,弹窗只做编排,这样后续改搜索、换数据源都不用动核心绘制代码。

2.1 方案对比:为什么用自绘 WheelView 而不是 RecyclerView

很多团队第一反应是用 RecyclerView 加 SnapHelper 来做滚轮,毕竟 RecyclerView 已经是现成的滚动容器。实际做下来会发现几个别扭的地方:选中项要放大加粗,SnapHelper 默认只对齐不解决缩放;多列联动时每次数据变化都要重新计算滚动位置;循环滚动(第一个滑到最后一个)在 LinearLayoutManager 里要做大量边界处理。自绘 WheelView 把每条数据当成固定行高绘制,选中线的位置恒定不变,偏移量直接换算成下标,滚动对齐的回弹完全由自己控制,反而更容易调出 iOS 那种「稳、准、能停住」的手感。

对比项自绘 WheelViewRecyclerView 改造
选中项放大/变淡直接改 Paint 与字号需要监听滚动偏移逐项计算
多轮联动重置直接替换数据数组需要处理 LayoutManager 复用
无限循环取模即可要包很大的虚拟数据集
实现成本核心绘制约两百行依赖理解较深,边界条件多

我最终选了自绘方案,核心就一个自定义 View。它的绘制输入只有三个东西:数据列表、当前选中下标、滚动偏移量,输出是在画布上按位置画出文字;滚动则由 Scroller 驱动,松手后统一收归到最近的下标上。

2.2 工程目录:三层边界各管什么

这套组件的目录结构是这个样子:

citypicker/ ├── data/ │ ├── RegionEntity.java # 省市区通用树节点 code/name/children │ ├── RegionDataSource.java # 数据源接口:loadRoot/loadChildren/findByCode │ └── AssetRegionDataSource.java # 默认实现,解析 assets 下的 region.json ├── widget/ │ └── WheelView.java # 自绘滚轮,只负责画和滚,不感知业务 └── picker/ ├── LinkagePickerDialog.java # 弹窗容器,持有三个 WheelView ├── PickerConfig.java # 全部可调参数 └── OnCityPickedListener.java # 选中结果回调

widget 层是纯控件,不知道自己在显示省还是区;data 层只提供树节点的增删查;picker 层负责把三者按联动规则拼起来。我用接口 RegionDataSource 把数据源挡在外面,意味着 assets JSON 只是默认实现,将来换数据库或接口下发,U 层代码一行不用改。

2.3 数据层设计:一份通用树 JSON 打天下

region.json 我采用的是完全不区分「省市区」语义的通用树结构,每一层都是 code、name、children:

[ { "code": "P001", "name": "示例省", "children": [ { "code": "C001", "name": "示例省会", "children": [ { "code": "D001", "name": "示例市辖区" }, { "code": "D002", "name": "示例高新区" } ] } ] } ]

解析时按深度取用:第一层是省,第二层是市,第三层是区,谁家有 children 谁就能继续展开,没有的自动作为叶子节点。这样设计有个额外好处——某些特殊行政区划数据本身就只有两级,用这套结构不会出现第三级空转的问题。

数据在 Provider 里只解析一次,同时维护两个索引:code 到 node 的映射、code 到完整路径的映射。前者给默认值回显用,后者给搜索命中后跳转用。热门城市的实现也很简单,数据文件里单独放一个 hotCodes 数组,Provider 提供对应的查询方法即可,不需要为热门城市单独建表。

3. 核心模块拆解:一次滚动背后的完整链路

从手指按住滚轮滑动,到选中结果回调给页面,中间经历了触摸事件、惯性滚动、按行对齐、联动刷新四段逻辑。这四段也是整个组件最容易写崩的地方,我一个一个说。

3.1 WheelView 的绘制与惯性回弹

WheelView 的绘制核心是算出「当前这一帧应该显示哪几个 item、各自在什么位置」。这里的关键变量是 itemHeight 和滚动偏移 mScrollY,前者决定一条数据的行高,后者表示累计滚动距离,二者相除得到当前下标。绘制时只画可见条数,避免无谓的 canvas.drawText:

@Override protected void onDraw(Canvas canvas) { int centerIndex = getCurrentIndex(); // 由 mScrollY / itemHeight 计算 int half = visibleCount / 2; for (int i = -half; i <= half; i++) { int pos = (centerIndex + i + dataSize) % dataSize; // 循环滚动取模 String text = dataList.get(pos).getName(); float centerY = getHeight() / 2f + i * itemHeight - (mScrollY % itemHeight); // 余数补偿,保证行平滑移动 float ratio = Math.abs(i) / (float) half; // 距离选中线越远越小越淡 paint.setAlpha((int) (255 * (1 - ratio))); paint.setTextSize(centerTextSize - (int) (8 * ratio)); // 选中放大 drawTextCentered(canvas, text, centerY); } }

代码里最容易被忽略的是第三行取模的负号处理:当 centerIndex 小于 half 时,centerIndex - half会变成负数,直接对 dataSize 取模在 Java 里得到的还是负数,必须先加一次 dataSize 再做取模。另外mScrollY % itemHeight这个余数,是滚动动画没停在下标整数位时的视觉补偿,没有它滚轮会一跳一跳地移动。

触摸侧的处理是标准模板:ACTION_MOVE 累加偏移并 invalidate,ACTION_UP 时用 VelocityTracker 算出纵向速度交给 Scroller:

case MotionEvent.ACTION_UP: velocityTracker.computeCurrentVelocity(1000); int velocityY = (int) velocityTracker.getYVelocity(); scroller.fling(0, mScrollY, 0, velocityY, 0, 0, Integer.MIN_VALUE, Integer.MAX_VALUE); postInvalidateOnAnimation(); break;

fling 结束后的回弹对齐是用户手感的最后一步。Scroller 跑完后 mScrollY 大概率停在一个非整数倍 itemHeight 的位置,这时计算余数,余数小于半个行高就回退,否则前进补满:

@Override public void computeScroll() { if (scroller.computeScrollOffset()) { mScrollY = scroller.getCurrY(); if (scroller.isFinished()) { int remain = mScrollY % itemHeight; int target; if (remain < itemHeight / 2) { target = mScrollY - remain; } else { target = mScrollY + (itemHeight - remain); } scroller.startScroll(mScrollY, 0, target - mScrollY, 0, 200); } invalidate(); } }

对齐这一步有个玄学点:remain恰好等于半个行高的边界情况容易来回抖。我一般会在回退和补进之间留一个容差判断,余数小于 2 像素时直接按当前值归零处理,不触发二次滚动,这一条翻车率极高,后面避坑章还会再提。

3.2 三级联动:重置顺序决定体验

三个轮子各自独立滚动,联动逻辑集中在 LinkagePickerDialog 里。规则很简单:省滚轮变化时,城市列和区县列的数据要重建,且默认选到第一位。真正麻烦的是「重置动作引发的回调」与「用户动作引发的回调」互相打架。

provinceWheel.setOnSelectedListener(new WheelView.OnSelectedListener() { @Override public void onSelected(int position, boolean fromUser) { if (!fromUser || isInternalReset) return; // 只响应真实用户操作 RegionEntity province = provinces.get(position); cities = dataSource.loadChildren(province.getCode()); isInternalReset = true; // 压制下级回调 cityWheel.setDataList(cities); cityWheel.setSelectedPosition(0, false); districts = dataSource.loadChildren(cities.get(0).getCode()); districtWheel.setDataList(districts); districtWheel.setSelectedPosition(0, false); isInternalReset = false; } });

参数说明:fromUser 是 WheelView 区分「用户滚到某项」和「代码 setSelectedPosition」的关键标志,没有它程序重置也会触发联动,造成死循环;isInternalReset 是 Dialog 层的总开关,因为 setDataList 内部会隐式重置到 0,也会再冒一次回调。

这里有一个容易漏的细节:如果从「示例省」切到另一个市数量恰好相同的省,城市列数据确实是变了,但 UI 上因为滚动位置没变、长度没变,肉眼看不出刷新。所以我要求 setDataList 内部无论新旧数据长度是否一致,都必须强制把 currentIndex 归零,再走一次对齐绘制,不能做「数据相同就跳过」的优化,否则联动就像没执行一样。

3.3 默认值回显:用 code 逆查路径

常见需求是编辑地址时回显已有城市。外部传入的往往是某个区的 code(比如P001C001D001),组件需要反推出「省是哪个、市是哪个、区是否可空」。

public boolean locateByCode(String targetCode) { List<RegionEntity> path = dataSource.findPathByCode(targetCode); if (path == null || path.size() < 2) return false; isInternalReset = true; provinceWheel.setDataList(provinces); provinceWheel.setSelectedPosition( indexOfCode(provinces, path.get(0).getCode()), false); cities = dataSource.loadChildren(path.get(0).getCode()); cityWheel.setDataList(cities); cityWheel.setSelectedPosition( indexOfCode(cities, path.get(1).getCode()), false); districts = dataSource.loadChildren(path.get(1).getCode()); districtWheel.setDataList(districts); if (path.size() >= 3) { districtWheel.setSelectedPosition( indexOfCode(districts, path.get(2).getCode()), false); } isInternalReset = false; return true; }

findPathByCode 在 Provider 里用 HashMap 缓存好的 codeToPath 直接查,复杂度 O(1),不要在回调里做递归搜索。indexOfCode 是线性的,因为省市列表通常几十条,没必要搞索引。注意清空数组和空数组的边界:区县列表可能为空,此时第三列会给一个无数据集合,UI 层需要隐藏第三列,这类数据形态问题在避坑章展开。

4. 接入与运行:把城市选择器装进你的页面

组件拆得再漂亮,接入体验差一样会被打回。这一章讲集成方式,从参数表开始,到页面调用,再到换自己的数据源。

4.1 先看参数表再动手

对外暴露的参数收敛在 PickerConfig 里,常用的就这几个:

参数类型默认值说明
linkLevelint3联动级数,传 2 表示只选省和市
defaultCodeStringnull回显用的行政区 code
itemHeightint44dp滚轮行高,改大需要同步调 visibleCount
visibleCountint5可见条数,必须为奇数保证选中线居中
loopEnabledbooleantrue是否允许循环滚动
showHotCitiesbooleanfalse是否在滚轮上方展示热门城市快捷项

这几个参数互相有依赖:itemHeight 增大而 visibleCount 不变,滚轮会显得拥挤;loopEnabled 关闭后,WheelView 的取模逻辑要切换到边界 clamp,否则首尾会穿帮。我在 init 里做了校验,visibleCount 为偶数直接抛 IllegalStateException,早挂比线上现眼好。

4.2 页面调用示例:四行代码弹出选择器

binding.llAddress.setOnClickListener(v -> new LinkagePickerDialog.Builder(MainActivity.this) .setLinkLevel(3) .setDefaultCode(cachedCode) .setLoopEnabled(true) .setShowHotCities(true) .setOnPickedListener((province, city, district) -> { cachedCode = district != null ? district.getCode() : city.getCode(); String addr = province.getName() + city.getName() + (district == null ? "" : district.getName()); binding.tvAddress.setText(addr); }) .show());

链式调用的顺序无所谓,但注意两点:defaultCode 只在数据解析完成后才会生效,所以 LinkagePickerDialog 启动时如果 Provider 还没 parse 完,我会先展示一个 loading 占位,解析完成再执行 locateByCode;listener 回调里 province、city 永远非空,district 可能为空(两级数据场景),页面侧要按district == null判空,不能想当然。

数据解析是异步的,这里贴一下 Dialog 里防止「数据没就绪就去读」的片段:

CityDataProvider.getInstance().loadAsync(() -> { provinces = provider.loadRoot(); provinceWheel.setDataList(provinces); if (TextUtils.isEmpty(defaultCode)) { provinceWheel.setSelectedPosition(0, false); } else { locateByCode(defaultCode); } });

loadAsync 内部用线程加 Handler 切回主线程,中间加一个 ready 标志。我一般会再做一层保护:解析失败时弹 Toast 并关闭弹窗,而不是让用户面对一只空转的滚轮。

4.3 数据源替换成自己的数据

这套组件的默认数据源是 assets 下的 region.json。如果你的城市数据在服务端或者本地数据库,实现 RegionDataSource 接口替换进去即可:

public interface RegionDataSource { List<RegionEntity> loadRoot(); List<RegionEntity> loadChildren(String parentCode); RegionEntity findByCode(String code); List<RegionEntity> loadHotCities(); }

接口只有四个方法,语义非常直白。服务端下发的场景,我会在后端先收敛成同一棵树形结构,字段名如果跟本地不一致,就在实现类里做一次 NodeConverter 映射,而不是让 Dialog 去适配字段差异。洗数据这件事放在 Provider 入口做,比散落在业务页里处理要可控得多。

5. 避坑指南:跑起来之后最容易翻车的四个点

这章是我把组件喂给多个业务方之后攒下的血泪经验,每一条都真实修过线上问题。

5.1 滚轮文字上下不居中,选中线对不齐

现象:滚轮停住后,文字视觉中心不在选中线上,上下还有裁切。

原因:drawText 的 y 坐标是文字基线而不是中心,我最初直接拿 itemHeight 的一半当中心 Y,碰上某些中文字体和系统默认字体混排时基线偏移量不一致,整体视觉偏上。另外行高小于文字自身高度时,drawText 会把字裁出边界。

解决:行高计算改成itemHeight = max(配置行高, fontMetrics.bottom - fontMetrics.top + 上下留白),绘制前用 Paint.getFontMetrics 算出基线偏移baselineY = centerY - (fontMetrics.ascent + fontMetrics.descent) / 2。从那以后我每次调行高都强制先量一次实际字体高度,不再拍脑袋给像素值。

5.2 快速滑动省列,城市列闪烁跳变

现象:用户快速 fling 省份滚轮,城市列和区县列在滚动过程中被重置了两三次,最终显示的内容和选中的省对不上,甚至闪回旧值。

原因:fling 过程中 onSelected 会在每个下标经过时触发,Dialog 收到回调就执行一次联动重置,而重置时旧滚轮的 mScrollY 余数还在,新数据替换后位置被强行归零,形成肉眼可见的跳变。这是典型的「回调在没有稳定态时被触发」。

解决:在 WheelView 的回调里增加稳定态判断——fling 未结束前不对外抛 onSelected。具体做法是 scroller.isFinished() 为 false 时把选中事件先缓存在队列里,等回弹对齐完成后再一次性触发;同时联动重置前调用cityWheel.abortAnimation()把下级滚轮的动画停掉再切数据,双管齐下才把闪烁压住。

5.3 某些特殊行政区划只有两级数据,第三列空转

现象:选择某个地区后,区县列没有数据可显示,要么白屏,要么出现空的滚动区域。

原因:数据源里该节点的 children 是空数组[],而代码只判断了 null,空数组被当成有效集合传给了 WheelView,渲染出一个没有任何 item 的轮子。

解决:在数据解析入口统一把「空数组和 null」都归一成叶子节点,并给 RegionEntity 加一个hasChildren()方法。LinkagePickerDialog 在联动时先查hasChildren(),没有下一级就直接隐藏对应滚轮,回调里把缺失层级传 null。数据是宁可缺字段,也不能给空壳集合——这个判断写充分后,顺带解决了一批「业务方数据没配全」引发的空指针。

5.4 Activity 重建后弹窗回显错位

现象:页面旋转或从后台恢复后,重新弹出的选择器选中项回到了第一项,而不是用户上次选的城市。

原因:弹窗是轻量组件,没有做状态持久化,Activity 重建后 defaultCode 还沿用初次传入的值,或直接为 null。

解决:在 Activity/ViewModel 侧把用户最后确认的 code 存下来,用 ViewModel 持有而不是 Fragment 字段。重建时先读 ViewModel 里的 cachedCode,再传给 Builder。我在 Dialog 内部也加了一道保险:onSaveInstanceState 时把当前路径 code 序列化,恢复时优先用弹窗自己存的值,其次才用外部传入的 defaultCode。

6. 二次开发思路:从三级滚轮长成完整城市组件

6.1 加搜索:给数据层铺拼音索引

在 AssetRegionDataSource 解析完成后,额外生成一份拼音索引:把 name 转成全拼和首字母,存成Map<String, List<String>>,key 是拼音串,value 是命中的 code 路径。搜索框输入时按简拼过滤,点击结果直接调 locateByCode 跳转。多音字是坑,地名里的常见多音字靠拼音库转出来不一定对,我会维护一个小字典手工覆盖,量不大但效果明显。

6.2 接定位:把定位结果当默认值

定位 SDK 拿到经纬度后走逆地理编码得到行政区 code,然后复用现有 locateByCode 回显。真正麻烦的是权限弹窗和定位回调时序——用户拒绝权限时要有降级方案,我一般是定位失败就回到「选中第一项」的默认态,并给页面回调一个可选参数标记是否定位成功,而不是阻塞弹窗。

6.3 数据改接口下发

assets JSON 每次更新都要发版,运营调整区划数据时太被动。改造方向是新增 RemoteRegionDataSource 实现同一个接口,服务端下发结构与 region.json 保持一致的树形格式,客户端做缓存后复用原来的 Provider。字段名不一致时用 NodeConverter 收敛,这层映射写一次,后续换字段就不用再动 UI。

本地城市数据只在发版时更新的教训我记了很久。有次上线后运营临时要求把某新区加进三级数据,才发现 assets JSON 的方案要等下一次版本,从那以后,我每次接城市类需求都先确认三件事:数据是本地还是接口下发、树结构是否通用、叶子节点是否允许没有下一级。确认完再动联动逻辑,基本能避开大部分返工,希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 15:09:20

AI系统权限控制实战:从认证授权到配额审计的完整方案

先说个场景&#xff1a;你花了大力气把大模型能力接进业务系统&#xff0c;上线第一天&#xff0c;运营同学拿它一口气生成了几千张图&#xff0c;财务月底看到账单直接懵了。这是我在实际项目里见过的真实问题&#xff0c;根子就一个——AI系统权限控制没做好。整套体系要管的…

作者头像 李华
网站建设 2026/10/16 15:07:59

FLIP动画技术详解:从浏览器渲染原理到工程级实践

FLIP 动画这几年几乎成了前端动画绕不开的话题&#xff0c;核心名字却简单得有些反直觉&#xff1a;First、Last、Invert、Play。它解决的问题特别直白——让一个改变尺寸、位置的 DOM 元素&#xff0c;动画跑起来又顺又不卡。但光看效果不过瘾&#xff0c;真要在项目里落地&am…

作者头像 李华
网站建设 2026/10/16 15:06:33

医院安全运营的大模型落地:从告警研判到人机协同闭环

医院的安全运营&#xff0c;一直是安全行业里比较特殊的一类场景。业务科室凌晨三点还在跑批&#xff0c;影像系统几十个T的数据说走就走&#xff0c;设备科新接的呼吸机连的还是老交换机&#xff0c;信息科手里却只有三四个安全运维人员。这种环境下&#xff0c;想靠堆人力和传…

作者头像 李华
网站建设 2026/10/16 15:02:41

Excel合并单元格后内容不居中?从原理到批量处理一次解决

合并单元格后内容不居中&#xff0c;这个事儿看着小&#xff0c;但几乎每个用Excel的人都栽过跟头。有人辛辛苦苦把表头合并好了&#xff0c;结果字死死贴在左下角&#xff0c;怎么看怎么别扭&#xff1b;有人点了“合并后居中”&#xff0c;换台电脑打开又变了样&#xff1b;还…

作者头像 李华
网站建设 2026/10/16 15:00:22

力扣1046最后一块石头的重量:C++优先队列与大小堆实战解析

力扣1046这道“最后一块石头的重量”&#xff0c;我常把它当作面试里考察大小堆和优先队列的入门练兵场。题目本身很直白&#xff1a;你有一堆石头&#xff0c;每次从里面拎出最重的两块&#xff0c;按规则粉碎一轮&#xff0c;剩下的差再丢回去&#xff0c;直到剩一块或全部消…

作者头像 李华
网站建设 2026/10/16 15:00:20

软考程序员37天高效备考路线图:真题倒推+场景化锚点

1. 这份笔记不是“速成宝典”&#xff0c;而是我用37天实测打磨出的程序员备考路线图 “初级软考-程序员”这六个字&#xff0c;对刚毕业的学生、转行想进IT基础岗的朋友、或者在小公司做基础开发但没系统梳理过知识体系的从业者来说&#xff0c;不是一张纸&#xff0c;而是一道…

作者头像 李华