【原创】手把手教你打造一款别致的时间线插件

Timeline

首先呢,我要感谢一下和我配合的设计师同学,给了我一份如此漂亮的时间线设计稿。拿到设计稿后,我们首先要做的是去分析整个作品的结构,及设计意图。当然这些工作我们可以提前完成,在设计师设计的时候进行充分沟通,而当我们动手去实现的时候就更为得心应手了。开始之前,我们还是先看看最终的交互效果:

Timeline

第一步,分析控件结构,说多了反而容易引起误导,直接看图吧,直观明了:

Timeline

接下来,我来说说我为什么要这么去划分。简单的说,在我看来分析一个控件的结构,其实就是尝试去发现一些规律性的东西。如上图,在月份栏很明显是在每月第一日的位置上方显示当月标签,因此第一行自然可以独立成一个在特定位置显示月份信息的独立区域。而第二行更明显,将时间线上的每一天从左往右挨个儿显示就行了,很显然这一行上只会出现具体天的数据,所以我也将它独立成一个区域。再往下,我们先看最下面的刻度区域,这里存在一定的迷惑性,由于每月第一天的那条线高度刚好填充满,而两个月的第一天刚好将整条刻度划分成一个一个的小区间,但事实上,我觉得它们其实就是一条横向连通的刻度线,所以应该整个一行是一个整体。最后再来说说灰色、蓝色重叠的那条区域,我们可以把蓝色区域包含在灰色区域之中,也可以分成两个同级的区域重叠在一起。但这里需要注意一点:蓝色区域表明的是一个时间区间,它的水平位置和宽度是随时可变的,所以,考虑到后面定位的方便,未知的需求变化,我更倾向分成两个独立区域重叠起来。

第二步,细化每个区域中的共同点,不同点。月份栏,没有问题,就是在每月第一天的位置显示月份标签,唯一不同的是每月第一天的横坐标(left)。日期栏基本与月份栏情况一致,多一点不同在于实现过程中考虑突出显示【今天】。灰色线很简单,从左到右一拉到底,蓝色线很显然是在选中某个事件标签的时候才会出现,所以期初也不用考虑。最后还是刻度栏相对复杂一点,水平位置上的差异不再多说,每月第一天需要追加高刻度样式,而且为了方便后面与事件标签关联,我们需要在生成刻度的时候,在每个刻度上存上对应的日期。大致就是这样,下面就可以着手编码了。

第三步,搭框架。就好像画素描一样,先勾勒出整个作品的骨架,再慢慢细化。

Timeline

代码很简单,先创建一个时间线全局容器,再往里面写入第一步和第二步中我们分析出来的几个独立区块,最后想整个时间线放入页面中准备好的节点(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)就可以在页面上得到一条静态的时间轴,看起来像:

static-line

而我们在前面的工作中已经对每一个刻度标记了对应日期,现在只需从服务端将哪些日期对于有相应的特殊事件获取回来并和时间轴上的刻度进行关联,我们的工作就差不多了。这里我们就不去模拟服务端的请求了,我们直接构造一份静态数据来制作演示效果。我们假设服务端返回的数据结构如下:

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 data

第六步,绑定事件,实现点击对应标签选中对应区间段及其他操作。同样,有了如今的界面及元素,我们就该考虑如何实现点击某个标签选中该标签到下一个标签这个区间了。其实,在我们绑定服务端数据的时候,我们已经做了一些准备,在第五步的代码中我们不难发现,我给每一个标签对应的刻度元素添加了一个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();
});

好了,到这里我们的时间轴功能就全部完成了。只需要对上面的方法进行包装,我们就可以得到一个功能比较完善的时间轴插件了。实例演示:一个干净清爽的时间线实例