設計和編寫一個非同步通用Picker選擇器,用於時間日期、城市、商品分類的選擇

xiangyuecn發表於2019-07-07

回到本初,看到多年前寫的一段移動端App內嵌入的H5相容處理程式碼,有段專門相容處理輸入框型別的程式碼:

  • 針對Android 5.0.1,5.0.2 time型別的輸入框統統改成text型別(當年的記憶猶新:這兩個版本有些手機上的彈框居然只有重置和取消兩個按鈕,被客戶叼了一頓);
  • 不管是IOS還是Android,datetime,datetime-local統統使用text型別,手輸比預設的彈框選擇器高效多了。

然後前些天在Android 9.0爪機上試了一下webview預設的時間日期選擇,還是當年的 傻大黑粗 未曾改變,故事就這樣開始了......

在Android App裡面嵌入了webview做不可描述的事情,因為網頁裡面的預設原生時間選擇器長的太醜,並且相容的不確定性太多,正好分類性質(商品分類、省市區這種存在上下級關係)的彈出元件太醜想要升級一下,於是就開始著手寫設計和編寫一個通用的選擇器。

功能圖:

設計和編寫一個非同步通用Picker選擇器,用於時間日期、城市、商品分類的選擇

最後花了3天多時間打磨,終於大功告成,手機上使用一下,被驚豔到了٩(๑❛ᴗ❛๑)۶

最終實現效果:

設計和編寫一個非同步通用Picker選擇器,用於時間日期、城市、商品分類的選擇

本文主要起到記錄和參考作用,不管是用在H5移動端的設計、還是Android、IOS的介面設計,包括裡面互動的設計,都是有意義的;但並沒有開源的打算(和自己的庫結合得太緊,剝不出來,自己的庫又依賴了swiper4),點此體驗>>
設計和編寫一個非同步通用Picker選擇器,用於時間日期、城市、商品分類的選擇

一、功能規劃

事先並沒有畫圖,只是腦子裡面過了一遍,上面的功能圖是事後畫的。

  1. 必須支援非同步,因為非同步可以當同步使用,同步只能同步到死;比較大的分類資料用非同步載入食用效果更好;時間日期這種,雖然是非同步呼叫,但資料是立即返回的,所以本質上還是同步的。
  2. 支援預設值的非同步初始化,給個預設值,非同步下不管幾級分類都能選中這個預設值。
  3. 彈框介面需要有個清空按鈕,相當於把輸入框的內容刪除,但當使用者進行了選擇動作時,這個按鈕改成返回(觀摩了很多Android、H5的Picker,無一例外沒有帶這種功能,只有返回,但我覺得是非常重要的);
  4. 點選空白區域返回
  5. 彈框介面每列需支援標頭(表頭?),每列都能定義自己的名字或便於識別的標誌;
  6. 美觀、使用者操作上符合主流的操作方式

二、最底層基礎實現

(1)Picker介面和功能實現

這是最底層部分,只定義資料格式和展示風格,具體資料由使用者通過非同步回撥提供。

只負責:

  • √ 定義接收資料的格式和接收資料;
  • √ 彈出介面和更新介面;
  • √ 使用者互動;

不管:

  • × 資料是什麼;
  • × 資料有多少;
  • × 資料分多少級,1 - ∞都是可以的,只要顯示的下;
  • × 資料的有效性(包括是否允許子級缺失);
  • × 更細的顯示外觀、控制。

因此可以定義為(摘錄的一部分註釋和虛擬碼僅供參考,下同):

Picker=func(set,onChange,onCancel){...}

其中最為核心的設定參考:
set={
    value:any //初始值。注意:null為特殊值,代表沒有任何選擇,其他型別的空值應該轉換成這個標準空值,比如0需要轉換成null
    ,title:"標題" //當然可以是手寫的html,自定義樣式
    
    ,columns:[ //定義選擇列,限定了選擇層級數量
        {
            name:"列名稱"
            ,weight:1 //列寬度權重
            ,... //更多列風格配置
        }
        ,...
    ]
    
    ,load:func //載入指定選項的子級列表資料
        /*load(vals,onLoad,onError)
            vals=[level0,level1,....levelx]
            當資料成功載入時使用者需要回撥onLoad(childs),此處定義了資料的格式...
            出錯回撥onError(msg),包括資料無效時也是此回撥
        */
    ,resolve:func //對初始值value進行反向解析出所有上級,如果初始值value=null空值時不會進行解析呼叫
        /*resolve(value,onLoad,onError)
            onLoad(vals) 反向解析出來的層級列表[level0,level1,...,value]
            onError(msg)
        */
}

