Caso de éxito: Efecto de cambio de página de 20thingsilearned.com

Hakim El Hattab
Hakim El Hattab

Introducción

En 2010, F-i.com y el equipo de Google Chrome colaboraron en una app web educativa basada en HTML5 llamada 20 Things I Learned about Browsers and the Web (www.20thingsilearned.com). Una de las ideas clave detrás de este proyecto fue que se presentaría mejor en el contexto de un libro. Dado que el contenido del libro se centra en gran medida en las tecnologías de la Web abierta, consideramos que era importante mantenernos fieles a ese concepto y hacer que el contenedor en sí mismo fuera un ejemplo de lo que estas tecnologías nos permiten lograr hoy en día.

Portada del libro y página principal de "20 Things I Learned About Browsers and the Web"
Portada del libro y página principal de "20 Things I Learned About Browsers and the Web" (www.20thingsilearned.com)

Decidimos que la mejor manera de lograr la sensación de un libro del mundo real es simular las partes buenas de la experiencia de lectura analógica y, al mismo tiempo, aprovechar los beneficios del ámbito digital en áreas como la navegación. Se dedicó mucho esfuerzo al tratamiento gráfico e interactivo del flujo de lectura, en especial, a cómo se pasan las páginas de los libros.

Comenzar

En este instructivo, se explica el proceso para crear tu propio efecto de cambio de página con el elemento canvas y mucho JavaScript. Parte del código rudimentario, como las declaraciones de variables y la suscripción al objeto de escucha de eventos, se omitió de los fragmentos de este artículo, por lo que te recomendamos que consultes el ejemplo de trabajo.

Antes de comenzar, es recomendable que veas la demostración para que sepas qué queremos crear.

Marca

Siempre es importante recordar que los motores de búsqueda no pueden indexar lo que dibujamos en el lienzo, ni los visitantes pueden seleccionarlo ni se puede encontrar con las búsquedas en el navegador. Por ese motivo, el contenido con el que trabajaremos se coloca directamente en el DOM y, luego, se manipula con JavaScript si está disponible. El lenguaje de marcado requerido para esto es mínimo:

<div id='book'>
<canvas id='pageflip-canvas'></canvas>
<div id='pages'>
<section>
    <div> <!-- Any type of contents here --> </div>
</section>
<!-- More <section>s here -->
</div>
</div>

Tenemos un elemento contenedor principal para el libro, que a su vez contiene las diferentes páginas del libro y el elemento canvas en el que dibujaremos las páginas que se voltean. Dentro del elemento section, hay un wrapper div para el contenido. Lo necesitamos para poder cambiar el ancho de la página sin afectar el diseño de su contenido. El div tiene un ancho fijo y el section está configurado para ocultar su desbordamiento, lo que hace que el ancho del section actúe como una máscara horizontal para el div.

Abrir libro
Se agrega una imagen de fondo que contiene la textura del papel y la sobrecubierta marrón al elemento del libro.

Lógica

El código necesario para activar el cambio de página no es muy complejo, pero sí bastante extenso, ya que incluye muchos gráficos generados de forma procedimental. Comencemos por analizar la descripción de los valores constantes que usaremos en todo el código.

var BOOK_WIDTH = 830;
var BOOK_HEIGHT = 260;
var PAGE_WIDTH = 400;
var PAGE_HEIGHT = 250;
var PAGE_Y = ( BOOK_HEIGHT - PAGE_HEIGHT ) / 2;
var CANVAS_PADDING = 60;

El CANVAS_PADDING se agrega alrededor del lienzo para que el papel se extienda fuera del libro cuando se lo da vuelta. Ten en cuenta que algunas de las constantes definidas aquí también se establecen en CSS, por lo que, si deseas cambiar el tamaño del libro, también deberás actualizar los valores allí.

Son constantes.
Los valores constantes que se usan en todo el código para hacer un seguimiento de la interacción y dibujar el cambio de página.

