小程序選人控件 - 仿企業微信實現多選及多層級無規則嵌套

在不少系統中都有選擇聯繫人的需求,市面上也沒什麼好的參照,產品經理看企業微信的選人挺好用的,就說參照這個作一個吧。。。css

算了,仍是試着作吧,企業微信的選人的確作的挺好,不得不佩服。git

先看看效果圖吧,多層級無規律的嵌套都能搞定github

1、設計解讀

整個界面分爲三部分:json

  • 最上面的返回上一層按鈕
  • 中間的顯示部門、人員的列表
  • 最下面顯示和操做已選人員的 footer。

爲何加一個返回上一層按鈕呢?小程序

我也以爲比較醜,但小程序沒法直接控制左上角返回鍵(自定義 Title 貌似能夠,沒試過),點左上角的返回箭頭的話就退出選人控件到上個頁面了。數組

咱們的需求是點擊一個文件夾,經過刷新當前列表進入下一級目錄,感受像是又進了一個頁面,但其實並無,只是列表的數據變化了。由此實現不定層級、無規律的部門和人員嵌套的支持。服務器

好比先點擊了首屏數據的第二個 item,它的 index1 ,就將 1 存入 indexList ;返回上一層時將最後一個元素刪除。微信

當勾選了某我的或部門時,會在底部的框中顯示全部已選人員或部門的名字,當文字超過屏幕寬度時能夠向右無限滑動,底部 footer 始終保持一行。網絡

最終選擇的人以底部 footer 裏顯示的爲準,點擊肯定時根據業務須要將已選人員數據發送給須要的界面。app

2、功能邏輯分析

先看看數據格式

{
  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 索引 pushindexList 中。點擊返回上一層按鈕時,刪除 indexList 中最後一個元素。

2. 要動態的更新當前列表 currentList

每進入新的一層,或返回上一層,都須要刷新 currentList 來實現頁面的更新。知道下一層數據很容易,直接取被點擊 itemchildren 賦值給 currentList 便可。

但如何還原上一層的數據呢?

第一點記錄的 indexList 就發揮做用了,原始數據樹爲 originalList,循環遍歷 indexList ,根據索引依次取出每層的 currentList 直到 indexList 的最後一個元素,就獲得了返回上一層須要顯示的數據。

3. 每一次勾選或取消選中都要更新原始的數據樹 originalList

頁面是根據每一個 itemchecked 屬性判斷是否選中的,因此每次改變勾選狀態都要設置被改變的 itemchecked 屬性,而後更新 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 值,若是數據量很大,會很是慢。我作了妥協……

關鍵的邏輯就這四塊了,固然還有不少小細節,直接看代碼吧,註釋寫的也比較詳細。

3、代碼

目錄結構:

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 請在評論區留言,畢竟剛寫完就分享出來了,還沒通過嚴格的測試。不過應該沒什麼大的問題。。。有些細節可能沒注意到。

相關文章
相關標籤/搜索