各種系統中行政區域選擇的場景不少,我們也有不少這樣的場景。本想使用第三方的組件,但是大多有些小問題,不能滿足需要。后面使用picker的mulitSelector模式寫了一個,發現這種列模式的體驗並好,最后仿京東模式自定義了一個。
一、造輪子的原因
1.1 數據要自定義
微信官方的picker的region模式使用的是標准的國家行政區域數據,而我們的場景有一些自設的區域要加入;也不可以自定久選擇級數,只能選到縣/區級。
1.2 picker的兼容性並不好。
uni-app的picker組件,在小程序模式是使用各自的picker,H5則是uni-app自的picker組件。所以在各平台中還是有差異的,在我們測試中微信的picker的mulitSelector模式,在列級聯滑動中如果出現兩次列數組值length不一致時,后綁定的選定索引時會無效,會自動致為0,且后續觸發的change事件則仍是綁定索引,而在H5時不會。
1.3 picker是不適合異步加載數據
級聯就是要簡便的控制后續列的變化,如1.2所示,綁定索引bug。而如果數據是異步加載,則更難於控制加載狀態,特別是滑動過快網絡不佳時,很容易出現數據混亂。
1.4 picker作級聯,不如京東級聯模式的體驗好效率高。
如圖所示
二、上代碼
使用的了tui-drawer 、tui-loadmore等tui-xxx為uni-app第三方組件,具本使用參考官方文檔,或使用別的組件替代。regionApi為行政區域節點異步加載封裝,可根自己數據自行封裝。

