搜索关键词:
parallax scrolling tutorial
前景移动越快,越远的层移动越慢。这种效果叫做__“视觉卷轴/滚动”(parallax scrolling)__
纯CSS视差特效
效果图:
当你手动拖动浏览器的窗口改变其大小,你会发现前景(小草)、中景(山)和背景(白云)的移动速度不一样,小草移动要快一些从而导致了视觉差。
这里的关键是:
- 背景图片按百分比铺设,背景的百分比铺设是非常有用的,见【CSS】响应式sprites
- 图像层使用
position:absolute
叠加
HTML:
<div id = "back" class = "layer"></div>
<div id = "middle" class = "layer"></div>
<div id = "front" class = "layer"></div>
<div style="position:absolute;top:280px;"><strong>通过修改浏览器窗口大小预览视差效果</strong></div>
CSS:
body {
padding:0px;
margin:0px;
}
.layer {
position:absolute;
width:100%;
height:256px;
}
# back {
background: #3BB9FF url(https://lh6.googleusercontent.com/-yztAKpPaHkg/VG3VMO7sIOI/AAAAAAAACIM/fqH9GUMFSCo/s800/back1.png) 20% 0px;
}
# middle{
background: transparent url(https://lh4.googleusercontent.com/-bB8YHlB7k1w/VG3VMNS6b_I/AAAAAAAACIM/g9SMFQnkDrs/s800/back2.png) 30% 0px ;
}
# front{
background: transparent url(https://lh5.googleusercontent.com/-ARbeGP3k9Ug/VG3VMczyFZI/AAAAAAAACIM/OX4PpTqAfJY/s800/back3.png) 40% 0px;
}
基于背景图片的视差
使用纯粹的CSS实现视差缺乏控制,只有在窗口大小改变的时候才出现,不能保证每个用户都看到。加上Javascript代码,使用场景将拓宽。
添加JS代码之后,当鼠标移到页面左侧或者右侧,就会朝这一方向加速滚动;当鼠标在页面中间的时候,则放慢滚动速度;当鼠标离开页面的时候,则完全停止滚动。工作原理:
mousemove
发生时,根据鼠标位置计算速度mouseout
发生时,将速度置为0- 每
30ms
将计算所得的速度值加到x坐标的位置变量xPos
,将缩放之后的xPos应用到每一层水平背景图像坐标。从back层到front层,每层缩放比例一次为**1
、2
和3
**
jQuery:
$(function () {
var speed = 0,
$back = $('#back'), // Initial speed.
$middle = $('#middle'), // Cache layers as jQuery objects.
$front = $('#front'),
xPos = 0, // Initial x position of background images.
$win = $(window); // Cache jQuery reference to window.
// Respond to mouse move events.
$(document).mousemove(function (e) {
var halfWidth = $win.width()/2;
// Calculate speed based on mouse position.
// 0 (center of screen) to 1 at edges.
speed = e.pageX - halfWidth;
speed /= halfWidth;
});
// Kill speed on mouseout
$(document).mouseout(function (e) {
speed = 0;
});
// Every 30ms, update each layer's background position.
// The two front layers use a scaled up x position to
// create the parralax effect.
setInterval(function () {
$back.css({
backgroundPosition: xPos + 'px 0px'
});
$middle.css({
backgroundPosition: (xPos * 2) + 'px 0px'
});
$front.css({
backgroundPosition: (xPos * 3) + 'px 0px'
});
// Update the background position.
xPos += speed;
}, 30);
});
See the Pen 【JS】基于背景图片的视差效果 by firstcod (@boycgit) on CodePen.
CSS和HTML代码和之前的大同小异,就不在这儿陈列了,注意CSS中没有显示声明
background-size
。
基于块的图像视差
上述滚动例子的缺点:
图片平铺在浏览器中,缺少变化;你可以使用大图作为背景,但内容区域越大,背景图片也就越大,基于大图的方法是不切实际的。
借鉴Canvas动画中的Image Tile
思想,我们使用索引来将Sprite上的图片拼成**“地图(map)”**,其底层思想来源于“精简重复从而达到数据压缩”——你丫26个英文字母都能拼成万千世界,拼个图有何难的?
P.S. 图片块在Canvas中的应用可参考 创建简单的sprite动画
两个问题:
- 在DOM中插入大量块级元素(image元素)。一个大的地图需要几千个块,如此庞大的DOM也就意味着性能变差
- 如果每个独立的img元素都从网络上下载,会导致页面载入变慢
解决方案:
- 针对DOM元素数量问题,可以使用**
snapping
**技术 - 针对图片下载问题,使用**
Sprites
**技术,使用div取代img元素
Snapping——tile的移动
将所需要的块数量降低到视点钟所用到的块数。比如
显示了640x384px的视点窗口,背后的网格代表地图中可见部分,每个网格都是64x64px大小,可以计算出视点最多可以容纳11 x 7个块。计算方法:(以高度方向为例)
7 = Math.ceil( 384 / 64 ) + 1
从而不难推演出公式:
num = Math.ceil( axisSize / tileWidth) + 1
我们需要一个方法,使得创建和操作的块数等于视点中最多可显示的块数,而和地图的大小无关。
想象向右滚动地图,77个块将向左移动。如果最左边的块被移动到了视点之外呢?
由于右边没有延伸部分的地图块,视点的右边就没有元素可以显示,因此将显示空白区域,而左边的块已经移出视觉之外。自然而然的想法就是将左边的块“挪到”右边填充空白。(这个方法可以同样应用在其他滚动方向上)
如何计算snap的位置呢?使用当前的地图滚动位置除以块宽度取余数的负数:
snapPos = -(scrollPosition % tileWidth);
如果块宽度是64px,而水平滚动距离每次增加8px(从0开始),那么snap的位置将如下重复:
0,-8,-16,-24,-32,-40,-48,-56,0,-8,-16,-24,-32,-40,-48,-56等
包含所有块的句柄元素的left值或top值将使用得到的snap位置。(snap位置必须分别针对水平坐标轴和垂直坐标轴计算)
如果仅仅是snap所有块到右边,将重复显示地图的一部分,因此会出现跳动效果,所以还需要根据滚动位置改变块的位图。为了选择正确的位图,你必须从地图中提取合适的块索引(地图只是块索引的数组)。假设yPos,xPos,mapWidth都是以块为单位,可以如下计算块索引:
index = map[(yPos*mapWidth)+xPos];
Wrapping —— 背景的移动
无限Wrap一个地图,没有wrapping,当地图在视点的一个方向上滚动,最终会到达地图的边缘(出现空白)。
用了wrapping技术后,在显示右边的边缘块之后,我们将显示左边的边缘块,好比在遍历数组时,从A[n-1]跳到A[0],这样使得滚动无限下去。
地图wrapping技术非常适合无限大的背景特效,比如蓝天白云、璀璨星辰等。
提高速度
虽然使用snapping能够极大减少块的数目,不过一个640x384
像素的视点内3层视差卷轴还是会达到3x77=231
块。如果其中一层只是有少许云朵的天空,我们可以使用128像素的块,这样将块数量从77降低到24。
为保证帧率,滚动时对这些块的操作应尽量降到最低:尽可能预先计算。注意:
- 少进行函数调用,尤其是jQuery
- 仅进行简单的循环和算术
- 将每个块的样式属性的引用存在数组中,方便快速改变诸如背景位置等属性。
- 将每个块索引的背景位置作为字符串存在数组中:'0px 0px'、'0px 64px'等等,你可以将这些字符串一次直接存入背景位置属性,而不是分别更新left、top属性
- 因为你只是在设置视点期间一次性加入可见的块,因此在滚动时浏览器不会进行页面内容回流(reflow)或其他耗费时间的动作。
reflow是指当页面文档流变动时,重新计算所有的DOM元素的位置和大小。绝对定位和固定定位的元素是在文档流之外的,操作他们不会引起回流。
页面代码
See the Pen 基于块的视差卷轴 by firstcod (@boycgit) on CodePen.
两个重点:
① 静态部分:创建开始图片(既场景的初始化),这和平常的Sprite
布局没啥区别
② 动态部分:snapping,根据鼠标移动更换背景图位置
逻辑思路:鼠标移动>获得偏移值(scrollX
、scrollY
)> A.获得handle偏移值xoff
,yoff
;B.获得Snapping偏移值sx、sy(以块为单位,snapping是整个背景跳跃) > 获取索引值tileIndex
(依据map[mapRow + sx]
,偏移了5个tile
,相当于就是那偏移第五个tile的背景做自己的背景,整体都替换)
背后的思想,动画就是多个静态图片的变化—>所以要先掌握静态图片的绘制(给定scrollX、scrollY的情形下)
基本结构
首先是简单的HTML:(只有一个viewports对象)
<body>
<!-- This div will contain the three viewports -->
<div id="viewports"></div>
</body>
它的CSS样式也很简单:(绝对定位)
body {
padding:0px;
margin:0px;
}
#viewports {
position:absolute;
border:4px solid #000;
background-color:#3090C7;
width:640px;
height:384px;
}
使用Tiled创建的地图
使用的png基本图形是:
使用Tiled软件拼接出三层合成图:
最后导出成CSV格式保存即可。
我的这个Tiled版本
0.9.1
可以地图按JSON格式导出。
获得地图信息
如果使用Tile导出的xml文件,使用jQuery
可以像解析DOM一样很方便地解析xml文件;
不过现在Tile软件还支持导出成JSON格式,建议使用使用JSON格式;无论怎么样,最终你需要获得下面几个参数:
var params = {
tileWidth: +$mapInfo.attr('tilewidth'),// tile的宽度、高度,以px为单位
tileHeight:+$mapInfo.attr('tileheight'),
wrapX:true,
wrapY:true,
mapWidth:+$mapInfo.attr('width'), // 以tile为单位,计算map的宽度
mapHeight:+$mapInfo.attr('height'),// 以tile为单位,计算map的高度
image:$imageInfo.attr('source'),
imageWidth: +$imageInfo.attr('width'), // 实际图片的宽度
imageHeight: +$imageInfo.attr('height') // 实际图片的高度
},
为保证都是数值相加,获取属性值时需要特意在前面添加
+
号:
获得的参数,用于后续创建tileScroller对象;
tileScroller对象
tileScroller
专门是根据params参数创建场景内容的。
为了方便讲解,先看一下程序运行后的DOM结构(因为DOM节点都是动态创建出来的);
前两层的DOM树如下,对应上面我们用Tiled拼接出来的三层合成图:
没错#smallclouds
、#bigclouds
、#foreground
元素内容的DOM结构是一样的(内容当然有区别,不要混淆),称之为tileScroller
,DOM下方都含有一个.handle
元素,.handle
元素下就是各个.tile
了(以#smallclouds
为代表展开):
打个不恰当的比方,
.handle
相当于显示器物理外框,.tile
相当于液晶,这两者都是物理客观存在的实体;显示器能够显示多彩多样的内容,是通过电流改变液晶内部元素位置产生的;而这里我们将使用JS操控.tile
的背景图片就可以达到此种效果。只是这里的.tile
也会进行“小幅度”(可控)的整体移动
what?你问什么是液晶显示器?.....@#$#!#%*&^....
tile数组
tileScroller
这个函数根据params
创建div.handle
元素以及里面的div.tile
各种子元素。
在创建这边多子元素的时候,有个最佳实践原则:
把DOM拼接成字符串之后一次性放到div.handle元素中。
将场景中的77个tile
的每个样式都存储在一个数组中,样式都是按引用方式存储的:
for (i = 0; i < tilesAcross * tilesDown; i++) {
tiles.push($('.tile', $viewport)[i].style);
}
这样以后在更新每个tile属性的时候会方便很多。
背景偏移数组
tileBackPos
中存储了tile背景图片对应的偏移(x,y)值:
tileBackPos.push('0px 0px'); // Tile zero - special 'hidden' tile.
for (top = 0; top \< params.imageHeight; top += params.tileHeight) {
for (left = 0; left < params.imageWidth; left += params.tileWidth) {
tileBackPos.push(-left + 'px ' + -top + 'px');
}
}
注意
tileBackPos
针对的是最原始的背景图片而不是地图!
使用sprite图制作图标的同学都知道,显示某个图标,只需要获得该图标在sprite图上的偏移位置即可。
函数draw
铺设完“地砖”之后,最为重要的是给每个地砖绘制准确的背景图片索引值。这都在方法that.draw
中计算的:
传入当前用户感觉应当偏离的坐标(scrollX,scrollY)值,计算出handle的偏移值xoff
和yoff
(就是上面说的snap值):
var xoff = -(scrollX % params.tileWidth),
yoff = -(scrollY % params.tileHeight);
//> 0 alternative to math.floor. Number changes from a float to an int.
handle.style.left = (xoff> 0) + 'px';
handle.style.top = (yoff> 0) + 'px';
同时计算出目前应当滚过多少片“地砖”:
// Convert pixel scroll positions to tile units.
scrollX = (scrollX / params.tileWidth)> 0;
scrollY = (scrollY / params.tileHeight)> 0;
params.map
存储者地图索引值,把二维图片存成一维的数组。
之后开始绘制逻辑,一行一行的绘制,所以先从列开始循环,第一行(countDown=1)....第二行(countDown=2)...第三行(countDown=3)....
for (countDown = tilesDown; countDown; countDown--) {
...
// Draw a row.
sx = scrollX;
mapRow = sy * mapWidth;
for (countAcross = tilesAcross; countAcross; countAcross--) {
...
// 获取背景图片索引.
tileIndex = map[mapRow + sx];
sx++;
// 如果索引值不为零(表示有对应的图片块),则“绘制”该模块,
if (tileIndex) {
tiles[tileInView].visibility = 'visible';
// 背景图片偏移值
tiles[tileInView++].backgroundPosition = tileBackPos[tileIndex];
}
// 否则就隐藏该板块
else {
tiles[tileInView++].visibility = 'hidden';
}
}
sy++;
}
};
其中有两行关键代码,先是获取块索引tileIndex:
// Get tile index no.
tileIndex = map[mapRow + sx]; // 其中 mapRow = sy * mapWidth;
这里的mapWidth不是按像素为单位的,是以“块”为单位的;经过map之后,tileIndex就是背景图片所对应的块索引值;有了块索引之后,就开始设置背景图片:
// If tile index non zero, then 'draw' it,
if (tileIndex) {
tiles[tileInView].visibility = 'visible';
tiles[tileInView++].backgroundPosition = tileBackPos[tileIndex];
}
注意有些tileIndex
的值是为0的,这表示没有背景,所以直接隐藏该块即可:
// 否则就隐藏该板块
else {
tiles[tileInView++].visibility = 'hidden';
}
进行视差
最后联合调用上面定义的两个函数即可,它初始化了所有的场景,并在鼠标移动的时候控制不同的层以不同的速度滚动,最后以30ms的setInterval调用完成这个效果:
// Call the loadMap function. The callback passed
// is a function that scrolls each viewport according
// to mouse movement.
loadMap("map1.tmx", $('#viewports'), function (tileScrollers) {
var ts1 = tileScrollers[0], // Get the three tileScrollers.
ts2 = tileScrollers[1],
ts3 = tileScrollers[2],
scrollX = 0, // Current scroll position.
scrollY = 0,
xSpeed = 0, // Current scroll speed.
ySpeed = 0,
// Width and height of viewports.
viewWidth = $('#viewports').innerWidth(),
viewHeight = $('#viewports').innerHeight();
// As mouse is moved around viewports,
// calculate a speed to scroll by.
$('#viewports').mousemove(function (ev) {
xSpeed = ev.clientX - (viewWidth / 2);
xSpeed /= (viewWidth / 2);
xSpeed *= 10;
ySpeed = ev.clientY - (viewHeight / 2);
ySpeed /= (viewHeight / 2);
ySpeed *= 10;
});
// Every 30 milliseconds, update the scroll positions
// for the three tileScrollers.
setInterval(function () {
// Each tileScroller is given a different scroll positions
// for a parralax effect.
ts1.draw(scrollX / 3, scrollY / 3);
ts2.draw(scrollX / 2, scrollY / 2);
ts3.draw(scrollX, scrollY);
// Update scroll position.
scrollX += xSpeed;
scrollY += ySpeed;
// Stop scrolling at edges of map.
// This code can be removed to test the wrapping.
if (scrollX < 0) {
scrollX = 0;
}
if (scrollX > ts3.mapWidthPixels - viewWidth) {
scrollX = ts3.mapWidthPixels - viewWidth;
}
if (scrollY < 0) {
scrollY = 0;
}
if (scrollY > ts3.mapHeightPixels - viewHeight) {
scrollY = ts3.mapHeightPixels - viewHeight;
}
}, 30);
});
通过 jQuery,很容易处理元素和浏览器窗口的尺寸:http://www.w3school.com.cn/jquery/jquery_dimensions.asp
如果,我们注释掉最后的几行代码,我们就可以wrapping,无限滚动了!
if (scrollX < 0) {
scrollX = 0;
}
if (scrollX > ts3.mapWidthPixels - viewWidth) {
scrollX = ts3.mapWidthPixels - viewWidth;
}
if (scrollY < 0) {
scrollY = 0;
}
if (scrollY > ts3.mapHeightPixels - viewHeight) {
scrollY = ts3.mapHeightPixels - viewHeight;
}
性能报告
为了检测动画的性能,我们使用chrome的Timeline
工具进行监测:
发现帧频都是到30fps
上下,怎么回事?
因为使用了setTimeout函数,其中的设置的ms数恰好是30ms
——好凑巧...
我们使用rAF来改善一下:
var step = function (timestamp) {
// Each tileScroller is given a different scroll positions
// for a parralax effect.
ts1.draw(scrollX / 3, scrollY / 3);
ts2.draw(scrollX / 2, scrollY / 2);
ts3.draw(scrollX, scrollY);
// Update scroll position.
scrollX += xSpeed;
scrollY += ySpeed;
// Stop scrolling at edges of map.
// This code can be removed to test the wrapping.
if (scrollX < 0) {
scrollX = 0;
}
if (scrollX > ts3.mapWidthPixels - viewWidth) {
scrollX = ts3.mapWidthPixels - viewWidth;
}
if (scrollY < 0) {
scrollY = 0;
}
if (scrollY > ts3.mapHeightPixels - viewHeight) {
scrollY = ts3.mapHeightPixels - viewHeight;
}
window.requestAnimationFrame(step);
};
window.requestAnimationFrame(step);
是不是流畅到想哭的冲动啊?!
手机上能用么?
结合上次写的自适应Sprite功能,非常方便地就移植到移动端了!
没错,使用百分比设置background-size
和background-position
即可
其实上面的代码直接放在客户端是可以直接使用的,只是针对不同宽度的手机,它所用的块数的数量是不同的。
我们的目标是,要求在不同宽度手机上,使用的块数也是一样的————一个直观的感觉是等比缩放。
最终效果:
See the Pen parallax_resp by firstcod (@boycgit) on CodePen.
改造tileScroller
获取准确的tile数量
首先获取响应式参数:
var ratio = 1 / 46.875;
var baseWidth = + $(document.documentElement).css("font-size").slice(0,-2);
这两个参数用于tileScroller
函数,用于计算准确的tile数量:
Parallax.prototype.tileScroller = function (params) {
// ...other code
// Snapping....
// 根据$viewport大小,计算所需的tile数量
//计算标准下的图像高宽
var standWidth = ($viewport.width()/baseWidth/ratio);
var standHeight = ($viewport.height()/baseWidth/ratio);
tilesAcross = Math.ceil((standWidth + params.tileWidth) / params.tileWidth),
tilesDown = Math.ceil((standHeight + params.tileHeight) / params.tileHeight);
// ...other code
}
修改tile数据
初始化tile
时候,将px
单位替换成rem
,注意这里多了background-size
的声明:
// ... ohter code
for (top = 0; top < tilesDown; top++) {
for (left = 0; left < tilesAcross; left++) {
html += '<div class="tile" style="position:absolute;' +
'background-image:url(\'' + params.image + '\');' +
'width:' + params.tileWidth*ratio + 'rem;' +
'height:' + params.tileHeight*ratio + 'rem;' +
'background-position: 0% 0%;' +
'background-size: '+ (100 * params.imageWidth / params.tileWidth) +'% auto;' +
'left:' + (left * params.tileWidth * ratio) + 'rem;' +
'top:' + (top * params.tileHeight * ratio) + 'rem;' + '"/>';
}
}
// ... ohter code
背景位置百分比
之后最为关键的,就是初始化Sprite图片时,需要使用百分比:
// ... other code
tileBackPos.push('0% 0%'); // Tile zero - special 'hidden' tile.
for (top = 0; top < params.imageHeight; top += params.tileHeight) {
for (left = 0; left < params.imageWidth; left += params.tileWidth) {
tileBackPos.push((-left/(params.tileWidth - params.imageWidth)*100) + '% ' + (-top/(params.tileHeight - params.imageHeight)*100) + '%');
}
}
// ... other code
P.S 百分比化的原理参见 “响应式Sprite图片”章节
改造draw函数
这个draw函数也是属于tileScroller
的,只用适当地替换单位即可:
handle.style.left = (xoff >> 0)*ratio + 'rem';
handle.style.top = (yoff >> 0)*ratio + 'rem';
好了就这么简单。具体代码参见:http://codepen.io/boycgit/pen/XJBdMV
加上传感器
利用Z轴作为法向量,因此x、y轴方向可以用gamma
和beta
计算:
// 加入传感器
if (window.DeviceOrientationEvent){
$(window).on('deviceorientation',function(e){
// 速度分解,速度矢量方向是z轴,进行x方向和y方向的速度分解
xSpeed = e.originalEvent.gamma / 5;
ySpeed = e.originalEvent.beta / 5;
});
}
将这段代码添加到主函数中,这样方便动态修改速度值:
// 第二个重点:动态部分
// Call the loadMap function. The callback passed
// is a function that scrolls each viewport according
// to mouse movement.
loadMap(mapJSON, $('#viewports'), function (tileScrollers) {
// ......
// 加入传感器
if (window.DeviceOrientationEvent){
$(window).on('deviceorientation',function(e){
// console.log("44,log",e.originalEvent.gamma);
// 速度分解,速度矢量方向是z轴,进行x方向和y方向的速度分解
xSpeed = e.originalEvent.gamma / 5;
ySpeed = e.originalEvent.beta / 5;
});
}
// Every 30 milliseconds, update the scroll positions
// for the three tileScrollers.
setInterval(function () {
// Each tileScroller is given a different scroll positions
// for a parralax effect.
ts1.draw(scrollX / 3, scrollY / 3);
ts2.draw(scrollX / 2, scrollY / 2);
ts3.draw(scrollX, scrollY);
// Update scroll position.
scrollX += xSpeed;
scrollY += ySpeed;
// ....
}, 30);
});
好了,现在在手机端打开,摇摆你的手机吧~~
P.S. 本地调试代码已经托管在demos仓库中了