资讯动态

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

发布时间:2026/10/16 15:09:46 来源:尧图企业网站定制
简介面向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.drawTextOverride 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 累加偏移并 invalidateACTION_UP 时用 VelocityTracker 算出纵向速度交给 Scrollercase 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) { ListRegionEntity 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回显用的行政区 codeitemHeightint44dp滚轮行高改大需要同步调 visibleCountvisibleCountint5可见条数必须为奇数保证选中线居中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 占位解析完成再执行 locateByCodelistener 回调里 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 { ListRegionEntity loadRoot(); ListRegionEntity loadChildren(String parentCode); RegionEntity findByCode(String code); ListRegionEntity 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 转成全拼和首字母存成MapString, ListStringkey 是拼音串value 是命中的 code 路径。搜索框输入时按简拼过滤点击结果直接调 locateByCode 跳转。多音字是坑地名里的常见多音字靠拼音库转出来不一定对我会维护一个小字典手工覆盖量不大但效果明显。6.2 接定位把定位结果当默认值定位 SDK 拿到经纬度后走逆地理编码得到行政区 code然后复用现有 locateByCode 回显。真正麻烦的是权限弹窗和定位回调时序——用户拒绝权限时要有降级方案我一般是定位失败就回到「选中第一项」的默认态并给页面回调一个可选参数标记是否定位成功而不是阻塞弹窗。6.3 数据改接口下发assets JSON 每次更新都要发版运营调整区划数据时太被动。改造方向是新增 RemoteRegionDataSource 实现同一个接口服务端下发结构与 region.json 保持一致的树形格式客户端做缓存后复用原来的 Provider。字段名不一致时用 NodeConverter 收敛这层映射写一次后续换字段就不用再动 UI。本地城市数据只在发版时更新的教训我记了很久。有次上线后运营临时要求把某新区加进三级数据才发现 assets JSON 的方案要等下一次版本从那以后我每次接城市类需求都先确认三件事数据是本地还是接口下发、树结构是否通用、叶子节点是否允许没有下一级。确认完再动联动逻辑基本能避开大部分返工希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