将乐高®积木引入多设备 Web
Build with Chrome 是一项面向桌面版 Chrome 用户的趣味实验,最初在澳大利亚推出,并于 2014 年重新发布,在全球范围内推出,与《乐高®电影》合作,并新增了对移动设备的支持。在本文中,我们将分享该项目的一些经验教训,尤其是从仅限桌面设备的体验转变为同时支持鼠标和触控输入的多屏幕解决方案。
Build with Chrome 的历史
“通过 Chrome 构建”计划的第一个版本于 2012 年在澳大利亚推出。我们希望以全新的方式展示 Web 的强大功能,并将 Chrome 推广给全新的受众群体。
该网站分为两个主要部分:“构建”模式,供用户使用乐高积木构建作品;“探索”模式,供用户在乐高版 Google 地图上浏览作品。
为了向用户提供最佳乐高积木搭建体验,互动式 3D 至关重要。2012 年,WebGL 仅在桌面浏览器中公开提供,因此 Build 的目标是仅限桌面设备的体验。探索功能使用 Google 地图来显示创作内容,但在放大到足够近时,它会切换到 WebGL 版地图,以 3D 方式显示创作内容,同时仍使用 Google 地图作为底板纹理。我们希望打造一个环境,让各个年龄段的乐高爱好者都能轻松直观地表达自己的创意,并探索彼此的创作。
2013 年,我们决定将“通过 Chrome 构建”扩展到新的 Web 技术。其中包括适用于 Android 的 Chrome 中的 WebGL,这自然会让“使用 Chrome 构建”演变为移动体验。首先,我们先开发了触控原型,然后再对“Builder Tool”的硬件提出疑问,以了解与移动应用相比,我们可能会在浏览器中遇到哪些手势行为和触感响应问题。
自适应前端
我们需要支持同时支持触控和鼠标输入的设备。不过,由于空间限制,在小屏幕触摸屏上使用相同的界面并不是最佳解决方案。
在 Build 中,有很多互动操作:放大和缩小、更改积木颜色,当然还有选择、旋转和放置积木。用户经常会花大量时间使用此工具,因此请务必让用户能够快速访问他们经常使用的所有内容,并且在与该工具互动时感到舒适。
在设计高度互动的触摸应用时,您会发现屏幕很快就会显得狭小,并且用户的手指在互动时往往会遮挡屏幕的大部分区域。在使用构建器时,这一点就很明显了。在进行设计时,您必须考虑实际屏幕尺寸,而不是图形中的像素。因此,请务必尽量减少按钮和控件的数量,以便尽可能将屏幕空间专用于实际内容。
我们的目标是让 Build 在触摸设备上使用起来顺畅自然,不仅仅是在原始桌面版中添加了触摸输入,而是让 Build 真正感觉像是专为触摸设备而设计的。最终,我们设计了两种界面变体,一种适用于大屏幕的桌面设备和平板电脑,另一种适用于屏幕较小的移动设备。请尽可能使用单一实现,并在模式之间实现流畅的转换。在我们的示例中,我们确定这两种模式之间的体验差异非常明显,因此决定使用特定断点。这两个版本有很多共同的功能,我们尝试只使用一个代码实现来完成大多数操作,但这两个版本的界面在某些方面存在不同之处。
我们使用用户代理数据检测移动设备,然后检查视口大小,以确定是否应使用小屏幕移动界面。很难为“大屏幕”选择断点,因为很难获得可靠的实体屏幕尺寸值。幸运的是,在我们的示例中,我们是否在具有大屏幕的触摸设备上显示小屏幕界面并不重要,因为该工具仍然可以正常运行,只是某些按钮可能看起来有点太大。最后,我们将断点设置为 1000 像素;如果您通过宽度超过 1000 像素的窗口(在横向模式下)加载网站,则会看到大屏幕版本。
我们来简单介绍一下这两种屏幕尺寸和体验:
大屏幕,支持鼠标和触控
大屏幕版本会面向所有支持鼠标的桌面计算机以及大屏幕触控设备(例如 Google Nexus 10)提供。此版本在可用的导航控件方面与原始桌面版解决方案相近,但我们添加了触控支持和一些手势。我们会根据窗口大小调整界面,因此当用户调整窗口大小时,系统可能会移除或调整部分界面。我们使用 CSS 媒体查询来实现这一点。
示例:当可用高度小于 730 像素时,探索模式中的缩放滑块控件会隐藏:
@media only screen and (max-height: 730px) {
.zoom-slider {
display: none;
}
}
小屏幕,仅支持触摸
此版本面向移动设备和小屏平板电脑(目标设备为 Nexus 4 和 Nexus 7)提供。此版本需要支持多点触控。
在小屏幕设备上,我们需要为内容提供尽可能多的屏幕空间,因此我们进行了一些调整来最大限度地利用空间,主要通过将不常使用的元素移出视野来实现:
- 构建时,Build 积木选择器会最小化为颜色选择器。
- 我们将缩放和方向控件替换成了多点触控手势。
- Chrome 的全屏功能也有助于获得额外的屏幕空间。
WebGL 性能和支持
新型触控设备的 GPU 非常强大,但仍远不及桌面设备的 GPU,因此我们知道自己在性能方面会遇到一些挑战,尤其是在“探索 3D 模式”下,我们需要同时渲染大量创作内容。
我们希望以富有创意的方式添加几种具有复杂形状和透明度的新型砖块,这些功能通常会对 GPU 造成很大的负担。不过,我们必须向后兼容,并继续支持第一版中的创作,因此无法设置任何新限制,例如大幅减少创作中的积木块总数。
在 Build 的第一个版本中,我们对一次创作中可使用的积木数量设置了上限。游戏中有一个“砖块计量器”,用于指示剩余的砖块数量。在新版中,我们在一些新砖块中加入了对砖块计数器影响更大的元素,因此砖块的总数量上限略有降低。这是在添加新砖块的同时仍能保持良好性能的一种方法。
在“探索 3D 模式”下,系统会同时执行很多操作,例如加载底板纹理、加载创作内容、为创作内容添加动画效果和渲染创作内容等。这对 GPU 和 CPU 都提出了较高的要求,因此我们在 Chrome DevTools 中进行了大量的帧性能分析,以尽可能优化这些部分。在移动设备上,我们决定将画面稍微放大,以便不必同时渲染那么多创作内容。
在某些设备上,我们不得不重新审视并简化一些 WebGL 着色器,但我们总能找到解决问题的方法并继续前进。
支持非 WebGL 设备
我们希望即使访问者的设备不支持 WebGL,网站也能正常使用。有时,您可以使用画布解决方案或 CSS3D 功能以简化的方式表示 3D 内容。很遗憾,我们没有找到一个足够好的解决方案,无法在不使用 WebGL 的情况下复制“构建和探索”3D 功能。
为保持一致,所有平台上的创作内容的视觉风格必须相同。我们本可以尝试使用2.5D 解决方案,但这样一来,创作内容在某些方面看起来会有所不同。我们还必须考虑如何确保使用第一版“通过 Chrome 构建”工具制作的作品在新版网站上看起来和运行起来与在旧版网站上一样顺畅。
非 WebGL 设备仍可使用“探索”2D 模式,但无法构建新创作或以 3D 方式探索。因此,即使用户使用的是支持 WebGL 的设备,也仍然可以大致了解项目的深度,以及他们可以使用此工具创建什么内容。对于不支持 WebGL 的用户,该网站可能没有那么有价值,但至少可以作为预览版,吸引他们参与试用。
有时,根本无法保留 WebGL 解决方案的后备版本。原因有很多,包括性能、视觉风格、开发和维护成本等。不过,如果您决定不实现回退,至少应照顾不支持 WebGL 的访问者,说明他们无法完全访问网站的原因,并说明他们如何使用支持 WebGL 的浏览器解决此问题。
资产管理
2013 年,Google 推出了新版 Google 地图,这是自该应用发布以来最重大的界面变更。因此,我们决定重新设计“通过 Chrome 构建”页面,使其与新版 Google 地图界面相得益彰,并在重新设计过程中考虑了其他因素。新设计相对扁平,采用简洁的纯色和简单的形状。这样一来,我们就可以在许多界面元素上使用纯 CSS,最大限度减少图片的使用。
在“探索”中,我们需要加载大量图片:创作的缩略图、底板的贴图纹理,以及实际的 3D 创作。我们会特别注意,确保在不断加载新图片时不会发生内存泄漏。
3D 创作内容以 PNG 图片的形式打包为自定义文件格式存储。将 3D 创作数据存储为图片后,我们基本上可以直接将数据传递给用于渲染创作的着色器。
得益于此设计,我们可以针对所有平台使用相同的图片尺寸,从而最大限度地减少存储空间和带宽用量。
管理屏幕方向
很容易忘记从纵向模式切换到横向模式或从横向模式切换到纵向模式时,屏幕宽高比会发生多大变化。在针对移动设备进行适配时,您需要从一开始就考虑这一点。
在启用了滚动功能的传统网站上,您可以应用 CSS 规则,以便获得会重新排列内容和菜单的自适应网站。只要您可以使用滚动功能,这便很容易解决。
我们在 Build 中也使用了此方法,但在解决布局方面受到了一些限制,因为我们需要让内容始终可见,同时还能快速访问许多控件和按钮。对于新闻网站等纯内容网站,采用流式布局非常有用,但对于像我们这样的游戏应用,却很难实现。如何找到一个既适用于横屏模式又适用于竖屏模式的布局,同时还能让用户清晰地了解内容并以舒适的方式进行互动,这对我们来说是一项挑战。最后,我们决定仅以横屏模式显示 Build,并告知用户旋转设备。
在两种屏幕方向下,探索任务都变得更容易完成。我们只需根据屏幕方向调整 3D 的缩放级别,即可获得一致的体验。
大多数内容布局由 CSS 控制,但某些与屏幕方向相关的内容需要使用 JavaScript 实现。我们发现,没有适合使用 window.orientation 来识别屏幕方向的跨设备解决方案,因此最终我们只是比较 window.innerWidth 和 window.innerHeight 来识别设备的屏幕方向。
if( window.innerWidth > window.innerHeight ){
//landscape
} else {
//portrait
}
添加触控支持
向 Web 内容添加触摸支持非常简单。基本互动(例如点击事件)在桌面设备和支持触控的设备上的工作原理相同,但对于更高级的互动,您还需要处理触摸事件:touchstart、touchmove 和 touchend。本文介绍了如何使用这些事件的基础知识。Internet Explorer 不支持触摸事件,而是使用指针事件(pointerdown、pointermove、pointerup)。指针事件已提交给 W3C 进行标准化,但目前仅在 Internet Explorer 中实现。
在“探索”3D 模式下,我们希望采用与标准 Google 地图实现相同的导航方式:使用单指在地图上平移,使用双指张合缩放。由于创作内容是 3D 的,因此我们还添加了双指旋转手势。这通常需要使用触摸事件。
最佳实践是避免进行大量计算,例如在事件处理脚本中更新或渲染 3D 内容。而是应将触摸输入存储在变量中,并在 requestAnimationFrame 渲染循环中对输入做出响应。这还可以让您更轻松地同时实现鼠标,只需将相应的鼠标值存储在相同的变量中即可。
首先,初始化一个对象来存储输入,并添加 touchstart 事件监听器。在每个事件处理脚本中,我们都调用 event.preventDefault()。这是为了防止浏览器继续处理轻触事件,因为这可能会导致一些意外行为,例如滚动或缩放整个页面。
var input = {dragStartX:0, dragStartY:0, dragX:0, dragY:0, dragDX:0, dragDY:0, dragging:false};
plateContainer.addEventListener('touchstart', onTouchStart);
function onTouchStart(event) {
event.preventDefault();
if( event.touches.length === 1){
handleDragStart(event.touches[0].clientX , event.touches[0].clientY);
//start listening to all needed touchevents to implement the dragging
document.addEventListener('touchmove', onTouchMove);
document.addEventListener('touchend', onTouchEnd);
document.addEventListener('touchcancel', onTouchEnd);
}
}
function onTouchMove(event) {
event.preventDefault();
if( event.touches.length === 1){
handleDragging(event.touches[0].clientX, event.touches[0].clientY);
}
}
function onTouchEnd(event) {
event.preventDefault();
if( event.touches.length === 0){
handleDragStop();
//remove all eventlisteners but touchstart to minimize number of eventlisteners
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
//also listen to touchcancel event to avoid unexpected behavior when switching tabs and some other situations
document.removeEventListener('touchcancel', onTouchEnd);
}
}
我们不会在事件处理脚本中实际存储输入,而是在单独的处理脚本中存储:handleDragStart、handleDragging 和 handleDragStop。这是因为我们还希望能够从鼠标事件处理程序调用这些方法。请注意,虽然不太可能,但用户可能会同时使用触控和鼠标。我们不会直接处理该支持请求,而是确保不会出现任何问题。
function handleDragStart(x ,y ){
input.dragging = true;
input.dragStartX = input.dragX = x;
input.dragStartY = input.dragY = y;
}
function handleDragging(x ,y ){
if(input.dragging) {
input.dragDX = x - input.dragX;
input.dragDY = y - input.dragY;
input.dragX = x;
input.dragY = y;
}
}
function handleDragStop(){
if(input.dragging) {
input.dragging = false;
input.dragDX = 0;
input.dragDY = 0;
}
}
基于 touchmove 执行动画时,通常还需要存储自上次事件以来的移动增量。例如,在“探索”中移动所有底板时,我们将其用作相机速度的参数,因为您不是拖动底板,而是实际移动相机。
function onAnimationFrame() {
requestAnimationFrame( onAnimationFrame );
//execute animation based on input.dragDX, input.dragDY, input.dragX or input.dragY
/*
/
*/
//because touchmove is only fired when finger is actually moving we need to reset the delta values each frame
input.dragDX=0;
input.dragDY=0;
}
嵌入示例:使用触摸事件拖动对象。实现方式与在“使用 Chrome 构建”中拖动“探索 3D 地图”类似:http://cdpn.io/qDxvo
多点触控手势
有几个框架或库(例如 Hammer 或 QuoJS)可以简化多点触控手势的管理,但如果您想组合使用多种手势并获得完全控制权,有时最好从头开始。
为了管理“张合”和“旋转”手势,我们会存储第二根手指放在屏幕上时两根手指之间的距离和角度:
//variables representing the actual scale/rotation of the object we are affecting
var currentScale = 1;
var currentRotation = 0;
function onTouchStart(event) {
event.preventDefault();
if( event.touches.length === 1){
handleDragStart(event.touches[0].clientX , event.touches[0].clientY);
}else if( event.touches.length === 2 ){
handleGestureStart(event.touches[0].clientX, event.touches[0].clientY, event.touches[1].clientX, event.touches[1].clientY );
}
}
function handleGestureStart(x1, y1, x2, y2){
input.isGesture = true;
//calculate distance and angle between fingers
var dx = x2 - x1;
var dy = y2 - y1;
input.touchStartDistance=Math.sqrt(dx*dx+dy*dy);
input.touchStartAngle=Math.atan2(dy,dx);
//we also store the current scale and rotation of the actual object we are affecting. This is needed to support incremental rotation/scaling. We can't assume that an object is always the same scale when gesture starts.
input.startScale=currentScale;
input.startAngle=currentRotation;
}
然后,在 touchmove 事件中,我们会持续测量这两根手指之间的距离和角度。然后,系统会使用起始距离与当前距离之间的差值来设置比例,并使用起始角度与当前角度之间的差值来设置角度。
function onTouchMove(event) {
event.preventDefault();
if( event.touches.length === 1){
handleDragging(event.touches[0].clientX, event.touches[0].clientY);
}else if( event.touches.length === 2 ){
handleGesture(event.touches[0].clientX, event.touches[0].clientY, event.touches[1].clientX, event.touches[1].clientY );
}
}
function handleGesture(x1, y1, x2, y2){
if(input.isGesture){
//calculate distance and angle between fingers
var dx = x2 - x1;
var dy = y2 - y1;
var touchDistance = Math.sqrt(dx*dx+dy*dy);
var touchAngle = Math.atan2(dy,dx);
//calculate the difference between current touch values and the start values
var scalePixelChange = touchDistance - input.touchStartDistance;
var angleChange = touchAngle - input.touchStartAngle;
//calculate how much this should affect the actual object
currentScale = input.startScale + scalePixelChange*0.01;
currentRotation = input.startAngle+(angleChange*180/Math.PI);
//upper and lower limit of scaling
if(currentScale<0.5) currentScale = 0.5;
if(currentScale>3) currentScale = 3;
}
}
您可以像拖动示例中那样使用每次 touchmove 事件之间的距离变化,但当您希望实现连续移动时,这种方法通常更有用。
function onAnimationFrame() {
requestAnimationFrame( onAnimationFrame );
//execute transform based on currentScale and currentRotation
/*
/
*/
//because touchmove is only fired when finger is actually moving we need to reset the delta values each frame
input.dragDX=0;
input.dragDY=0;
}
如果需要,您还可以启用在执行捏合和旋转手势时拖动对象的功能。在这种情况下,您可以使用两指之间的中心点作为拖动处理脚本的输入。
嵌入示例:在 2D 中旋转和缩放对象。类似于“探索”中的地图的实现方式:http://cdpn.io/izloq
在同一硬件上支持鼠标和触控
目前,有几款笔记本电脑(例如 Chromebook Pixel)同时支持鼠标和触控输入。如果您不小心,这可能会导致一些意外行为。
请务必注意,您不应仅检测触控支持,然后忽略鼠标输入,而应同时支持这两者。
如果您在触摸事件处理脚本中未使用 event.preventDefault()
,系统还会触发一些模拟的鼠标事件,以便大多数未针对触摸进行优化的网站仍能正常运行。例如,对于屏幕上的单次点按,系统可能会按以下顺序快速依序触发这些事件:
- touchstart
- touchmove
- touchend
- 鼠标悬停
- mousemove
- mousedown
- mouseup
- 点击
如果您的互动比较复杂,这些鼠标事件可能会导致一些意外行为,并破坏您的实现。通常最好在触摸事件处理脚本中使用 event.preventDefault()
,并在单独的事件处理脚本中管理鼠标输入。请注意,在轻触事件处理脚本中使用 event.preventDefault()
还会阻止某些默认行为,例如滚动和点击事件。
“在 Build with Chrome 中,我们不希望在用户双击网站时发生缩放,即使这是大多数浏览器的标准操作。因此,我们使用视口元标记告知浏览器在用户双击时不要缩放。这还移除了 300 毫秒的点击延迟,从而提高了网站的响应速度。(点击延迟是为了在启用双击缩放时区分单点按和双点按。)
<meta name="viewport" content="width=device-width,user-scalable=no">
请注意,使用此功能时,您必须确保网站在所有屏幕尺寸下都易于阅读,因为用户将无法放大。
鼠标、触控和键盘输入
在“探索 3D 模式”下,我们希望提供三种地图导航方式:鼠标(拖动)、触控(拖动、双指张合缩放和旋转)和键盘(使用方向键导航)。所有这些导航方法的工作方式略有不同,但我们对它们采用了相同的方法:在事件处理程序中设置变量,并在 requestAnimationFrame 循环中对其执行操作。requestAnimationFrame 循环无需知道使用哪种方法进行导航。
例如,我们可以使用所有三种输入方法设置地图的移动(dragDX 和 dragDY)。下面是键盘实现:
document.addEventListener('keydown', onKeyDown );
document.addEventListener('keyup', onKeyUp );
function onKeyDown( event ) {
input.keyCodes[ "k" + event.keyCode ] = true;
input.shiftKey = event.shiftKey;
}
function onKeyUp( event ) {
input.keyCodes[ "k" + event.keyCode ] = false;
input.shiftKey = event.shiftKey;
}
//this needs to be called every frame before animation is executed
function handleKeyInput(){
if(input.keyCodes.k37){
input.dragDX = -5; //37 arrow left
} else if(input.keyCodes.k39){
input.dragDX = 5; //39 arrow right
}
if(input.keyCodes.k38){
input.dragDY = -5; //38 arrow up
} else if(input.keyCodes.k40){
input.dragDY = 5; //40 arrow down
}
}
function onAnimationFrame() {
requestAnimationFrame( onAnimationFrame );
//because keydown events are not fired every frame we need to process the keyboard state first
handleKeyInput();
//implement animations based on what is stored in input
/*
/
*/
//because touchmove is only fired when finger is actually moving we need to reset the delta values each frame
input.dragDX = 0;
input.dragDY = 0;
}
嵌入式示例:使用鼠标、触控和键盘进行导航:http://cdpn.io/catlf
摘要
我们在调整 Build with Chrome 以支持屏幕尺寸各异的触控设备的过程中,收获了许多经验。该团队在触摸设备上实现这种级别的互动性方面没有太多经验,因此在开发过程中学到了很多。
最大的挑战是如何解决用户体验和设计问题。技术挑战包括管理许多屏幕尺寸、触摸事件和性能问题。
虽然触摸设备上的 WebGL 着色器存在一些问题,但效果几乎超出了预期。设备的功能越来越强大,WebGL 实现也在快速改进。我们认为,在不久的将来,我们将在设备上更广泛地使用 WebGL。
现在,如果您尚未开始,不妨开始构建一些精彩的应用!