A continuación, debemos definir un objeto de cambio de página para cada página. Estos se actualizarán constantemente a medida que interactuemos con el libro para reflejar el estado actual del cambio de página.

// Create a reference to the book container element
var book = document.getElementById( 'book' );

// Grab a list of all section elements (pages) within the book
var pages = book.getElementsByTagName( 'section' );

for( var i = 0, len = pages.length; i < len; i++ ) {
pages[i].style.zIndex = len - i;

flips.push( {
progress: 1,
target: 1,
page: pages[i],
dragging: false
});
}

Primero, debemos asegurarnos de que las páginas estén dispuestas correctamente organizando los índices Z de los elementos de la sección de modo que la primera página esté en la parte superior y la última en la parte inferior. Las propiedades más importantes de los objetos de inversión son los valores progress y target. Se usan para determinar qué tan doblada debería estar la página actualmente. -1 significa que está doblada por completo hacia la izquierda, 0 significa el centro exacto del libro y +1 significa el borde más a la derecha del libro.

Progreso
Los valores de progreso y objetivo de los giros se usan para determinar dónde se debe dibujar la página plegable en una escala de -1 a +1.

Ahora que tenemos un objeto de cambio definido para cada página, debemos comenzar a capturar y usar la entrada del usuario para actualizar el estado del cambio.

function mouseMoveHandler( event ) {
// Offset mouse position so that the top of the book spine is 0,0
mouse.x = event.clientX - book.offsetLeft - ( BOOK_WIDTH / 2 );
mouse.y = event.clientY - book.offsetTop;
}

function mouseDownHandler( event ) {
// Make sure the mouse pointer is inside of the book
if (Math.abs(mouse.x) < PAGE_WIDTH) {
if (mouse.x < 0 &amp;&amp; page - 1 >= 0) {
    // We are on the left side, drag the previous page
    flips[page - 1].dragging = true;
}
else if (mouse.x > 0 &amp;&amp; page + 1 < flips.length) {
    // We are on the right side, drag the current page
    flips[page].dragging = true;
}
}

// Prevents the text selection
event.preventDefault();
}

function mouseUpHandler( event ) {
for( var i = 0; i < flips.length; i++ ) {
// If this flip was being dragged, animate to its destination
if( flips[i].dragging ) {
    // Figure out which page we should navigate to
    if( mouse.x < 0 ) {
    flips[i].target = -1;
    page = Math.min( page + 1, flips.length );
    }
    else {
    flips[i].target = 1;
    page = Math.max( page - 1, 0 );
    }
}

flips[i].dragging = false;
}
}

La función mouseMoveHandler actualiza el objeto mouse para que siempre trabajemos con la ubicación del cursor más reciente.

En mouseDownHandler, primero verificamos si se presionó el mouse en la página izquierda o derecha para saber en qué dirección queremos comenzar a voltear la página. También nos aseguramos de que exista otra página en esa dirección, ya que podríamos estar en la primera o la última. Si hay una opción de cambio válida disponible después de estas verificaciones, establecemos la marca dragging del objeto de cambio correspondiente en true.

Una vez que llegamos al mouseUpHandler, revisamos todos los flips y verificamos si alguno de ellos se marcó como dragging y ahora se debe lanzar. Cuando se suelta un flip, establecemos su valor objetivo para que coincida con el lado hacia el que debe voltearse según la posición actual del mouse. El número de página también se actualiza para reflejar esta navegación.

Renderización

Ahora que la mayor parte de nuestra lógica está en su lugar, veremos cómo renderizar el papel plegado en el elemento canvas. La mayor parte de esto sucede dentro de la función render(), a la que se llama 60 veces por segundo para actualizar y dibujar el estado actual de todos los giros activos.

function render() {
// Reset all pixels in the canvas
context.clearRect( 0, 0, canvas.width, canvas.height );

for( var i = 0, len = flips.length; i < len; i++ ) {
var flip = flips[i];

if( flip.dragging ) {
    flip.target = Math.max( Math.min( mouse.x / PAGE_WIDTH, 1 ), -1 );
}

// Ease progress towards the target value
flip.progress += ( flip.target - flip.progress ) * 0.2;

// If the flip is being dragged or is somewhere in the middle
// of the book, render it
if( flip.dragging || Math.abs( flip.progress ) < 0.997 ) {
    drawFlip( flip );
}

}
}

