回到本初,看到多年前寫的一段移動端App內嵌入的H5兼容處理代碼,有段專門兼容處理輸入框類型的代碼:
- 針對
Android 5.0.1,5.0.2
time
類型的輸入框統統改成text
類型(當年的記憶猶新:這兩個版本有些手機上的彈框居然只有重置和取消兩個按鈕,被客戶叼了一頓);- 不管是
IOS
還是Android
,datetime,datetime-local
統統使用text
類型,手輸比默認的彈框選擇器高效多了。然后前些天在
Android 9.0
爪機上試了一下webview
默認的時間日期選擇,還是當年的 傻大黑粗 未曾改變,故事就這樣開始了......
在Android App里面嵌入了webview
做不可描述的事情,因為網頁里面的默認原生時間選擇器長的太丑,並且兼容的不確定性太多,正好分類性質(商品分類、省市區這種存在上下級關系)的彈出組件太丑想要升級一下,於是就開始着手寫設計和編寫一個通用的選擇器。
功能圖:
最后花了3天多時間打磨,終於大功告成,手機上使用一下,被驚艷到了٩(๑❛ᴗ❛๑)۶
最終實現效果:
本文主要起到記錄和參考作用,不管是用在H5移動端的設計、還是Android、IOS的界面設計,包括里面交互的設計,都是有意義的;但並沒有開源的打算(和自己的庫結合得太緊,剝不出來,自己的庫又依賴了swiper4),點此體驗>>
一、功能規划
事先並沒有畫圖,只是腦子里面過了一遍,上面的功能圖是事后畫的。
- 必須支持異步,因為異步可以當同步使用,同步只能同步到死;比較大的分類數據用異步加載食用效果更好;時間日期這種,雖然是異步調用,但數據是立即返回的,所以本質上還是同步的。
- 支持默認值的異步初始化,給個默認值,異步下不管幾級分類都能選中這個默認值。
- 彈框界面需要有個清空按鈕,相當於把輸入框的內容刪除,但當用戶進行了選擇動作時,這個按鈕改成返回(觀摩了很多Android、H5的Picker,無一例外沒有帶這種功能,只有返回,但我覺得是非常重要的);
- 點擊空白區域返回;
- 彈框界面每列需支持標頭(表頭?),每列都能定義自己的名字或便於識別的標志;
- 美觀、用戶操作上符合主流的操作方式。
二、最底層基礎實現
(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部分:
- 時間日期類,這部分分為
Time
、Date
、DateTime
,他們有部分邏輯可用共用,比如時間的計算,但界面上是不同的,此處不進行分解,放到后面的數據源部分進行分解; - 同步類 Type Sync,此種類型數據是已全部提前准備好,不存在
load
、resolve
復雜異步操作;雖然同種具體類型的界面和異步的完全一樣,但還是要單獨分開為一類。 - 異步類 Type Async,封裝好
load
、resolve
這些低級繁重操作給上層具體類型使用。
同步類
、異步類
兩個方法定義為:
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)時間日期
Time
、Date
、DateTime
選擇器除了界面不一樣外,數據基本相似:
- 都可以限定大小區間;
DateTime
的計算就包括了Time
、Date
兩個的實現;
可以抽象出兩個方法搞定這個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
方法,在內部生成columns
、load
、和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
,那會折磨死人,因為要寫復雜的數據加載和解析函數。
因此有了上一層的封裝:PickerTime
、PickerDate
、PickerDateTime
、PickerType
、PickerTypeAsync
。
但這些功能還是需要一個個手動調用,不夠簡單,我想要:
- 給個
dom節點
(比如輸入框),賦個城市ID,自動轉換成省市區名字顯示; - 點擊
dom節點
,自動彈出選擇,選擇完后自動更新名字顯示;
於是我進一步對Picker*
進行了封裝,得到了最頂上的兩層,而真正使用的也就是這兩層,很少會去調用太過底層的Picker*
。
這兩層是用我自己最為得意的編寫習慣來寫的,別人看到了這種寫法可能會吐,我就不特別介紹了,其實也沒有什么好介紹的,最終結果就是本文開頭的那張gif圖里面的那些表單,可點擊、點擊自動彈出Picker。如果感興趣,可以在控制台里面查看一下這些dom節點
就知道咋實現的啦。
> 完 <