首先呢,我要感謝一下和我配合的設計師同學,給了我一份如此漂亮的時間線設計稿。拿到設計稿後,我們首先要做的是去分析整個作品的結構,及設計意圖。當然這些工作我們可以提前完成,在設計師設計的時候進行充分溝通,而當我們動手去實現的時候就更為得心應手了。開始之前,我們還是先看看最終的互動效果:
第一步,分析控制元件結構,說多了反而容易引起誤導,直接看圖吧,直觀明瞭:
接下來,我來說說我為什麼要這麼去劃分。簡單的說,在我看來分析一個控制元件的結構,其實就是嘗試去發現一些規律性的東西。如上圖,在月份欄很明顯是在每月第一日的位置上方顯示當月標籤,因此第一行自然可以獨立成一個在特定位置顯示月份資訊的獨立區域。而第二行更明顯,將時間線上的每一天從左往右挨個兒顯示就行了,很顯然這一行上只會出現具體天的資料,所以我也將它獨立成一個區域。再往下,我們先看最下面的刻度區域,這裡存在一定的迷惑性,由於每月第一天的那條線高度剛好填充滿,而兩個月的第一天剛好將整條刻度劃分成一個一個的小區間,但事實上,我覺得它們其實就是一條橫向連通的刻度線,所以應該整個一行是一個整體。最後再來說說灰色、藍色重疊的那條區域,我們可以把藍色區域包含在灰色區域之中,也可以分成兩個同級的區域重疊在一起。但這裡需要注意一點:藍色區域表明的是一個時間區間,它的水平位置和寬度是隨時可變的,所以,考慮到後面定位的方便,未知的需求變化,我更傾向分成兩個獨立區域重疊起來。
第二步,細化每個區域中的共同點,不同點。月份欄,沒有問題,就是在每月第一天的位置顯示月份標籤,唯一不同的是每月第一天的橫座標(left)。日期欄基本與月份欄情況一致,多一點不同在於實現過程中考慮突出顯示【今天】。灰色線很簡單,從左到右一拉到底,藍色線很顯然是在選中某個事件標籤的時候才會出現,所以期初也不用考慮。最後還是刻度欄相對複雜一點,水平位置上的差異不再多說,每月第一天需要追加高刻度樣式,而且為了方便後面與事件標籤關聯,我們需要在生成刻度的時候,在每個刻度上存上對應的日期。大致就是這樣,下面就可以著手編碼了。
第三步,搭框架。就好像畫素描一樣,先勾勒出整個作品的骨架,再慢慢細化。
程式碼很簡單,先建立一個時間線全域性容器,再往裡面寫入第一步和第二步中我們分析出來的幾個獨立區塊,最後想整個時間線放入頁面中準備好的節點(wrap)中。
第四步,寫靜態資料,細節優化。根據前面的分析,各個部分的容器都已經準備就緒,接著就該將各部分應該呈現的內容寫入到對應的位置中去了,這一步程式碼會稍微多些。而且根據自己的需求,可能在某些判斷邏輯上會略顯不同,先看看我的示例程式碼吧:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 |
var Utils = { // 格式化數字,小於10補前置0 prefixZero: function(num) { return num < 10 ? '0' + num : num; } }; function createTimeline(from, to) { var Timeline, f = typeof from === 'string' ? new Date(from.replace(/-/g, '/')) : from, t = typeof to === 'string' ? new Date(to.replace(/-/g, '/')) : to, timestamp = t - f, day = 24 * 60 * 60 * 1000, today = new Date(), miliStart = f.getTime(), dayCount = Math.floor(timestamp / day) + 1, // 計算時間軸上顯示的總天數 offLeft = 12, // 初始日期左邊距 offRight = 12, // 結束日期右邊距 offDay = 20, // 日與日之間的間距 lineLength = dayCount * offDay + offLeft + offRight; // 計算時間軸的長度 var line = $('<div class="J_TimeLine timeline-slider"></div>'), monthLabel, dayLabel, dayDiff, scaleLabel; line.css({width: lineLength, height: 134}); line.html('<div class="J_MonthLabel month-label"></div>' + '<div class="J_DayLabel day-label"></div>' + '<div class="J_ScaleLine scale-line"></div>' + '<div class="J_DayDiff day-diff"></div>' + '<div class="J_ScaleLabel scale-label"></div>'); monthLabel = line.find('.J_MonthLabel'); dayLabel = line.find('.J_DayLabel'); dayDiff = line.find('.J_DayDiff'); scaleLabel = line.find('.J_ScaleLabel'); for (var i = 0; i < dayCount; i++) { var d = new Date(miliStart + i * day), left = i * offDay + offLeft, monthObj, dayObj, scaleObj; dayObj = $('<span class="J_Day day"></span>'); dayObj.css('left', left - 9); dayObj.html(d.getDate()); // 如果【今天】在時間軸範圍內,則強調顯示 if (d.getFullYear() === today.getFullYear() && d.getMonth() === today.getMonth() && d.getDate() === today.getDate()) { dayObj.addClass('today'); } scaleObj = $('<span class="J_Scale scale"></span>'); scaleObj.css('left', left); scaleObj.attr('data-date', d.getFullYear() + '/' + Utils.prefixZero(d.getMonth() + 1) + '/' + Utils.prefixZero(d.getDate())); // 在每月第一天的上方顯示月份資訊 if (d.getDate() === 1) { scaleObj.addClass('first-day'); monthObj = $('<span class="J_Month month"></span>'); monthObj.css('left', left); monthObj.html(d.getFullYear() + '/' + Utils.prefixZero(d.getMonth() + 1)); monthLabel.append(monthObj); } dayLabel.append(dayObj); scaleLabel.append(scaleObj); } tw.html('').append(line); Timeline = { slider: line, monthLabel: monthLabel, dayLabel: dayLabel, dayDiff: dayDiff, scaleLabel: scaleLabel }; return Timeline; } |
第五步,整合服務端資料。經過以上程式碼的處理,我們只需簡單呼叫createTimeline(fromDate, toDate)
就可以在頁面上得到一條靜態的時間軸,看起來像:
而我們在前面的工作中已經對每一個刻度標記了對應日期,現在只需從服務端將哪些日期對於有相應的特殊事件獲取回來並和時間軸上的刻度進行關聯,我們的工作就差不多了。這裡我們就不去模擬服務端的請求了,我們直接構造一份靜態資料來製作演示效果。我們假設服務端返回的資料結構如下:
1 2 3 4 5 6 7 8 9 10 11 12 |
var serverData = { snapshotTimes: [{ date: '2016/06/01', content: 'JD618大促啟動' }, { date: '2016/06/18', content: '京東618大促進行時' }, { date: '2016/09/15', content: '2016年中秋節放假' }] }; |
我們可以使用下面的程式碼,將資料繫結到時間軸刻度上:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
var data = serverData.snapshotTimes, tLen = data.length, tempDate, tlBegin, tlEnd; tlBegin = data[0].date; tempDate = new Date(data[tLen - 1].date); // 時間軸最後預留三個月空時段,保證時間軸的視覺效果美觀 if (tempDate.getMonth() > 8) { tlEnd = (tempDate.getFullYear() + 1) + '/0' + ((tempDate.getMonth() + 3) % 11) + '/' + Utils.prefixZero(tempDate.getDate()); } else { tlEnd = tempDate.getFullYear() + '/' + Utils.prefixZero(tempDate.getMonth() + 4) + '/' + Utils.prefixZero(tempDate.getDate()); } // 根據服務端資料的起始日期,初始化時間軸 TL = createTimeline(tlBegin, tlEnd); // 根據日期,將事件觸發器綁到對應刻度上 $.each(data, function(idx, d){ var sc = TL.scaleLabel.find('.J_Scale[data-date="' + d.date + '"]'), sct = sc.attr('data-date'), scArr = sct.split('/'); sc.addClass('has-snap').html('<a href="#' + sct + '" class="J_SnapshotLink snapshot-link">' + scArr[1] + '月' + scArr[2] + '</a>'); }); |
現在,我們的時間軸看起來更豐滿了:
第六步,繫結事件,實現點選對應標籤選中對應區間段及其他操作。同樣,有了如今的介面及元素,我們就該考慮如何實現點選某個標籤選中該標籤到下一個標籤這個區間了。其實,在我們繫結服務端資料的時候,我們已經做了一些準備,在第五步的程式碼中我們不難發現,我給每一個標籤對應的刻度元素新增了一個has-snap
的標記class。那麼,接下來就簡單多了,選中區間無非就是當前被點選標籤對應的刻度到下一個含有has-snap
標記的刻度之間的距離了。因此,我又設計了一個根據日期字串物件實現選中區間的方法,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
function selectByDate(dateStr) { var ele = TL.scaleLabel.find('.J_Scale[data-date="' + dateStr.replace(/-/g, '/') + '"]'), nextEle = ele.nextAll('.has-snap').eq(0), nLast, nl, nw, today = new Date(), lastDate; if (!ele.length) { return; } nl = parseFloat(ele.css('left')); if (nextEle.length) { // 如果存在下一個有has-snap標記的元素 nLast = nextEle; lastDate = new Date(nLast.attr('data-date')); } else { // 如果已是最後一個有has-snap標記的元素 nLast = TL.scaleLabel.find('.J_Scale').last(); lastDate = new Date(nLast.attr('data-date')); // 如果最後一個刻度日期大於今天,則結束刻度設為今天,否則使用最後刻度 if (lastDate > today) { lastDate = today; nLast = TL.scaleLabel.find('.J_Scale[data-date="' + (today.getFullYear() + '/' + Utils.prefixZero(today.getMonth() + 1) + '/' + Utils.prefixZero(today.getDate())) + '"]'); } } // 這裡是對選中某項後做一些其他業務,與本文關係不太大 if (!nextEle.length && lastDate !== today) { selDays.html(Math.floor((today - new Date(ele.attr('data-date'))) / (24 * 60 * 60 * 1000))); selRange.html(ele.attr('data-date').replace(/\//g, '-') + ' ~ ' + '今天'); nw = parseFloat(nLast.css('left')) - parseFloat(ele.css('left')) + 32; } else { selDays.html(Math.floor((new Date(nLast.attr('data-date')) - new Date(ele.attr('data-date'))) / (24 * 60 * 60 * 1000))); selRange.html(ele.attr('data-date').replace(/\//g, '-') + ' ~ ' + nLast.attr('data-date').replace(/\//g, '-')); nw = parseFloat(nLast.css('left')) - parseFloat(ele.css('left')); } viewFrame.attr('src', '/decorate/getSnapshotInfoByAppAndTime.html?appId=' + appId + '&snapshotTime=' + ele.attr('data-date').replace(/\//g, '-')); TL.scaleLabel.find('.active').removeClass('active'); ele.find('.J_SnapshotLink').addClass('active'); TL.dayDiff.css({left: nl, width: 0}); TL.dayDiff.animate({width: nw}, 300); } |
接著,就該是對每一個標籤繫結事件,讓每個標籤被點選的時候實現選中等效果了。這裡順便將時間軸的拖拽及回彈功能也一併提供了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
tw.delegate('.J_TimeLine', 'mousedown', function(ev){ // 拖拽及回彈 var originX = ev.clientX, moveX = originX, delta = 0, originLeft = parseFloat(TL.slider.css('left')); if (TL.slider.width() < tw.width()) { return true; } TL.slider.data('moved', false); doc.bind('mousemove', function(ev){ moveX = ev.clientX; delta = moveX - originX; if (Math.abs(delta) > 10) { TL.slider.css('left', originLeft + delta + 'px'); } }); doc.bind('mouseup', function(ev){ doc.unbind('mousemove'); doc.unbind('mouseup'); if (Math.abs(delta) <= 10) { TL.slider.data('moved', false); } else { TL.slider.data('moved', true); } var tLeft = parseFloat(TL.slider.css('left')), tWidth = tw.width(); if (tLeft > 0) { TL.slider.animate({'left': 0}, 300, function(){}); } else if (Math.abs(tLeft) > TL.slider.width() - tWidth) { TL.slider.animate({'left': '-' + (TL.slider.width() - tWidth) + 'px'}, 300, function(){}); } }); }).delegate('.J_SnapshotLink', 'click', function(ev){ // 標籤點選選中事件 if (TL.slider.data('moved')) { return false; } var _this = $(this), hrefArr = _this.attr('href').split('#'); selectByDate(hrefArr[1]); ev.preventDefault(); }); |
好了,到這裡我們的時間軸功能就全部完成了。只需要對上面的方法進行包裝,我們就可以得到一個功能比較完善的時間軸外掛了。例項演示:一個乾淨清爽的時間線例項