1 <!-- 2 * 行政區域選擇器 3 * 4 * @alphaiar 5 * 20210408 created. 6 --> 7 8 <template> 9 <view class="region-picker"> 10 <input placeholder-class="placeholder" :placeholder="placeholder" :value="selectorPath" disabled 11 @tap="onPopupToggle" /> 12 <view v-if="errorMessage" class="messager">{{errorMessage}}</view> 13 <tui-drawer :visible="visibled" mode="bottom" @close="onPopupToggle"> 14 <view class="header"> 15 <text class="cancel" @tap="onPopupToggle">取消</text> 16 <text class="confirm" @tap="onConfirm">確認</text> 17 </view> 18 <view class="tab-wrapper"> 19 <template v-for="(lab,idx) in labels"> 20 <label v-if="idx!==labelIndex" :key="idx" @tap="onLabelChange({index:idx})"> 21 {{lab}} 22 </label> 23 <template v-else> 24 <label class="active"> 25 {{lab}} 26 </label> 27 <iconfont class="indicator" name="arrow-down" /> 28 </template> 29 </template> 30 </view> 31 <tui-loadmore v-if="loading" :index="3" type="primary" text="加載中..." /> 32 <view v-else class="region-view"> 33 <template v-for="(n,idx) in regions"> 34 <label v-if="idx !== selectorIndexs[labelIndex]" @tap="onSelector(idx)" :key="idx">{{n}}</label> 35 <label v-else :key="idx"> 36 <span class="selected">{{n}}</span> 37 </label> 38 </template> 39 </view> 40 <view v-if="errorTips" class="error-tips"> 41 {{errorTips}} 42 </view> 43 </tui-drawer> 44 </view> 45 </template> 46 47 <script> 48 import utils from "../utils/utils.js"; 49 import regionApi from "../apis/region.js"; 50 51 export default { 52 name: 'regionPicker', 53 props: { 54 /** 55 * 選擇器區級 56 * 0-省 57 * 1-地市 58 * 2-縣區 59 * 3-鄉鎮 60 */ 61 selectorLevel: { 62 type: Number, 63 default: 1, 64 validator(val) { 65 return [0, 1, 2, 3].some(x => x === val); 66 } 67 }, 68 /** 69 * 當前選擇值 70 */ 71 value: { 72 type: Array, 73 default: null 74 }, 75 /** 76 * 沒有值時的占位符 77 */ 78 placeholder: { 79 type: String, 80 default: '請選擇地區' 81 }, 82 /** 83 * 表單驗證錯誤提示消息 84 */ 85 errorMessage: { 86 type: String, 87 default: null 88 } 89 }, 90 watch: { 91 selectorLevel(val) { 92 this.$emit('input', null); 93 this.initialize(); 94 }, 95 value(val) { 96 this.initialize(); 97 } 98 }, 99 data() { 100 101 return { 102 visibled: false, 103 loading: false, 104 labels: ['請選擇'], 105 labelIndex: 0, 106 regions: [], 107 selectorIndexs: [], 108 selectorNodes: [], 109 errorTips: null 110 }; 111 }, 112 computed: { 113 selectorPath() { 114 let nodes = this.selectorNodes; 115 116 if (!nodes || nodes.length < 1) 117 return null; 118 119 let paths = nodes.map(x => x.name); 120 let path = paths.join(' / '); 121 122 return path; 123 } 124 }, 125 mounted() { 126 const self = this; 127 regionApi.getNodes({ 128 params: { 129 endCategory: 1 130 }, 131 loading: false, 132 onLoading(ld) { 133 self.loading = ld; 134 }, 135 showError: true, 136 callback(fkb) { 137 138 if (!fkb.success) 139 return; 140 141 let nodes = fkb.result; 142 self.__rawRegions = nodes; 143 144 if (!self.value || self.value.length < 1) 145 self.bindViews(nodes); 146 else 147 self.initialize(); 148 } 149 }); 150 151 }, 152 methods: { 153 /** 154 * 初始化選擇器 155 */ 156 initialize() { 157 this.labels = ['請選擇']; 158 this.labelIndex = 0; 159 this.selectorIndexs = []; 160 this.selectorNodes = []; 161 this.bindViews(this.__rawRegions); 162 163 //設定初始值 164 let values = this.value; 165 if (!values || values.length < 1) 166 return; 167 168 const self = this; 169 let prevs = this.__rawRegions; 170 let setValue = function(idx) { 171 let nd = values[idx]; 172 let about = false; 173 let exists = prevs.some((x, i) => { 174 if (nd.name !== x.name && nd.code !== x.code) 175 return false; 176 177 prevs = x.children || prevs; 178 179 //如果還有下級,但又未加載子節點,則先加載再來設定 180 if (!x.children && idx + 1 < values.length) { 181 self.getNextRegions(x, () => { 182 setValue(idx); 183 }); 184 about = true; 185 return true; 186 } 187 188 self.selectorNodes.push({ 189 category: x.category, 190 code: x.code, 191 name: x.name 192 }); 193 self.onSelector(i); 194 return true; 195 }); 196 197 if (about) 198 return; 199 200 if (exists && idx + 1 < values.length) 201 setValue(idx + 1); 202 }; 203 204 setValue(0); 205 }, 206 /** 207 * 將待選節點綁定至待選視圖 208 * 209 * @param {Array} nodes 要綁定的原始節點 210 */ 211 bindViews(nodes) { 212 this.regions = nodes.map(x => x.name); 213 }, 214 /** 215 * 獲取下級節點 216 * 217 * @param {Object} prevNode 上級選中的節點 218 * @param {function} cb 加載完成后回調 219 */ 220 getNextRegions(prevNode, cb) { 221 const self = this; 222 regionApi.getChildren({ 223 params: { 224 category: prevNode.category + 1, 225 prevCode: prevNode.code 226 }, 227 loading: false, 228 onLoading(ld) { 229 self.loading = ld; 230 }, 231 showError: true, 232 callback(fkb) { 233 if (!fkb.success) 234 return; 235 236 prevNode.children = fkb.result; 237 if (!cb) 238 self.bindViews(fkb.result); 239 else 240 cb(); 241 } 242 }); 243 }, 244 /** 245 * 獲取指定列選擇的節點 246 * 247 * @param {Object} level 地區級別0-3 248 */ 249 getSelectorNode(level) { 250 let prevs = this.__rawRegions; 251 252 for (let i = 0; i < level; i++) { 253 254 let sidx = this.selectorIndexs[i]; 255 if (!sidx) 256 return null; 257 258 prevs = prevs[sidx].children; 259 if (!prevs) 260 return null; 261 } 262 263 let cval = this.selectorIndexs[level]; 264 let node = prevs[cval]; 265 266 return node; 267 }, 268 /** 269 * 切下至下一級區域選擇 270 * 271 * @param {Object} current 當前選中級別0-3 272 */ 273 moveNextLevel(current) { 274 let node = this.getSelectorNode(current); 275 if (node == null) 276 return; 277 278 if (node.children) 279 this.bindViews(node.children); 280 else 281 this.getNextRegions(node); 282 }, 283 onPopupToggle(e) { 284 this.visibled = !this.visibled; 285 }, 286 onConfirm(e) { 287 if (this.selectorLevel + 1 > this.selectorIndexs.length) { 288 this.errorTips = '*請將地區選擇完整。'; 289 return; 290 } 291 292 let nodes = []; 293 for (let i = 0; i < this.selectorIndexs.length; i++) { 294 let node = this.getSelectorNode(i); 295 nodes.push({ 296 category: node.category, 297 code: node.code, 298 name: node.name 299 }); 300 } 301 302 this.selectorNodes = nodes; 303 this.onPopupToggle(); 304 305 this.$emit('input', nodes); 306 this.$emit('change', nodes); 307 }, 308 onLabelChange(e) { 309 //加載中,禁止切換 310 if (this.loading) 311 return; 312 313 let idx = e.index; 314 this.labelIndex = idx; 315 if (idx > 0) 316 this.moveNextLevel(idx - 1); 317 else 318 this.bindViews(this.__rawRegions); 319 }, 320 onSelector(idx) { 321 322 this.errorTips = null; 323 let labIdx = this.labelIndex; 324 325 //由於uni 對於數組的值監聽不完善,只有復制數組更新才生效 326 let labs = utils.clone(this.labels); 327 labs[labIdx] = this.regions[idx]; 328 this.labels = labs; 329 330 //原因上同 331 let idexs = utils.clone(this.selectorIndexs); 332 if (idexs.length <= labIdx) 333 idexs.push(idx); 334 else 335 idexs[labIdx] = idx; 336 this.selectorIndexs = idexs; 337 338 //有下級,全清空 339 if (labIdx >= this.selectorLevel) 340 return; 341 342 this.selectorIndexs.splice(labIdx + 1, 4); //最大只有4級 343 this.labels.splice(labIdx + 1, 4); //最大只有4級 344 345 this.labels.push('請選擇'); 346 this.labelIndex = labIdx + 1; 347 this.moveNextLevel(labIdx); 348 } 349 } 350 } 351 </script> 352 353 <style lang="scss"> 354 .region-picker { 355 356 .header { 357 width: 100%; 358 box-sizing: border-box; 359 margin: 7.2463rpx 0; 360 line-height: $uni-font-size-base+ 7.2463rpx; 361 362 .cancel { 363 padding: 0 18.1159rpx; 364 float: left; 365 //color: $uni-text-color-grey; 366 } 367 368 .confirm { 369 padding: 0 18.1159rpx; 370 float: right; 371 color: $uni-color-primary; 372 } 373 374 text:hover { 375 background-color: $uni-bg-color-hover; 376 } 377 } 378 379 .tab-wrapper { 380 width: 100%; 381 margin-bottom: 28.9855rpx; 382 display: flex; 383 justify-content: center; 384 box-sizing: border-box; 385 386 label { 387 margin: 7.2463rpx 28.9855rpx; 388 padding: 7.2463rpx 0; 389 color: $uni-text-color; 390 border-bottom: solid 3.6231rpx transparent; 391 } 392 393 .active { 394 color: $uni-color-primary; 395 border-color: $uni-color-primary; 396 } 397 398 .indicator { 399 margin-left: -10px; 400 margin-top: 6px; 401 color: $uni-color-primary; 402 } 403 } 404 405 .region-view { 406 width: 100%; 407 display: flex; 408 flex-wrap: wrap; 409 padding: 7.2463rpx 14.4927rpx 28.9855rpx 14.4927rpx; 410 box-sizing: border-box; 411 412 label { 413 margin: 7.2463rpx 0; 414 width: 33%; 415 text-align: center; 416 color: $uni-text-color-grey; 417 text-overflow: ellipsis; 418 overflow: hidden; 419 } 420 421 .selected { 422 padding: 3.6231rpx 14.4927rpx; 423 background-color: $uni-color-light-primary; 424 color: #FFF; 425 border-radius: 10.8695rpx; 426 } 427 } 428 429 .error-tips { 430 width: 100%; 431 height: auto; 432 padding-bottom: 21.7391rpx; 433 text-align: center; 434 color: $uni-color-error; 435 font-size: $uni-font-size-sm; 436 } 437 } 438 </style>
行政區化節點數據,來源國家統計局,到縣區級。
https://files.cnblogs.com/files/blogs/677104/cn_regions.json
最終效果