小程式選人控制元件 - 仿企業微信實現多層級無規則巢狀
在很多系統中都有選擇聯絡人的需求,市面上也沒什麼好的參照,產品經理看企業微信的選人挺好用的,就說參照這個做一個吧。。。

算了,還是試著做吧,企業微信的選人的確做的挺好,不得不佩服。
先看看效果圖吧,多層級無規律的巢狀都能搞定

一、設計解讀

整個介面分為三部分:
- 最上面的返回上一層按鈕
- 中間的顯示部門、人員的列表
- 最下面顯示和操作已選人員的 footer。
為什麼加一個返回上一層按鈕呢?
我也覺得比較醜,但小程式無法直接控制左上角返回鍵(自定義 Title 貌似可以,沒試過),點左上角的返回箭頭的話就退出選人控制元件到上個頁面了。
我們的需求是點選一個資料夾,通過重新整理當前列表進入下一級目錄,感覺像是又進了一個頁面,但其實並沒有,只是列表的資料變化了。由此實現不定層級、無規律的部門和人員巢狀的支援。
比如先點選了首屏資料的第二個 item
,它的 index
是 1
,就將 1
存入 indexList
;返回上一層時將最後一個元素刪除。
當勾選了某個人或部門時,會在底部的框中顯示所有已選人員或部門的名字,當文字超過螢幕寬度時可以向右無限滑動,底部 footer
始終保持一行。
最終選擇的人以底部 footer
裡顯示的為準,點選確定時根據業務需要將已選人員資料傳送給需要的介面。
二、功能邏輯分析
先看看資料格式
{ id: TEACHER_ID, name: '教師', parentId: '', checked: false, isPeople: false, children: [ { id: TEACHER_DEPARTMENT_ID, name: '部門', parentId: 'teacher', checked: false, isPeople: false, children: [] }, { id: TEACHER_SUBJECT_ID, name: '學科', parentId: 'teacher', checked: false, isPeople: false, children: [] }, { id: TEACHER_GRADECLASS_ID, name: '年級班級', parentId: 'teacher', checked: false, isPeople: false, children: [] }, ] } 複製程式碼
所有的資料組成一個數據樹,子節點巢狀在父節點下。
id
, name
不說了, parentId
指明它的父節點, children
包含它的所有子節點, checked
用來判斷勾選狀態, isPeople
判斷是部門還是人員,因為兩者的圖示不一樣。
注意:
本控制元件採用了資料分步載入的模式,除了最上層固定的幾個分類,其他的每層資料都是點選具體的部門後才去請求伺服器載入本部門下的資料的,然後再拼接到原始資料樹上。這樣可以提高載入速度,提升使用者體驗。
我也試了一次性把所有資料都拉下來,一是太慢,得三五秒,二是資料量太大的話(我這裡應該是超過1000,閾值多少沒測過), setData()
的時候就會報錯:

超過最大長度了。。。所以只能分步載入資料。
當然如果你的資料量小,幾十人或幾百人,也可以選擇一次性載入。
這個控制元件邏輯上還是比較複雜的,要考慮的細節太多……下面梳理一下主要的邏輯點
主要邏輯點
1. 需要一個數組儲存所有被點選的部門在當前列表的索引 index
,這裡用 indexList
表示
點選某個部門進入下一層目錄時,將被點選部門的 index
索引 push
進 indexList
中。點選返回上一層按鈕時,刪除 indexList
中最後一個元素。
2. 要動態的更新當前列表 currentList
每進入新的一層,或返回上一層,都需要重新整理 currentList
來實現頁面的更新。知道下一層資料很容易,直接取被點選 item
的 children
賦值給 currentList
即可。
但如何還原上一層的資料呢?
第一點記錄的 indexList
就發揮作用了,原始資料樹為 originalList
,迴圈遍歷 indexList
,根據索引依次取出每層的 currentList
直到 indexList
的最後一個元素,就得到了返回上一層需要顯示的資料。
3. 每一次勾選或取消選中都要更新原始的資料樹 originalList
頁面是根據每個 item
的 checked
屬性判斷是否選中的,所以每次改變勾選狀態都要設定被改變的 item
的 checked
屬性,然後更新 originalList
。這樣即使返回上一層了,再進到當前層級選中狀態還會被保留,否則重新整理 currentList
後已選狀態將丟失。
4. 列表中選擇狀態的改變與底部 footer
的雙聯動
我們期望的效果是,選中 currentList
列表的某一項,底部 footer
會自動新增被選人的名字。取消選中,底部 footer
也會自動刪除。
也可以通過 footer
來刪除已選人,點選 footer
中人名,會將此人從已選列表中刪除, currentList
列表中也會自動取消勾選狀態。
嗯,這個功能比較耗效能,每一次都需要大量的計算。考慮到效能和速度因素,本次只做了從 footer
刪除只更新 currentList
的勾選狀態。
什麼意思呢?假如有兩層,A 和 B,B 是 A 的下一層資料,即 A 是 B 的父節點。在 A 中選中了一個部門 校長室
,點選下一層到 B,在 B 中又選了兩個人 張三
和 李四
,這時底部 footer
裡顯示的應該是三個: 校長室
、 張三
、 李四
。此時點選 footer
的 張三
, footer
會把 張三
刪除,中間列表中 張三
會被置為未選中狀態,這沒問題。但點選 footer
的 校長室
, 在 footer
中是把 校長室
刪除了,但再返回到上一層時,中間列表中的 校長室
依然是勾選狀態,因為此時沒有更新原始資料樹 originalList
。如果覺得這是個 bug
, 可以加個更新 originalList
的操作。這樣就要遍歷 originalList
的每個元素判斷與本次刪除的 id 是否相等,然後改變 checked
值,如果資料量很大,會非常慢。我做了妥協……
關鍵的邏輯就這四塊了,當然還有很多小細節,直接看程式碼吧,註釋寫的也比較詳細。
三、程式碼
目錄結構:

footer
資料夾下是抽離出的 footer
元件, userSelect
是選人控制元件的主要邏輯。把這幾個檔案複製過去就可以用了。
把 userSelect.js
裡網路請求的程式碼替換為你的請求程式碼,注意資料的欄位名是否一致。
userSelect 的程式碼
userSelect.js
import API from '../../../utils/API.js' import ArrayUtils from '../../../utils/ArrayUtils.js' import EventBus from '../../../components/NotificationCenter/WxNotificationCenter.js' let TEACHER_ID = 'teacher'; let TEACHER_DEPARTMENT_ID = 't_department'; let TEACHER_SUBJECT_ID = 't_subject'; let TEACHER_GRADECLASS_ID = 't_gradeclass'; let STUDENT_ID = 'student'; let PARENT_ID = 'parent' let TEACHER = { id: TEACHER_ID, name: '教師', parentId: '', checked: false, isPeople: false, children: [ { id: TEACHER_DEPARTMENT_ID, name: '部門', parentId: 'teacher', checked: false, isPeople: false, children: [] }, { id: TEACHER_SUBJECT_ID, name: '學科', parentId: 'teacher', checked: false, isPeople: false, children: [] }, { id: TEACHER_GRADECLASS_ID, name: '年級班級', parentId: 'teacher', checked: false, isPeople: false, children: [] }, ] } let STUDENT = { id: STUDENT_ID, name: '學生', parentId: '', checked: false, isPeople: false, children: [] } let PARENT = { id: PARENT_ID, name: '家長', parentId: '', checked: false, isPeople: false, children: [] } let ORIGINAL_DATA = [ TEACHER, STUDENT, PARENT ] Page({ data: { currentList: [], //當前展示的列表 selectList: [],//已選擇的元素列表 originalList: [], //最原始的資料列表 indexList: [],//儲存目錄層級的陣列,用於準確的返回上一層 selectList: [],//已選中的人員列表 }, onLoad: function (options) { wx.setNavigationBarTitle({ title: '選人控制元件' }) this.init(); }, init(){ //使用者的單位id this.unitId = getApp().globalData.userInfo.unitId; //使用者型別 this.userType = 0; //上次選中的列表,用於判斷是不是取消選中了 this.lastTimeSelect = [] this.setData({ currentList: ORIGINAL_DATA, //當前展示的列表 originalList: ORIGINAL_DATA, //最原始的資料列表 }) }, clickItem(res){ console.log(res) let index = res.currentTarget.id; let item = this.data.currentList[index] console.log("item", item) if (!item.isPeople) { //點選教師,下一層資料是寫死的,不用請求介面 if (item.id === TEACHER_ID) { this.userType = 2; this.setData({ currentList: item.children }) } else if (item.id === TEACHER_SUBJECT_ID) { if (item.children.length === 0){ this._getTeacherSubjectData() }else{ //children的長度不為0時,更新 currentList this.setData({ currentList: item.children }) } } else if (item.id === TEACHER_DEPARTMENT_ID) { if (item.children.length === 0) { this._getTeacherDepartmentData() } else { //children的長度不為0時,更新 currentList this.setData({ currentList: item.children }) } } else if (item.id === TEACHER_GRADECLASS_ID) { if (item.children.length === 0) { this._getTeacherGradeClassData() } else { //children的長度不為0時,更新 currentList this.setData({ currentList: item.children }) } } else if (item.id === STUDENT_ID) { this.userType = 1; if (item.children.length === 0) { this._getStudentGradeClassData() } else { //children的長度不為0時,更新 currentList this.setData({ currentList: item.children }) } } else if (item.id === PARENT_ID) { this.userType = 3; if (item.children.length === 0) { this._getParentGradeClassData() } else { //children的長度不為0時,更新 currentList this.setData({ currentList: item.children }) } } else{ //children的長度為0時,請求伺服器 if(item.children.length === 0){ this._getUserByGroup(item) }else{ //children的長度不為0時,更新 currentList this.setData({ currentList: item.children }) } } //將當前的索引存入索引目錄中。索引多一個表示目錄多一級 let indexes = this.data.indexList indexes.push(index) //是目錄不是具體的使用者 this.setData({ indexList: indexes }) //清空上次選中的元素列表,並設定上一層的選中狀態給lastTimeSelect this.setLastTimeSelectList(); } }, //返回按鈕 goBack() { let indexList = this.data.indexList if (indexList.length > 0) { //返回時刪掉最後一個索引 indexList.pop() if (indexList.length == 0) { //indexList長度為0說明回到了最頂層 this.setData({ currentList: this.data.originalList, indexList: indexList }) } else { //迴圈將當前索引的對應陣列賦值給currentList let list = this.data.originalList for (let i = 0; i < indexList.length; i++) { let index = indexList[i] list = list[index].children } this.setData({ currentList: list, indexList: indexList }) } //清空上次選中的元素列表,並設定上一層的選中狀態給lastTimeSelect this.setLastTimeSelectList(); } }, //清空上次選中的元素列表,並設定上一層的選中狀態給lastTimeSelect setLastTimeSelectList(){ this.lastTimeSelect = [] this.data.currentList.forEach(item => { if (item.checked) { this.lastTimeSelect.push(item) } }) }, //獲取教師部門資料 _getTeacherDepartmentData() { this._commonRequestMethod(2, 'department') }, //請求教師的學科資料 _getTeacherSubjectData(){ this._commonRequestMethod(2, 'subject') }, //請求教師的年級班級 _getTeacherGradeClassData() { this._commonRequestMethod(2, 'gradeclass') }, //請求學生的年級班級 _getStudentGradeClassData() { this._commonRequestMethod(1, 'gradeclass') }, //請求家長的年級班級 _getParentGradeClassData() { this._commonRequestMethod(3, 'gradeclass') }, //根據部門查詢人 _getUserByGroup(item){ let params = { userType: this.userType, unitId: this.unitId, groupType: item.type, groupId: item.id } console.log('params', params) getApp().get(API.selectUserByGroup(), params, result => { console.log('result', result) let list = this.transformData(result.data.data, item.id) this.setData({ currentList: list }) this.addList2DataTree() //清空上次選中的元素列表,並設定上一層的選中狀態給lastTimeSelect。寫在這裡防止非同步請求時執行順序問題 this.setLastTimeSelectList(); }) }, //通用的請求部門方法 _commonRequestMethod(userType, groupType){ wx.showLoading({ title: '', }) let params = { userType: userType, unitId: this.unitId, groupType: groupType } console.log('params', params) getApp().get(API.selectUsersByUserGroupsTree(), params, result => { console.log('result', result) wx.hideLoading() let data = result.data.data this.setData({ currentList: data }) this.addList2DataTree(); //清空上次選中的元素列表,並設定上一層的選中狀態給lastTimeSelect。寫在這裡防止非同步請求時執行順序問題 this.setLastTimeSelectList(); }) }, //將請求的資料轉化為需要的格式 transformData(list, parentId){ //先將資料轉化為固定的格式 let newList = [] for(let i=0; i<list.length; i++){ let item = list[i] newList.push({ id: item.id, name: item.realName, parentId: parentId, checked: false, isPeople: true, userType: item.userType, gender: item.gender, children: [] }) } return newList; }, //將當前列表掛載在原資料樹上, 目前支援5層目錄,如需更多接著往下寫就好 addList2DataTree(){ let currentList = this.data.currentList; let originalList = this.data.originalList; let indexes = this.data.indexList switch (indexes.length){ case 1: originalList[indexes[0]].children = currentList break; case 2: originalList[indexes[0]].children[indexes[1]].children = currentList break; case 3: originalList[indexes[0]].children[indexes[1]].children[indexes[2]].children = currentList break; case 4: originalList[indexes[0]].children[indexes[1]].children[indexes[2]].children[indexes[3]].children = currentList break; case 5: originalList[indexes[0]].children[indexes[1]].children[indexes[2]].children[indexes[3]].children[indexes[4]].children = currentList break; } this.setData({ originalList: originalList }) console.log("originalList", originalList) }, //選框變化回撥 checkChange(res){ console.log(res) let values = res.detail.value let selectItems = [] //將值取出拼接成 id,name 格式 values.forEach(value => { let arrs = value.split(",") selectItems.push({id: arrs[0], name: arrs[1]}) }) console.log("selectItems", selectItems) console.log("lastTimeSelect", this.lastTimeSelect) //將本次選擇的與上次選擇的比對,本次比上次多說明新增了,本次比上次少說明刪除了,找出被刪除的那條資料,在footer中也刪除 if (selectItems.length > this.lastTimeSelect.length){ //將 selectList 與 selectItems 拼接並去重 let newList = this.data.selectList.concat(selectItems) newList = ArrayUtils.checkRepeat(newList) this.setData({ selectList: newList }) }else{ //找出取消勾選的item,從selectList中刪除 //比對出取消勾選的是哪個元素 let diffItem = {} this.lastTimeSelect.forEach(item => { let flag = false; selectItems.forEach(item2 => { if(item.id === item2.id){ flag = true } }) if(!flag){ diffItem = item console.log("diff=", item) } }) //找出被刪除的元素在 selectList 中的位置 let list = this.data.selectList let delIndex = 0; for(let i=0; i<list.length; i++){ if (list[i].id === diffItem.id){ delIndex = i; break; } } //從list中刪除這個元素 list.splice(delIndex, 1) this.setData({ selectList: list }) } console.log("selectList", this.data.selectList) //更新 currentList 選中狀態並重新掛載在資料樹上,以儲存選擇狀態 this.updateCurrentList(this.data.currentList, this.data.selectList) }, //footer點選刪除回撥 footerDelete(res){ console.log(res) this.setData({ selectList: res.detail.selectList }) console.log('selectList', this.data.selectList) this.updateCurrentList(this.data.currentList, res.detail.selectList) }, //點選 footer 的確定按鈕提交資料 submitData(res){ let selectList = this.data.selectList //通過 WxNotificationCenter 傳送選擇的結果通知 EventBus.postNotificationName("SelectPeopleDone", selectList) //將選擇結果存入 app.js 的 globalData getApp().globalData.selectPeopleList = selectList //返回 wx.navigateBack({ delta: 1 }) console.log("selectdone", selectList) }, //更新 currentList 並將更新後的列表掛載在資料樹上 updateCurrentList(currentList, selectList){ let newList = [] currentList.forEach(item => { let flag = false; selectList.forEach(item2 => { if (item.id === item2.id) { flag = true } }) if (flag) { item.checked = true } else { item.checked = false } newList.push(item) }) this.setData({ currentList: newList }) this.addList2DataTree() this.setLastTimeSelectList() } }) 複製程式碼
userSelect.wxml
<view class='container'> <view class='btn-wrapper'> <button bindtap='goBack'>返回上一層</button> </view> <view class='people-wrapper'> <scroll-view scroll-y class='scrollview'> <checkbox-group bindchange="checkChange"> <view class='item' wx:for='{{currentList}}' wx:key='{{item.id}}'> <checkbox checked='{{item.checked}}' value='{{item.id + "," + item.name}}'> </checkbox> <view id='{{index}}' class='item-content' bindtap='clickItem'> <image class='img' wx:if='{{!item.isPeople}}' src='../../../assets/file.png'></image> <image class='avatar' wx:if='{{item.isPeople}}' src='../../../assets/avatar.png'></image> <text class='itemtext'>{{item.name}}</text> </view> </view> </checkbox-group> <view class='no-data' wx:if='{{currentList.length===0}}'>暫無資料</view> </scroll-view> </view> <view class='footer'> <footer list='{{selectList}}' binddelete='footerDelete' bindsubmit="submitData"/> </view> </view> 複製程式碼
userSelect.wxss
.container { width: 100%; height: 100%; display: flex; flex-direction: column; padding: 20rpx; overflow-x: hidden; box-sizing: border-box; background-color: #fff; } .btn-wrapper { width: 100%; padding: 0 20rpx; box-sizing: border-box; } .btn { font-size: 24rpx; width: 100%; } .people-wrapper { width: 100%; margin-top: 10rpx; margin-bottom: 100rpx; } .scrollview { width: 100%; display: flex; flex-direction: column; } .item { width: 100%; display: flex; flex-direction: row; align-items: center; padding: 30rpx 0; margin: 0 20rpx; border-bottom: 1rpx solid rgba(7, 17, 27, 0.1); } .item-content { width: 100%; display: flex; flex-direction: row; align-items: center; margin-left: 20rpx; } .itemtext { font-size: 36rpx; color: #333; margin-left: 20rpx; text-align: center; } .img { width: 50rpx; height: 40rpx; } .avatar { width: 50rpx; height: 50rpx; } .footer { position: absolute; left: 0; bottom: 0; width: 100%; } .no-data{ width: 100%; font-size: 32rpx; text-align: center; padding: 40rpx 0; } 複製程式碼
userSelect.json
{ "usingComponents": { "footer": "footer/footer" } } 複製程式碼
footer 的程式碼
footer.js
Component({ /** * 元件的屬性列表 */ properties: { list: { type: Array } }, /** * 元件的初始資料 */ data: { }, /** * 元件的方法列表 */ methods: { delete(res){ console.log(res) let index = res.currentTarget.id let list = this.data.list list.splice(index,1) this.setData({list: list}) this.triggerEvent("delete", {selectList: list}) }, /** * 點選確定按鈕 */ confirm(){ this.triggerEvent("submit", "") } } }) 複製程式碼
footer.wxml
<view class='container'> <view class='scroll-wrapper'> <scroll-view scroll-x style='scroll'> <text id='{{index}}' class='text' wx:for='{{list}}' wx:key='{{index}}' bindtap='delete'>{{item.name}}</text> </scroll-view> </view> <text class='btn' bindtap='confirm'>確定</text> </view> 複製程式碼
footer.wxss
.container { width: 100%; height: 100rpx; display: flex; flex-direction: row; padding: 20rpx; box-sizing: border-box; background-color: #fff; align-items: center; overflow-x: hidden; white-space: nowrap; border-top: 2rpx solid rgba(7, 17, 27, 0.1) } .scroll-wrapper { flex: 1; overflow-x: hidden; white-space: nowrap; } .scroll { width: 100%; } .text { font-size: 32rpx; color: #333; padding: 40rpx 20rpx; margin-right: 10rpx; background-color: #f5f5f5; } .btn { padding: 10rpx 20rpx; background-color: rgb(26, 173, 25); border-radius: 10rpx; font-size: 32rpx; color: #fff; } 複製程式碼
footer.json
{ "component": true, "usingComponents": {} } 複製程式碼
再補一個用到的 ArrayUtils
的程式碼
export default{ /** * 給陣列去重 */ checkRepeat(list) { let noRepList = [list[0]] for (let i = 0; i < list.length; i++) { let repeat = false for (let j = 0; j < noRepList.length; j++) { if (noRepList[j].id === list[i].id) { repeat = true break } } if (!repeat) { noRepList.push(list[i]) } } return noRepList }, //刪除list中id為 delId 的元素 deleteItemById(list, delId){ for (let i = 0; i < list.length; i++) { if (list[i].id == delId) { list.splice(i, 1) return list; } } return list; } } 複製程式碼
由於時間緊張,還沒有把這個控制元件單獨從專案中抽出來寫個 Demo,有時間了會給 github 地址的。
程式碼還有很多可以優化的地方,比如有幾個方法太長了,不符合單一職責原則等等,不想改了,以後再優化吧。。
水平有限,各位大俠請輕噴~
有問題或發現 Bug
請在評論區留言,畢竟剛寫完就分享出來了,還沒經過嚴格的測試。不過應該沒什麼大的問題。。。有些細節可能沒注意到。