Antes de comenzar a renderizar el flips, restablecemos el lienzo con el método clearRect(x,y,w,h). Borrar todo el lienzo tiene un costo de rendimiento muy alto, y sería mucho más eficiente borrar solo las regiones en las que estamos dibujando. Para mantener este instructivo sobre el tema, lo dejaremos en borrar todo el lienzo.

Si se arrastra un giro, actualizamos su valor de target para que coincida con la posición del mouse, pero en una escala de -1 a 1 en lugar de píxeles reales. También incrementamos progress en una fracción de la distancia a target. Esto generará una progresión fluida y animada del giro, ya que se actualiza en cada fotograma.

Dado que revisamos todos los flips en cada fotograma, debemos asegurarnos de volver a dibujar solo los que están activos. Si un pliegue no está muy cerca del borde del libro (a menos del 0.3% de BOOK_WIDTH) o si se marca como dragging, se considera activo.

Ahora que toda la lógica está en su lugar, debemos dibujar la representación gráfica de un giro según su estado actual. Es hora de analizar la primera parte de la función drawFlip(flip).

// Determines the strength of the fold/bend on a 0-1 range
var strength = 1 - Math.abs( flip.progress );

// Width of the folded paper
var foldWidth = ( PAGE_WIDTH * 0.5 ) * ( 1 - flip.progress );

// X position of the folded paper
var foldX = PAGE_WIDTH * flip.progress + foldWidth;

// How far outside of the book the paper is bent due to perspective
var verticalOutdent = 20 * strength;

// The maximum widths of the three shadows used
var paperShadowWidth = (PAGE_WIDTH*0.5) * Math.max(Math.min(1 - flip.progress, 0.5), 0);
var rightShadowWidth = (PAGE_WIDTH*0.5) * Math.max(Math.min(strength, 0.5), 0);
var leftShadowWidth = (PAGE_WIDTH*0.5) * Math.max(Math.min(strength, 0.5), 0);

// Mask the page by setting its width to match the foldX
flip.page.style.width = Math.max(foldX, 0) + 'px';

En esta sección del código, se comienza por calcular una cantidad de variables visuales que necesitamos para dibujar el pliegue de manera realista. El valor progress del giro que dibujamos juega un papel importante aquí, ya que es donde queremos que aparezca el pliegue de la página. Para agregar profundidad al efecto de cambio de página, hacemos que el papel se extienda fuera de los bordes superior e inferior del libro. Este efecto alcanza su punto máximo cuando el cambio está cerca del lomo del libro.

Girar
Así se ve el pliegue de la página cuando se gira o se arrastra.

Ahora que todos los valores están preparados, solo queda dibujar el papel.

context.save();
context.translate( CANVAS_PADDING + ( BOOK_WIDTH / 2 ), PAGE_Y + CANVAS_PADDING );

// Draw a sharp shadow on the left side of the page
context.strokeStyle = `rgba(0,0,0,`+(0.05 * strength)+`)`;
context.lineWidth = 30 * strength;
context.beginPath();
context.moveTo(foldX - foldWidth, -verticalOutdent * 0.5);
context.lineTo(foldX - foldWidth, PAGE_HEIGHT + (verticalOutdent * 0.5));
context.stroke();

// Right side drop shadow
var rightShadowGradient = context.createLinearGradient(foldX, 0,
            foldX + rightShadowWidth, 0);
rightShadowGradient.addColorStop(0, `rgba(0,0,0,`+(strength*0.2)+`)`);
rightShadowGradient.addColorStop(0.8, `rgba(0,0,0,0.0)`);