介面實現上參考大部分開源的選擇器樣式,挑個美觀的照著畫和配色就ok啦;最後總結出來一個比較好使用的介面:選擇器顯示7行候選項,每行45px,在觀看和操作上都是比較優良的;上面的gif因為要截圖所以設定了5行;還要留意一下滾動選項時如果滾動元件沒有回撥,我們可以通過監控選中位置變化來強制重新整理介面,swiper偶爾動快了會丟失回撥,還好處理手段蠻多。

最大的挑戰還是應對複雜多變的配置項和組合邏輯,如何應用到介面裡面,不過我有100行不到的過氣html模板解析引擎,反正隨意到沒朋友,再複雜的介面也應對自如,寫完這個選擇器還特地更新了一下文件,前往GitHub BuildHTML圍觀

(2)不同型別的選擇器基礎實現

Picker已經搞定啦,但針對不同的資料來源,我們還是要封裝一下,不然每個型別的選擇器直接呼叫Picker那會太複雜了,比如:時間、日期的操作可以共享很多相同程式碼,非同步型別的load、和resolve資料請求部分可以進行一次封裝。

因此就分成了3部分:

  1. 時間日期類,這部分分為TimeDateDateTime,他們有部分邏輯可用共用,比如時間的計算,但介面上是不同的,此處不進行分解,放到後面的資料來源部分進行分解;
  2. 同步類 Type Sync,此種型別資料是已全部提前準備好,不存在loadresolve複雜非同步操作;雖然同種具體型別的介面和非同步的完全一樣,但還是要單獨分開為一類。
  3. 非同步類 Type Async,封裝好loadresolve這些低階繁重操作給上層具體型別使用。

同步類非同步類兩個方法定義為:

PickerType=func(set,onChange,onCancel)

其中最為核心的設定參考:
set={
    value:123 //預設值
    ,title:"請選擇"
    ,data:{} //必填,完整的型別資料,具體資料格式在這裡統一定義,會自動轉成Picker需要的格式
    
    ,allowLose:false //是否允許有的選項沒有下一級,當然不允許啦,如果缺失了下級,`load`的時候會直接走錯誤回撥
    
    ,columns:[] //必填,為Picker.columns選項
    
    ,picker:{} //picker配置,columns、title不用在這裡寫
    
    ,itemFormat:func //對選項進行格式化,比如選項名稱特殊處理一下
    ,itemsSort:func //對選項列表進行排序
}
PickerTypeAsync=func(set,onChange,onCancel)

其中最為核心的設定參考:
set={
    extend PickerType.set +* -data
    //和PickerType的基本相同,只是沒有data資料而已,增加下面兩個
    
    type:"load 要載入的資料型別" //load、resolve應該呼叫後端統一的一個介面,通過type引數控制載入具體型別的資料
    ,hotData:[] //可選熱啟動資料,比如前幾級的完整資料比較小可以預先載入
}

另外此函式應該對load、resolve獲取到的資料進行快取,避免每滑動一下就請求伺服器

三、資料來源層

(1)時間日期

TimeDateDateTime選擇器除了介面不一樣外,資料基本相似:

  1. 都可以限定大小區間;
  2. DateTime的計算就包括了TimeDate兩個的實現;

可以抽象出兩個方法搞定這個3個具體型別的資料生成:
(1)通過[年、月]提供0-2個上級,就能生成年、月、日3個級別的列表資料:

/*生成日期部分的js完整程式碼
set提供大小範圍的Date例項
vals為年、月取值
    vals=[] 生成年份列表
    vals=[2010] 生成2010年的月份列表
    vals=[2010,2] 生成2010年2月的天數列表

如genDate({min:new Date("2012-01-01"),max:new Date("2012-02-06")},[2012,2]) 當然set是在初始化時就準備好的,不可能這樣寫
*/
function genDate(set,vals){
    var min=set.min;
    var max=set.max;
    
    var a,b;
    var minY=min.getFullYear(),maxY=max.getFullYear();
    var minM=min.getMonth()+1,maxM=max.getMonth()+1;
    var y=vals[0],m=vals[1];
    var fixed=-2;
    if(vals.length==0){
        a=minY;
        b=maxY;
        fixed=-4;
    }else if(vals.length==1){
        a=y==minY?minM:1;
        b=y==maxY?maxM:12;
    }else{
        a=y==minY&&m==minM?min.getDate():1;
        if(y==maxY&&m==maxM){
            b=max.getDate();
        }else{
            if("|1|3|5|7|8|10|12|".indexOf("|"+m+"|")+1){
                b=31;
            }else if(m==2){
                if(y % 4 == 0 && y % 100 != 0 || y % 400 === 0){
                    b=29;
                }else{
                    b=28;
                };
            }else{
                b=30;
            };
        };
    };
    
    var rtv=[];
    for(var i=a;i<=b;i++){
        rtv.push({
            text:("0"+i).substr(fixed)
            ,value:i
        });
    };
    return rtv;
};

