搜索关键词:parallax scrolling tutorial

前景移动越快,越远的层移动越慢。这种效果叫做__“视觉卷轴/滚动”(parallax scrolling)__

纯CSS视差特效

效果图:

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】视差效果 by firstcod (@boycgit) on CodePen.

基于背景图片的视差

使用纯粹的CSS实现视差缺乏控制,只有在窗口大小改变的时候才出现,不能保证每个用户都看到。加上Javascript代码,使用场景将拓宽。

添加JS代码之后,当鼠标移到页面左侧或者右侧,就会朝这一方向加速滚动;当鼠标在页面中间的时候,则放慢滚动速度;当鼠标离开页面的时候,则完全停止滚动。工作原理:

  • mousemove发生时,根据鼠标位置计算速度
  • mouseout发生时,将速度置为0
  • 30ms将计算所得的速度值加到x坐标的位置变量xPos,将缩放之后的xPos应用到每一层水平背景图像坐标。从back层到front层,每层缩放比例一次为**123**

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的移动

将所需要的块数量降低到视点钟所用到的块数。比如

640x384像素的视点

显示了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,根据鼠标移动更换背景图位置

逻辑思路:鼠标移动>获得偏移值(scrollXscrollY)> 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拼接出来的三层合成图:
前两层的DOM树

没错#smallclouds#bigclouds#foreground元素内容的DOM结构是一样的(内容当然有区别,不要混淆),称之为tileScroller,DOM下方都含有一个.handle元素,.handle元素下就是各个.tile了(以#smallclouds为代表展开):
tileScroller结构图

打个不恰当的比方,.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的偏移值xoffyoff(就是上面说的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-sizebackground-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轴方向可以用gammabeta计算:

	// 加入传感器
	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仓库中了