context.fillStyle = rightShadowGradient;
context.beginPath();
context.moveTo(foldX, 0);
context.lineTo(foldX + rightShadowWidth, 0);
context.lineTo(foldX + rightShadowWidth, PAGE_HEIGHT);
context.lineTo(foldX, PAGE_HEIGHT);
context.fill();

// Left side drop shadow
var leftShadowGradient = context.createLinearGradient(
foldX - foldWidth - leftShadowWidth, 0, foldX - foldWidth, 0);
leftShadowGradient.addColorStop(0, `rgba(0,0,0,0.0)`);
leftShadowGradient.addColorStop(1, `rgba(0,0,0,`+(strength*0.15)+`)`);

context.fillStyle = leftShadowGradient;
context.beginPath();
context.moveTo(foldX - foldWidth - leftShadowWidth, 0);
context.lineTo(foldX - foldWidth, 0);
context.lineTo(foldX - foldWidth, PAGE_HEIGHT);
context.lineTo(foldX - foldWidth - leftShadowWidth, PAGE_HEIGHT);
context.fill();

// Gradient applied to the folded paper (highlights &amp; shadows)
var foldGradient = context.createLinearGradient(
foldX - paperShadowWidth, 0, foldX, 0);
foldGradient.addColorStop(0.35, `#fafafa`);
foldGradient.addColorStop(0.73, `#eeeeee`);
foldGradient.addColorStop(0.9, `#fafafa`);
foldGradient.addColorStop(1.0, `#e2e2e2`);

context.fillStyle = foldGradient;
context.strokeStyle = `rgba(0,0,0,0.06)`;
context.lineWidth = 0.5;

// Draw the folded piece of paper
context.beginPath();
context.moveTo(foldX, 0);
context.lineTo(foldX, PAGE_HEIGHT);
context.quadraticCurveTo(foldX, PAGE_HEIGHT + (verticalOutdent * 2),
                        foldX - foldWidth, PAGE_HEIGHT + verticalOutdent);
context.lineTo(foldX - foldWidth, -verticalOutdent);
context.quadraticCurveTo(foldX, -verticalOutdent * 2, foldX, 0);

context.fill();
context.stroke();

context.restore();

El método translate(x,y) de la API de Canvas se usa para desplazar el sistema de coordenadas de modo que podamos dibujar el cambio de página con la parte superior del lomo como la posición 0,0. Ten en cuenta que también debemos save() la matriz de transformación actual del lienzo y restore() a ella cuando terminemos de dibujar.

Traducir
Este es el punto desde el que dibujamos el cambio de página. El punto 0,0 original se encuentra en la parte superior izquierda de la imagen, pero, al cambiarlo con translate(x,y), simplificamos la lógica de dibujo.

El foldGradient es lo que usaremos para rellenar la forma del papel doblado y darle reflejos y sombras realistas. También agregamos una línea muy delgada alrededor del dibujo en papel para que no desaparezca cuando se coloque sobre fondos claros.

Todo lo que resta ahora es dibujar la forma del papel doblado con las propiedades que definimos anteriormente. Los lados izquierdo y derecho del papel se dibujan como líneas rectas, y los lados superior e inferior se curvan para transmitir la sensación de un papel doblado. La resistencia de este doblez de papel se determina con el valor de verticalOutdent.

Eso es todo. Ahora tienes una navegación de cambio de página completamente funcional.

Demostración de cambio de página

El efecto de cambio de página se trata de comunicar la sensación interactiva correcta, por lo que mirar imágenes de él no le hace justicia.

Próximos pasos

Hard-flip
El cambio de página suave de este instructivo se vuelve aún más potente cuando se combina con otras funciones similares a las de un libro, como una tapa dura interactiva.

Este es solo un ejemplo de lo que se puede lograr con las funciones de HTML5, como el elemento canvas. Te recomiendo que veas la experiencia de libro más refinada de la que se extrajo esta técnica en www.20thingsilearned.com. Allí verás cómo se pueden aplicar los cambios de página en una aplicación real y lo potente que se vuelve cuando se combina con otras funciones de HTML5.

Referencias

  • Especificación de la API de Canvas