(2)通過[時][年、月、日、時]提供0-1個(Time) 或3-4個(DateTime)上級,就能生成時、分2個級別的列表資料:

/*生成時間部分的js完整程式碼
set提供大小範圍的日期或時間數字
vals為年、月、日、時取值,前3個在DateTime型別時才有,不然就是Time型別
    vals=[] 生成小時列表
    vals=[22] 生成分鐘列表

如Time類:genTime({min:10*60+56,max:21*60+3},[21])
如DateTime類:genTime({min:new Date("2012-01-01 10:56"),max:new Date("2012-02-06 21:03")},[2012,2,6,21])
*/
function genTime(set,vals){
    var min=set.min;
    var max=set.max;
    var h=vals[0];
    if(vals.length>2){//DateTime
        var y=vals[0],m=vals[1],d=vals[2];
        if(y==min.getFullYear()&&m==min.getMonth()+1&&d==min.getDate()){
            min=min.getHours()*60+min.getMinutes();
        }else{
            min=0;
        };
        if(y==max.getFullYear()&&m==max.getMonth()+1&&d==max.getDate()){
            max=max.getHours()*60+max.getMinutes();
        }else{
            max=23*60+59;
        };
        h=vals[3];
    };
    
    var a,b;
    var minH=Math.floor(min/60),maxH=Math.floor(max/60);
    if(h==null){
        a=minH;
        b=maxH;
    }else{
        a=h==minH?min%60:0;
        b=h==maxH?max%60:59;
    };
    
    var rtv=[];
    for(var i=a;i<=b;i++){
        rtv.push({
            text:("0"+i).substr(-2)
            ,value:i
        });
    };
    return rtv;
};

有了這兩個方法,我們就可以寫著3個型別的具體實現啦:

PickerTime=func(set,onChange,onCancel)
PickerDate=func(set,onChange,onCancel)
PickerDateTime=func(set,onChange,onCancel)

3個最為核心的設定都基本類似:
set={
    min:123 ||"00:00" //最小時間
    max:123 ||"23:59" //最大時間
    value:123 ||"10:01" //設定時間,如果為null為當前時間部分
    
    title:"選擇時間"
    picker:{} //Picker更多配置項
}


各型別內部呼叫Picker時load寫法
PickerTime
load:function(vals,onLoad,onError){
    onLoad(genTime(set,vals));
}

PickerDate
    onLoad(genDate(set,vals));
    
PickerDateTime
    onLoad(vals.length>2?genTime(set,vals):genDate(set,vals));

這3個型別直接呼叫的Picker方法,在內部生成columnsload、和reverse配置項,使用者無需關係這些最底層的複雜配置。

(2)多級同步分類,如:城市

因為在Picker之上已經實現了同步類的選擇器Type Sync,因此我們只需要直接呼叫PickerType這個同步方法,傳入分類資料即可。

比如省市區3級的選擇,我們就把城市省市區3級資料一股腦的載入到頁面裡即可。

(3)多級非同步分類,如:城市

因為在Picker之上已經實現了非同步類的選擇器Type Async,因此我們只需要直接呼叫PickerTypeAsync這個同步方法,傳入要非同步載入的型別即可,型別可以是:省市區這種城市、也可以是商品分類,甚至很古怪的分類也可以支援。

比如省市區鎮4級的選擇,我們只需要把type="city"之類的設定一下就ok啦;為了提升響應速度,可以預先把省市區3級載入為熱資料。

另附:GitHub AreaCity-JsSpider-StatsGov 省市區鎮資料,一年來還是更新的蠻勤快的,我自己在用,還有快1000的star啦。

四、最終的呼叫層

如果直接使用Picker,那會折磨死人,因為要寫複雜的資料載入和解析函式。

因此有了上一層的封裝:PickerTimePickerDatePickerDateTimePickerTypePickerTypeAsync

但這些功能還是需要一個個手動呼叫,不夠簡單,我想要:

  1. 給個dom節點(比如輸入框),賦個城市ID,自動轉換成省市區名字顯示;
  2. 點選dom節點,自動彈出選擇,選擇完後自動更新名字顯示;

於是我進一步對Picker*進行了封裝,得到了最頂上的兩層,而真正使用的也就是這兩層,很少會去呼叫太過底層的Picker*

這兩層是用我自己最為得意的編寫習慣來寫的,別人看到了這種寫法可能會吐,我就不特別介紹了,其實也沒有什麼好介紹的,最終結果就是本文開頭的那張gif圖裡面的那些表單,可點選、點選自動彈出Picker。如果感興趣,可以在控制檯裡面檢視一下這些dom節點就知道咋實現的啦。


> 完 <

相關文章