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 sería mejor presentarlo en el contexto de un libro. Dado que el contenido del libro se enfoca mucho en las tecnologías de la Web abierta, consideramos que era importante ser fieles a eso y hacer que el contenedor en sí sea un ejemplo de lo que estas tecnologías nos permiten lograr hoy.

Portada y página principal del libro "20 cosas que aprendí sobre navegadores y la Web"
Portada y página principal del libro “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 reino digital en áreas como la navegación. Se dedicó mucho esfuerzo al tratamiento gráfico e interactivo del flujo de lectura, en especial a la forma en que las páginas de los libros pasan de una a otra.

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, así que recuerda consultar el ejemplo en funcionamiento.

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

Marca

Siempre es importante recordar que lo que dibujamos en el lienzo no se puede indexar en los motores de búsqueda, seleccionar un visitante ni encontrar en las búsquedas en el navegador. Por esa razón, el contenido con el que trabajaremos se coloca directamente en el DOM y, luego, JavaScript lo manipula si está disponible. El 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 de contenedor principal para el libro, que a su vez contiene las diferentes páginas de nuestro 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. Necesitamos esto 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.

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

Lógica

El código necesario para potenciar el cambio de página no es muy complejo, pero es 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 voltee. Ten en cuenta que algunas de las constantes definidas aquí también se establecen en CSS, por lo que, si quieres cambiar el tamaño del libro, también deberás actualizar los valores allí.

Constantes
Son 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 una de ellas. 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 en capas correctamente. Para ello, debemos organizar 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 cambio son los valores progress y target. Se usan para determinar hasta dónde se debe doblar la página en la actualidad. -1 significa todo a la izquierda, 0 significa el centro exacto del libro y +1 significa el borde más a la derecha del libro.

Progreso.
El progreso y los valores 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 en la ubicación más reciente del cursor.

En mouseDownHandler, comenzamos por verificar si se presionó el mouse en la página izquierda o derecha para saber en qué dirección queremos comenzar a girar. También nos aseguramos de que exista otra página en esa dirección, ya que es posible que estemos en la primera o la última página. Si hay una opción de cambio válida disponible después de estas verificaciones, set la marca dragging del objeto de cambio correspondiente a true.

Una vez que llegamos a mouseUpHandler, revisamos todos los flips y comprobamos si alguno de ellos se marcó como dragging y ahora debería liberarse. Cuando se suelta un giro, configuramos su valor objetivo para que coincida con el lado al que debe girar 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 plegable en el elemento lienzo. La mayor parte de esto ocurre 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). Limpiar todo el lienzo representa un gran costo de rendimiento y sería mucho más eficiente borrar solo las regiones en las que estamos dibujando. Para que este instructivo siga el tema, lo dejaremos en borrar todo el lienzo.

Si se arrastra un cambio, actualizamos su valor 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, lo que dará como resultado 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 giro no está muy cerca del borde del libro (dentro del 0.3% de BOOK_WIDTH) o si está marcado 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 ver 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';

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

Girar
Así es como 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 compensar el sistema de coordenadas, de modo que podamos dibujar el giro de página con la parte superior del lomo actuando 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 giro de página. El punto 0,0 original está en la parte superior izquierda de la imagen, pero si lo cambiamos, a través de translate(x,y), simplificamos la lógica de dibujo.

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

Ahora solo queda dibujar la forma del papel doblado con las propiedades que definimos anteriormente. Los lados izquierdo y derecho de nuestro papel se dibujan como líneas rectas, y los lados superior e inferior están curvados para dar esa sensación de doblamiento de un papel plegable. La resistencia de esta curva de papel se determina según 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

Giro forzado
El giro de página suave de este instructivo se vuelve aún más potente cuando se combina con otras funciones similares a un libro, como una cubierta 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 mires la experiencia de libro más refinada de la que esta técnica es un extracto en www.20thingsilearned.com. Allí, verás cómo se pueden aplicar los giros de página en una aplicación real y lo potentes que se vuelven cuando se combinan con otras funciones de HTML5.

Referencias

  • Especificación de la API de Canvas