一步步打造一個支援非同步載入資料的移動端選擇器

深海魚在掘金發表於2018-03-02

選擇器在應用中是使用比較頻繁的一個元件。在移動端,不同的作業系統預設的選擇器存在各種差異,iOS為底部滾動的選擇器,不同的Android系統,預設的選擇器也是不盡相同的。同時,預設的選擇器在web應用中定製性也比較差。在實際開發中,針對web應用,我們的需求一般是忽略系統差異,不同的作業系統間,同一個元件的表現形式應該是一致的。另外,我們常常要求選擇器可以定製,如支援多級聯動,支援標題定製,樣式定製和回撥等。因此,web應用中的選擇器大部分情況需要自己開發元件。目前,關於移動端選擇器元件在各大技術社群和github上都有不少的相關的專案,實現的方法和方式也是各式各樣。就我個人而言,我比較喜歡的選擇器互動方式為類似京東的地區聯動選擇器,如圖:

一步步打造一個支援非同步載入資料的移動端選擇器

原因有如下幾個(與iOS選擇器做比較):

  • 實現方法簡單
  • 可以更好實現真正意義上的無限級聯動
  • UI的定製更加靈活,如單行除了顯示一個項(京東一行顯示一個地址)以外,也可以所有的項緊密排列在一起,這樣對於比較多的資料時會顯得更清晰,操作更方便;定製樣式也會變得更靈活,如資料描述文字的長短控制(在iOS那種互動的選擇器下,如果文字過長可能需要做一些如顯示省略號的特殊處理
  • 相容性高,互動的事件更簡單

喜歡歸喜歡,實際上目前為止,本人接到最多的需求還是設計成仿照iOS選擇器的樣式,如下:

一步步打造一個支援非同步載入資料的移動端選擇器

本文將基於這種互動方式,實現一個仿iOS的選擇器,最終效果如上圖。類似京東地址選擇器的開發核心思路與此相同,甚至要簡單一些。

需求

  • 支援扁平的資料和巢狀的資料,如["北京市","上海市","天津市"]
[{
    name: '北京市',
    code:"110000",
    list: [
      {name: '東城區',code: '110101'},
	  {name: '西城區',code: '110102'},
      {name: '朝陽區',code: '110105'},
      {name: '豐臺區',code: '110106'},
      {name: '海淀區',code: "110108"},
      {name: '房山區',code:'110111'}
    ]
 }]

複製程式碼
  • 支援多級聯動選擇
  • 支援確認回撥,單項選擇回撥和取消選擇回撥
  • 支援非同步載入資料並動態改變選擇器
  • 支援樣式定製

開發思路

(1) 初始化時先建立必須的dom結構

(2) 遍歷傳入的資料,建立第一個可以滾動的選擇器,並選中第一個(如果有傳入預設值,則選擇對應的項),選中後如果當前項存在子級列表,則遞迴建立第二個滾動的選擇器,並依次遞迴建立第三個,第四個...直至不存在子級列表為止

(3)當滑動選擇器時,改變選擇器的偏移量來選擇對應的項,選擇結束後一樣需要遞迴建立子級滾動器

輔助資料

由於傳入的資料可以是扁平的,也可以是巢狀的。在實現過程有部分程式碼會有不同的處理情況,故以下給出兩組資料輔助理解。


//扁平的選擇資料
var fruit = ["蘋果","香蕉","火龍果","牛油果","草莓"];
//城市聯動資料
var city = [
  {
    name: '北京市', //name對應例項化時傳入的textKey
    code:"110000",
    list: [ //list對應傳入的childKey
      {name: '東城區'},
	  {name: '西城區'},
      {name: '朝陽區'},
      {name: '海淀區'}
    ]
  },
  {
    name: '廣東省',
    code: "440000",
    list:[
      {
        name: '廣州市',
        code: "440100",
        list: [
          {name: '海珠區',code:'440105'},
          {name: '天河區',code: "440106",
           list:[
				{name: '黃埔大道'},
				{name: '中山大道'},
				{name: '華夏路'}
			]
		  }
        ]
      },
      {
        name: '深圳市',
        list: [
			{name: '羅湖區'},
			{name: '南山區'},
			{name: '福田區'},
			{name: '龍華新區'},
			{name: '龍崗區'},
			{name: '寶安區'}
		]
      },
      {name: '東莞市'},
    ]
  }
];

複製程式碼

實現

html 與 CSS 部分

由於html結構和CSS本身不難也不是重點,這裡不做詳細的描述和分析,只給出基本的html和在CSS中對應的類名關係說明。詳細的程式碼可以見原始碼。

一步步打造一個支援非同步載入資料的移動端選擇器

圖中,黑色框部分為核心的html結構,後面所有的操作都操作這一部分的結構,其它部分例項化不再改變。每增加一個選擇器就增加一個<div class="mp-list-wrap><ul></ul></div>,通過改變ul的transform屬性來實現滾動效果。

javascript部分

1. 基本的程式碼結構

;(function(global){
  var PICKERCOUNT = 0; //同一個頁面可能有多個選擇器,用於記錄選擇器個數
  var body = document.getElementsByTagName("body")[0]; // body元素
  var coordinate = {start: {y:0},end: {y:0,status:true}, move: {y:0}}; //記錄手指滑動的座標
  function Picker(config){} //建構函式
  
  Picker.prototype = {
   constructor: Picker,
   //核心程式碼
   //...
  }

  if (typeof module !== 'undefined' && typeof exports === 'object') {
     module.exports = Picker;
  } else if (typeof define === 'function' && (define.amd || define.cmd)) {
    define(function() { return Picker; });
  } else {
    global.Picker = Picker;
  }
})(this);

複製程式碼

2. 工具函式

在實現邏輯時會多次用到同一個操作或計算,我們封裝一個Util物件,專門用來處理這部分的邏輯,程式碼和解釋如下:

var Util = {
  removeClass: function(el, className) {//刪除el元素指定的類
    var reg = new RegExp('(\\s|^)' + className + '(\\s|$)');
    el.className = el.className.replace(reg, ' ').replace(/^\s\s*/, '').replace(/\s\s*$/, '');
  },
  addClass: function(el, className) { //為el元素新增一個類
    !Util.hasClass(el,className) && (el.className += (el.className ? ' ' : '') + className);
  },
  hasClass: function(el, className) { //判斷el元素是否有對應的類
    return !!el.className && new RegExp('(^|\\s)' + className + '(\\s|$)').test(el.className);
  },
  loop: function(start,end,handle){ //迴圈操作
    for(var i = start; i < end; i++){
      Util.isFunc(handle) && handle(i);
    }
  },
  isFunc: function(name){ //判斷是否為函式
    return typeof name === 'function';
  },
  isArray: function(o) { // 判斷是否為陣列
    return Object.prototype.toString.call(o) === '[object Array]';
  },
  isObject: function(o) { // 判斷是否為物件
    return typeof o === 'object';
  },
  damping: function (value) {//阻尼運算,用於滾動時的彈性計算
    var steps = [20, 40, 60, 80, 100],rates = [0.5, 0.4, 0.3, 0.2, 0.1];
    var result = value,len = steps.length;
    while (len--) {
      if (value > steps[len]) {
        result = (value - steps[len]) * rates[len];
        for (var i = len; i > 0; i--) {
            result += (steps[i] - steps[i - 1]) * rates[i - 1];
        }
        result += steps[0] * 1;
        break;
      }
    }
    return result;
  },
  //建立元素,引數分別為父級元素,標籤名,類名,初始的html內容
  createEle: function(parent, tag, className, html) { 
    var ele = document.createElement(tag);
    className && Util.addClass(ele,className);
    html && (ele.innerHTML = html);
    parent && parent.appendChild(ele);
    return ele;
  },
  getEle: function(ctx, selector) { // 獲取元素
    return ctx.querySelector(selector);
  },
  setTransform: function(el,y) { // 設定el元素的translateY
    el.style.transform = 'translate3d(0,'+ y +'px,0)';
  }
}

複製程式碼

3. 建構函式

建構函式接收傳入的配置,包括資料,觸發選擇器的元素,事件回撥以及初始值,子列表的key等,同時要記錄當前選擇器的索引(通過PICKERCOUNT來計算)。構函式最終的程式碼如下:


function Picker(config){
  this.index = ++PICKERCOUNT;//當前選擇器的索引
  this.target = config.target instanceof HTMLElement ? config.target : typeof config.target === "string" ? Util.getEle(document,config.target) : null;//觸發選擇器的dom元素
  this.data  = config.data || [];//需要顯示的資料
  this.value = config.value ? (Util.isArray(config.value) ? config.value : config.value.split(',')) : [];//選擇器預設值
  this.childKey = config.childKey || 'child';//子資料索引名
  this.valueKey = config.valueKey || 'value';//用於索引初始值的key
  this.textKey = config.textKey || 'value';//用於顯示的key
  this.autoFill = !(config.autoFill === false);//選擇確定後是否填充到目標元素
  this.confirm = config.confirm;//確定選擇的回撥
  this.cancel = config.cancel;//取消回撥
  this.initCallback = config.initCallback;//例項化完成的回撥
  this.select = config.select;//單個列表選擇後的回撥
  this.lock = config.lock === true;//鎖定確定按鈕,用於非同步載入時等待使用
  this.className = config.className || '';//定製的類名
  this.init(); //生成各種結構
}

複製程式碼

4. init方法

init方法用於生成基本的dom結構(上圖中非黑色框部分),生成後遞迴建立選擇器列表,並繫結事件。程式碼和解析如下:

init: function(){
  this.initResult(); //初始化選擇器結果
  //基本的dom結構
  var html = '<div class="mp-container"><div class="mp-header"><span class="mp-cancel">取消</span><span class="mp-confirm'+(this.lock ? ' disabled' : '')+'">確定</span></div><div class="mp-content"><div class="mp-shadowup"></div><div class="mp-shadowdown"></div><div class="mp-line"></div><div class="mp-box"></div></div>';
  var container = Util.createEle(body,'div','mp-mask',html);
  // 如果傳入了額外的類名,則在外層容器新增類名
  this.className && Util.addClass(container,this.className);
  container.id = 'mobilePicker'+this.index;
  this.container = container;
  this.box = Util.getEle(container, '.mp-box')//用於包含滾動元素的容器
  this.createScroll(this.data);//核心方法:建立滾動的元素
  this.value = []; // 建立完重置傳入的預設值,後面的選擇器值均為上一次選擇的值
  this.bindEvent();//繫結事件
  this.finisInit(); //初始化結束後的回撥
}
initResult: function(config){
  this.scrollCount = 0;//已渲染的資料層級數
  this.selectIndex = [];//每個層級選中的索引集合
  this.result = [];//選擇器最終的結果
  this.offset = [];//每個層級滾動的偏移量集合
}

複製程式碼

5. 核心方法

接下來重點看一下以下幾個核心方法的實現思路和具體的程式碼。

  • 思路:

整個思路的方法呼叫關係如下圖:

一步步打造一個支援非同步載入資料的移動端選擇器

init方法建立完基本元素後呼叫createScroll方法,該方法首先建立一個類名為.mp-list-wrap的元素,用於包裹接下來要建立的滾動結構,同時為改元素記錄一個遞增的scrollIndex,用於後續定位該元素並更新資料和偏移量等操作;接著呼叫addList方法,遍歷傳入的data資料,先遍歷第一層資料,每遍歷一個拼接一個li標籤並填充資料,如果有傳入預設值,則比對當前的值是否與預設值相同,如果相同則記錄當前選擇列表選中的index為當前索引,否則index為0。拼接完所有的li標籤則新增至滾動器,並初始化當前的滾動器偏移量offset為0,緊接著呼叫selectItem方法,傳入index引數,選中當前需要選中的列表項。selectItem根據傳入的index計算出當前選中的結果儲存至結果集,並且計算當前滾動器的偏移量,將滾動器滾動至相應的位置。同時,遍歷傳入的data,根據index判斷當前data[index]是否有子級列表資料,如果有,則遞迴呼叫createScroll方法繼續建立下一個選擇列表,直至沒有子級列表。同時,每次執行選擇應該將當前列表以後的列表清除掉(removeScroll)或更新(udpateScroll)【見下面場景說明】。滾動的列表已經全部建立完成後,再呼叫calcWidth計算每一個scroll的寬度即可。

init方法呼叫完createScroll方法後需要呼叫bindEvent方法繫結事件。事件的處理基本也都是在不同的時機呼叫createScroll,selectItem以及更新單個scroll的方法等。

關於需要更新和刪除scroll的場景說明: 假設當前選擇器為一個城市聯動選擇器,使用者第一次選擇了廣東省->廣州市->天河區->中山大道。緊接著使用者第二次開啟選擇器,將廣東省改為了北京市,此時屬於第一個選擇列表,所以第二個選擇列表(即廣州市所在的列)的資料應該更改為北京市下面的區,通過呼叫updateList來實現,更新後預設選擇第一個,如東城區。假如此時東城區以下沒有子級的資料,那麼應該呼叫removeScroll來移除東城區所在列以後的列,即天河區和中山大道所在的列。

  • 詳細程式碼和解析
 createScroll: function(data){//建立滾動列表
    var scroll = Util.createEle(this.box,'div','mp-list-wrap','<ul></ul>');//建立一個scroll
    scroll.scrollIndex = this.scrollCount++; //為當前scroll新增索引
    this.addList(Util.getEle(scroll, 'ul'), data); //往scroll新增li元素
 },
 addList: function(parent, data){//新增資料
  var html = '',that = this;
  var index = 0,scrollIndex = parent.parentNode.scrollIndex,text = '';
  Util.loop(0,data.length,function(i){
    text = that.getText(data[i]);//計算要顯示的列表項文字
    html += '<li>'+text+'</li>';
    //初始化時有預設值,應該選中當前值,否則index就會為0,即選中第一個
    if(that.value.length && that.value[scrollIndex] && (Util.isObject(data[i]) && data[i][that.valueKey] === that.value[scrollIndex][that.valueKey] || data[i] == that.value[scrollIndex])){
     index = i;
    }
  });
  parent.innerHTML = html;
  this.offset.push(0);//每新增一個需要新增一個偏移量的記錄
  this.selectItem(data, index, scrollIndex);//選中並建立下一級選擇器
 },
 updateList: function(index,data){//更新某一列的資料
   var dom = this.box.childNodes[index];
   if(!dom){
     this.createScroll(data);
     return;
   }
   dom = Util.getEle(dom,'ul');
   this.addList(dom, data);
 },
 removeScroll: function(index){//移除某一列
   var that = this;
   var node = this.box.childNodes[index];
   if(node){
     this.box.removeChild(node);
     this.scrollCount--;//移除後當前的列數量要減1
     this.calcWidth(); //重新計算每一列的寬度
   }
 },
 selectItem:function(data, index, scrollIndex){//params: 資料,選中的索引,當前scroll的索引
  var that = this;
  var oldScrollCount = this.scrollCount;//記錄當前的列數
  this.selectIndex.length = this.result.length = scrollIndex + 1; //重置結果集的長度
  this.selectIndex[scrollIndex] = index;//記錄當前列選中的索引
  this.result[scrollIndex] = this.setResult(data[index]); //記錄當前列選中的結果(去除子列表)
  this.setOffset(scrollIndex, index); //  將當前列滾動至選擇的位置
  if(data[index] && data[index][that.childKey] && Util.isArray(data[index][that.childKey]) && data[index][that.childKey].length){//存在子級列表
    if(that.scrollCount < scrollIndex + 2){//如果上一次的列數少於當前需要的列數,則建立一個新的列
      that.createScroll(data[index][that.childKey]);
    }   else { // 目前已有的列數不小於當前需要的列數,則更新對應的列的資料
      that.updateList(scrollIndex + 1, data[index][that.childKey]);
    }
  } else {//說明當前的已有的列數目多於需要的,移除多餘的
    for ( var j = oldScrollCount - 1, len = that.selectIndex.length; j >= len; j-- ) {//刪除多餘的ul
      that.removeScroll(j);
    }
  }
 this.offset.length = this.selectIndex.length;//重置偏移量結果集的長度
 this.calcWidth();//計算每一列的寬度
 Util.isFunc(that.select) && that.select(scrollIndex,this.result,index,data[index] && data[index][that.childKey] && Util.isArray(data[index][that.childKey]) && data[index][that.childKey].length); //執行單列選擇回撥,一般用於非同步請求資料
},
 bindEvent: function(){//事件繫結
   var that = this;
    that.target.disabled = true;
    ['touchstart','touchend','touchmove'].forEach(function(action){
        that.box.parentNode.addEventListener(action,function(event){
        event = event || window.event;
        event.preventDefault();
        var target  = event.target;
        var index = target.parentNode.scrollIndex;
        var child = target.childNodes;
        var liHeight = child[child.length - 1].offsetHeight;
        var scrollHeight = child[child.length - 2].offsetTop;
        if(target.tagName.toLowerCase() != 'ul') return;
        switch(action) {
          case 'touchstart':
            if(coordinate.end.status){
              coordinate.end.status = !coordinate.end.status;
              coordinate.start.y = event.touches[0].clientY;
              coordinate.start.time = Date.now();
            }
            break;
          case 'touchmove':
            coordinate.move.y = event.touches[0].clientY;
            var distance = coordinate.start.y - coordinate.move.y;
            var os = distance + that.offset[index];
            if(os < 0){//已經滑到最頂部
              Util.setTransform(target, Util.damping(-os));
            } else if(os <= scrollHeight){
              Util.setTransform(target, -os);
            } else {//超過了整體的高度
              Util.setTransform(target, -(scrollHeight + Util.damping(os-scrollHeight)));
            }
            break;
          case 'touchend': //停止滾動後計算應該選擇的項
            coordinate.end.y = event.changedTouches[0].clientY;
            var count = Math.floor((that.offset[index] + (coordinate.start.y - coordinate.end.y))/liHeight + 0.5)
            count = count < 0 ? 0 : Math.min(count, target.childNodes.length - 1);
            var temp = that.offset[index];
            that.offset[index] = count < 0 ? 0 : Math.min(count * liHeight,target.offsetHeight - 5 * liHeight)
            Util.setTransform(target, -that.offset[index]);
            coordinate.end.status = true;
            that.selectIndex.length  = index + 1;
            that.selectIndex[index] = count;
            that.selectItem(that.getData(that.selectIndex),count,index);
            break;
        }
      },false)
    });
    that.target.addEventListener('touchstart',function(event){
      (event || window.event).preventDefault();
	   //記錄舊結果,用於取消恢復
      that.oldResult = that.result.slice(0);
      that.update({//由於更新整個選擇器
        value: that.result,
        valueKey: that.textKey
      });
      that.show();
    });
    //  用click事件代替touchstart防止點透
    Util.getEle(that.container,'.mp-cancel').addEventListener('click',function(){
      that.hide();
	  //恢復舊的結果,update方法見後面的介面方法
      that.update({
        value: that.oldResult,
        valueKey: that.textKey
      });
      Util.isFunc(that.cancel) && that.cancel();
    },false);
    Util.getEle(that.container,'.mp-confirm').addEventListener('click',function(){
      if(that.lock) return;
      var value = that.fillResult(); //計算最終的選擇結果
      that.hide();
      Util.isFunc(that.confirm) && that.confirm(value, that.result);
    });
  }

複製程式碼
  • 其他輔助程式碼
 hide: function(){//關閉選擇器
    var that = this;
    Util.getEle(this.container,'.mp-container').style.transform = 'translate3d(0,100%,0)';
    Util.removeClass(body, 'mp-body');
    setTimeout(function(){
      that.container.style.visibility = 'hidden';
    },250)
  },
  show: function(){ //顯示選擇器
    var that = this;
    that.container.style.visibility = 'visible';
    Util.addClass(body, 'mp-body');
    setTimeout(function(){
      Util.getEle(that.container,'.mp-container').style.transform= 'translate3d(0,0,0)';
    },0)
  },
  fillContent: function(content){ //填充最終選擇的資料到觸發的元素
    var tagName  = this.target.tagName.toLowerCase();
    if(['input','select','textarea'].indexOf(tagName) != -1) {
      this.target.value = content;
    } else {
      this.target.innerText = content;
    }
  },
  fillResult: function(){ //計算最終結果
    var value = '';
      for(var i = 0,len = this.result.length; i < len; i++){
        if(Util.isObject(this.result[i])){ //如果是巢狀的資料,則根據textKey來拼接
          this.result[i][this.textKey] && (value += this.result[i][this.textKey]);
        } else { //扁平的資料則直接拼接
          value += this.result[i];
        }
      }
      this.autoFill && this.fillContent(value); //選擇後需要自動填充則填充資料
      return value;
  },
  getText: function(data){ //每一項要顯示的文字,分扁平資料和巢狀資料兩種情況
    return Util.isObject(data) ? data[this.textKey] : data;
  },
  finisInit: function(){//初始化完成後內部呼叫的方法
    var value = this.fillResult();
    Util.isFunc(this.initCallback) && this.initCallback(value,this.result);
  },
  setOffset: function(scrollIndex, index){ //設定偏移量
    var scroll = this.box.childNodes[scrollIndex].querySelector('ul');
    var offset = scroll.childNodes[0] ? scroll.childNodes[0].offsetHeight * index : 0;
    Util.setTransform(scroll, -offset)
    this.offset[scrollIndex] = offset;
  },
 setResult: function(data){ //去除子級列表,計算每一列選擇的結果
    if(!Util.isObject(data)) return data;
    var temp = {};
    for(var key in data){
      key != this.childKey && (temp[key] = data[key]);
    }
    return temp;
  },
 getData: function(indexes){//根據一組所以深度遍歷資料,獲取資料當前需要新增的集合
    var arr = [];
    for(var i = 0; i < indexes.length; i++){
      arr = i == 0 ? this.data : arr[indexes[i -1]][this.childKey];
    }
    return arr;
 }

複製程式碼

6. 介面方法

由於選擇器需要支援非同步載入資料,則需要在某些時機操作和動態的更新選擇器,因此需要提供一些介面方法供非同步載入時使用。其中上面的removeScrolladdList以及updateScroll均可作為介面方法。除此之外還需要提供以下方法:

/*傳入新的資料,更新整個選擇器
該方法相當於重新初始化選擇器,僅僅只是省去了建立基本dom介面和事件繫結的過程
*/
update: function(options){ 
  for(var i in options) {
    this[i] = options[i];
  }
  this.initResult()
  this.box.innerHTML = '';
  this.createScroll(this.data);
  this.value = [];
}, 
/* 更新某一列的資料,一般用於聯動時載入下一級資料
@param: index 要更新的列索引
@param: data 要更新的資料集合
@pramg: value 預設選中的值
@param: callback 更新後的回撥函式
*/

setScroll: function(index,data,value,callback) {
  value && (this.value[index] = value);
  this.offset.length = this.selectIndex.length = this.result.length = this.selectIndex.length = index;
  if(index == 0){
    this.data = data;
  } else {
    var temp = this.data[this.selectIndex[0]];
    for(var i = 1, len = index; i < len; i++){
      temp = temp[this.childKey][this.selectIndex[i]];
   }
    temp && (temp[this.childKey] = data); //更新data裡對應的資料
  }
  this.updateList(index,data);
  this.value = [];
  Util.isFunc(callback) && callback(index,this.result);
},
 setLock: function(value){ //鎖定或者解鎖確定按鈕,一般用於請求資料時的等待,以保證資料的完整性
    var confirm = Util.getEle(this.container,'.mp-confirm'),old = this.lock;
    this.lock = value !== false;
    if(old !== this.lock) {
      this.lock ? Util.addClass(confirm,'disabled') : Util.removeClass(confirm, 'disabled');
    }
  },


複製程式碼

例項

以下所有例項點選標題可以在移動端預覽效果,你可以在手機端點選標題或者用手機掃描二維碼預覽。

一步步打造一個支援非同步載入資料的移動端選擇器

<div class="container">
 <label for="">水果:</label>
 <input type="text" id="fruit">
</div>

複製程式碼
var fruit = ['蘋果','香蕉','火龍果','芒果','百香果'];
new Picker({
  target: '#fruit',
  data: fruit,
  value: '火龍果' //預設值
});

複製程式碼

效果如下:

一步步打造一個支援非同步載入資料的移動端選擇器

一步步打造一個支援非同步載入資料的移動端選擇器

這是一個綜合例項,包含的內容有:巢狀的資料傳預設值,自動填充和自定義填充,一個頁面有多個選擇器,childKey,valueKey,textKey,initCallback,confirm等的使用。

html:


<div class="container">
    <div>
      <label for="">出發地</label>
      <input type="text" id="start">
    </div>
    <div>
      <label for="">目的地</label>
      <input type="text" id="end">
    </div>
    <div class="print">
      <div>選擇結果:</div>
      <div>startValue: <span id="startValue"></span></div>
      <div>startResult: <span id="startResult"></span></div>
      <div style="margin: 10px 0">--------------------</div>
      <div>endValue: <span id="endValue"></span></div>
      <div>endResult: <span id="endResult"></span></div>
    </div>
 </div>

複製程式碼

data的基本格式:

var city = [
  {
    name: '北京市',
    code:"110000",
    list: [
      {name: '東城區'},
      {name: '豐臺區'},//...
    ]
  },
  {
    name: '廣東省',
    code: "440000",
    list:[
      {
        name: '廣州市',
        code: "440100",
        list: [
          {name: '海珠區',code:'440105'},
          {name: '天河區',code: "440106",
		   list:[
				{name: '黃埔大道'},
 				{name: '中山大道'},
				{name: '華夏路'}
		  ]}
        ]
      },
      //...
    ]
  }
 //...
 
];

複製程式碼

邏輯程式碼:


new Picker({
  target: document.getElementById('start'),//直接傳入dom元素
  data: city,
  textKey: 'name', //用於顯示的key
  valueKey: 'name', //用於關聯預設值的key
  childKey: 'list', //子資料列表的key
  value: [{name: '廣東省'},{name: '深圳市'},{name: '南山區'}], //預設值
  confirm: function(value,result){ //確定選擇的回撥
    document.getElementById('startValue').innerText = value;
    document.getElementById('startResult').innerText = JSON.stringify(result);
  },
  initCallback: function(value,result) { //初始化結束後的回撥
    document.getElementById('startValue').innerText = value;
    document.getElementById('startResult').innerText = JSON.stringify(result);
  }
});
new Picker({
  target: '#end',
  data: city,
  textKey: 'name',
  autoFill: false, //不自動填充
  childKey: 'list',
  confirm: function(value,result){
    document.getElementById('endValue').innerText = value;
    document.getElementById('endResult').innerText = JSON.stringify(result);
    //結果處理後再顯示
    var text = [];
    for(var i = 0; i < result.length; i++){
      text.push(result[i].name);
    }
    this.target.value = text.join('-');//this.target取得當前的目標元素,並自定義填充
  }
});

複製程式碼

效果如下:

一步步打造一個支援非同步載入資料的移動端選擇器

一步步打造一個支援非同步載入資料的移動端選擇器

其中html結構與上面的城市聯動結構一致,不同的是,這次,第三級以後的資料(如天河區等)採用非同步載入的方法來獲取。思路是:當選擇一個列後,呼叫select回撥,如果當前沒有下一級資料則需要非同步載入,載入時通過setLock方法鎖定確定按鈕,不讓點選,以保證資料的完整性。同時,呼叫setScroll方法預先新增一個空白列,待資料返回後,如果有子級列表,則再呼叫setScroll方法更新當前列的資料,否則,呼叫removeScroll方法移除預先新增的列。完成後,再呼叫setLock方法解鎖確定按鈕。

function getDistrict(code){
  var data = {
    "code_440330" : [{name: '羅湖區'},{name: '南山區'},{name: '福田區'},{name: '龍華新區'},{name: '龍崗區'},{name: '寶安區'}],
    "code_440100": [{name: '海珠區',code:'440105'},{name: '天河區',code: "440106",list:[{name: '黃埔大道'},{name: '中山大道'},{name: '華夏路'}]}],
    "code_440700":  [{name: '臺山市'},{name: '鶴山市'},{name: '開平市'},{name: '新會區'},{name: '恩平市'}],
    "code_330100": [{name: '桐廬縣'},{name: '江乾區'},{name: '西湖區'},{name: '下城區' }],
    "code_330200": [{name: '江東區'},{name: '江北區'},{name: '高新區'},{name:'海曙區'},{name: '象山區'},{name:' 慈溪市'}]
  }
  return data['code_'+code] || [];
}
var picker = new Picker({
    target: '#area',
    data: city,
    textKey: 'name',
    childKey: 'list',
    confirm: function(value, result){
      var str = [];
      for(var i = 0, len = result.length; i < len; i++){
        str.push(result[i].name);
      }
      this.target.value = str.join('-');
      document.getElementById('value').innerText = value;
      document.getElementById('result').innerText = JSON.stringify(result)
    },
    select: function(scrollIndex,result,index,haschild){
      var city = result[scrollIndex];//獲取結果集中的當前項
      var that = this;
      //當選擇的不是城市級別或者選擇的是直轄市或者當前選擇的城市已有子級列表(沒有的在請求後會被快取)則不做操作
      if(scrollIndex !== 1 || "11|12|31|50".indexOf(city.code.substring(0, 2)) >= 0 || haschild) return;
      this.setScroll(scrollIndex + 1, []);//建立空白的下一級選擇器,之所以這樣做是防止頁面抖動
      that.setLock(true);//因為是非同步請求,資料沒返回之前鎖定選擇器,請留意效果圖中的確定按鈕
      setTimeout(function(){//這裡模擬一個1秒鐘的非同步請求
        var data = getDistrict(city.code);//拿到非同步資料
        if(data.length){
          that.setScroll(scrollIndex + 1, data);//更新下一級選擇器的資料
        } else {
          that.removeScroll(scrollIndex + 1);//沒有資料,則移除之前防止抖動的選擇器
        }
        that.setLock(false);//請求完畢,解鎖
      },1000)
    }
 })
複製程式碼

效果如下:

一步步打造一個支援非同步載入資料的移動端選擇器

時間選擇器

一步步打造一個支援非同步載入資料的移動端選擇器

自定義樣式

一步步打造一個支援非同步載入資料的移動端選擇器

這兩個例子這裡不放程式碼。其中自定義樣式比較簡單,原始碼可以看這裡。而時間選擇器是一種特殊的選擇器,選擇器的資料基本可以通過計算得到而不需要額外傳入,同時,配置項也有所差異,所以,時間選擇器更適合寫一個獨立的選擇器,後續我會在github上加上獨立的時間選擇器。當前時間選擇器的例子的原始碼比較囉嗦同時還會造成介面抖動,原始碼可以檢視這裡檢視。兩個例子的效果如下):

一步步打造一個支援非同步載入資料的移動端選擇器

一步步打造一個支援非同步載入資料的移動端選擇器

總結

無論是實現其他互動型別的選擇器還是實現仿iOS的選擇器,實現的方式都各式各樣。如果在實際的應用只是有一處用到了選擇器,那應該沒有必要去專門封裝一個通用的選擇器,直接按需寫一個僅僅滿足需求反而會簡單和快捷一些。本文提供一個封裝選擇器的思路,供大家一起學習和探討,歡迎反饋問題。本文所有原始碼和使用的文件均在github上,有興趣的可以fork下來繼續完善,點選這裡檢視

相關文章