Étude de cas : Effet de page tournée sur 20thingsilearned.com

Hakim El Hattab
Hakim El Hattab

Introduction

En 2010, F-i.com et l'équipe Google Chrome ont collaboré sur une application Web éducative basée sur HTML5 appelée 20 Things I Learned about Browsers and the Web (www.20thingsilearned.com). L'une des idées clés de ce projet était qu'il serait préférable de le présenter dans le contexte d'un livre. Le contenu du livre portant principalement sur les technologies du Web ouvert, nous avons jugé important de rester fidèles à cet esprit en faisant du conteneur lui-même un exemple de ce que ces technologies nous permettent d'accomplir aujourd'hui.

Couverture et page d'accueil du livre "20 Things I Learned About Browsers and the Web"
Couverture et page d'accueil de "20 Things I Learned About Browsers and the Web" (www.20thingsilearned.com)

Nous avons décidé que la meilleure façon de recréer la sensation d'un livre physique était de simuler les aspects positifs de l'expérience de lecture analogique, tout en tirant parti des avantages du numérique dans des domaines tels que la navigation. Nous avons déployé beaucoup d'efforts pour le traitement graphique et interactif du flux de lecture, en particulier pour la façon dont les pages des livres se tournent.

Premiers pas

Ce tutoriel vous explique comment créer votre propre effet de page tournée à l'aide de l'élément canvas et de nombreux éléments JavaScript. Une partie du code rudimentaire, comme les déclarations de variables et l'abonnement aux écouteurs d'événements, a été omise des extraits de code de cet article. N'oubliez donc pas de vous référer à l'exemple fonctionnel.

Avant de commencer, il est judicieux de consulter la démo pour savoir ce que nous allons créer.

Annoter

Il est important de se rappeler que ce que nous dessinons sur le canevas ne peut pas être indexé par les moteurs de recherche, sélectionné par un visiteur ni trouvé par les recherches dans le navigateur. C'est pourquoi le contenu avec lequel nous allons travailler est placé directement dans le DOM, puis manipulé par JavaScript s'il est disponible. Le balisage requis est minimal :

<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>

Nous avons un élément de conteneur principal pour le livre, qui contient à son tour les différentes pages de notre livre et l'élément canvas sur lequel nous allons dessiner les pages qui se tournent. Dans l'élément section, il existe un wrapper div pour le contenu. Nous en avons besoin pour pouvoir modifier la largeur de la page sans affecter la mise en page de son contenu. La largeur de div est fixe et section est défini pour masquer son contenu qui dépasse. La largeur de section sert donc de masque horizontal pour div.

Ouvrez le livre.
Une image de fond contenant la texture du papier et la couverture marron du livre est ajoutée à l'élément du livre.

Logique

Le code nécessaire pour faire tourner la page n'est pas très complexe, mais il est assez étendu, car il implique de nombreux graphiques générés de manière procédurale. Commençons par examiner la description des valeurs constantes que nous utiliserons tout au long du code.

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;

Le CANVAS_PADDING est ajouté autour du canevas afin que le papier puisse s'étendre au-delà du livre lors du changement de page. Notez que certaines constantes définies ici sont également définies dans le CSS. Par conséquent, si vous souhaitez modifier la taille du livre, vous devrez également mettre à jour les valeurs correspondantes.

Constantes.
Les valeurs constantes utilisées dans le code pour suivre l'interaction et dessiner le changement de page.

Nous devons ensuite définir un objet d'inversion pour chaque page. Ces objets seront constamment mis à jour à mesure que nous interagissons avec le livre pour refléter l'état actuel de l'inversion.

// 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
});
}

Nous devons d'abord nous assurer que les pages sont correctement superposées en organisant les z-index des éléments de section de sorte que la première page soit en haut et la dernière en bas. Les propriétés les plus importantes des objets d'inversion sont les valeurs progress et target. Elles permettent de déterminer le degré de pliage de la page. -1 signifie que la page est pliée complètement à gauche, 0 qu'elle est pliée au centre du livre et +1 qu'elle est pliée complètement à droite.

Progression.
Les valeurs de progression et de cible des flips sont utilisées pour déterminer où la page de pliage doit être dessinée sur une échelle allant de -1 à +1.

Maintenant que nous avons défini un objet de retournement pour chaque page, nous devons commencer à capturer et à utiliser les saisies des utilisateurs pour mettre à jour l'état du retournement.

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 fonction mouseMoveHandler met à jour l'objet mouse afin que nous travaillions toujours sur l'emplacement du curseur le plus récent.

Dans mouseDownHandler, nous commençons par vérifier si la souris a été enfoncée sur la page de gauche ou de droite afin de savoir dans quelle direction nous voulons commencer à tourner les pages. Nous nous assurons également qu'une autre page existe dans cette direction, car nous pouvons être sur la première ou la dernière page. Si une option d'inversion valide est disponible après ces vérifications, nous définissons l'indicateur dragging de l'objet d'inversion correspondant sur true.

Une fois que nous avons atteint le mouseUpHandler, nous parcourons tous les flips et vérifions si l'un d'eux a été signalé comme dragging et doit maintenant être libéré. Lorsqu'un flip est relâché, nous définissons sa valeur cible pour qu'elle corresponde au côté vers lequel il doit basculer en fonction de la position actuelle de la souris. Le numéro de page est également mis à jour pour refléter cette navigation.

Affichage

Maintenant que la majeure partie de notre logique est en place, nous allons voir comment afficher le papier plié sur l'élément canvas. La plupart de ces opérations se déroulent dans la fonction render(), qui est appelée 60 fois par seconde pour mettre à jour et dessiner l'état actuel de tous les flips actifs.

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 );
}

}
}

Avant de commencer le rendu de flips, nous réinitialisons le canevas à l'aide de la méthode clearRect(x,y,w,h). Effacer l'intégralité du canevas a un coût important en termes de performances. Il serait beaucoup plus efficace d'effacer uniquement les régions sur lesquelles nous dessinons. Pour rester concentrés sur le sujet de ce tutoriel, nous allons nous contenter d'effacer l'intégralité du canevas.

Si un flip est en cours de déplacement, nous mettons à jour sa valeur target pour qu'elle corresponde à la position de la souris, mais sur une échelle de -1 à 1 plutôt qu'en pixels réels. Nous incrémentons également progress d'une fraction de la distance par rapport à target. Cela permet une progression fluide et animée de l'inversion, car elle est mise à jour à chaque frame.

Comme nous parcourons tous les flips à chaque frame, nous devons nous assurer de ne redessiner que ceux qui sont actifs. Si un retournement de page n'est pas très proche du bord du livre (à moins de 0,3 % de BOOK_WIDTH) ou s'il est signalé comme dragging, il est considéré comme actif.

Maintenant que toute la logique est en place, nous devons dessiner la représentation graphique d'un flip en fonction de son état actuel. Il est temps d'examiner la première partie de la fonction 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';

Cette section du code commence par calculer un certain nombre de variables visuelles dont nous avons besoin pour dessiner le pli de manière réaliste. La valeur progress de l'inversion que nous dessinons joue un rôle important ici, car c'est là que nous voulons que le pli de la page apparaisse. Pour ajouter de la profondeur à l'effet de page qui se tourne, nous faisons en sorte que le papier dépasse des bords supérieur et inférieur du livre. Cet effet est à son apogée lorsqu'une page est proche de la reliure du livre.

Changer de caméra
Voici à quoi ressemble le pli de page lorsque la page est retournée ou déplacée.

Maintenant que toutes les valeurs sont préparées, il ne reste plus qu'à dessiner le papier.

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();

La méthode translate(x,y) de l'API Canvas est utilisée pour décaler le système de coordonnées afin que nous puissions dessiner notre page en utilisant le haut de la reliure comme position 0,0. Notez que nous devons également save() la matrice de transformation actuelle du canevas et restore() à celle-ci lorsque nous avons terminé de dessiner.

Traduire
Il s'agit du point à partir duquel nous dessinons le retournement de page. Le point 0,0 d'origine se trouve en haut à gauche de l'image, mais en le modifiant à l'aide de translate(x,y), nous simplifions la logique de dessin.

Le foldGradient est ce qui remplira la forme du papier plié pour lui donner des reflets et des ombres réalistes. Nous ajoutons également une ligne très fine autour du dessin sur papier pour que le papier ne disparaisse pas sur les arrière-plans clairs.

Il ne reste plus qu'à dessiner la forme du papier plié à l'aide des propriétés que nous avons définies ci-dessus. Les côtés gauche et droit de notre feuille sont dessinés sous forme de lignes droites, tandis que les côtés supérieur et inférieur sont incurvés pour donner l'impression d'une feuille pliée. La résistance de ce pliage du papier est déterminée par la valeur verticalOutdent.

Et voilà ! Vous disposez désormais d'une navigation par page entièrement fonctionnelle.

Démonstration du retournement de page

L'effet de changement de page vise à communiquer la bonne sensation interactive. Par conséquent, le fait de regarder des images de cet effet ne lui rend pas vraiment justice.

Étapes suivantes

Hard-flip
L'effet de page tournée en douceur de ce tutoriel devient encore plus puissant lorsqu'il est associé à d'autres fonctionnalités de type livre, comme une couverture rigide interactive.

Il ne s'agit que d'un exemple de ce qui peut être accompli en utilisant les fonctionnalités HTML5 telles que l'élément canvas. Je vous recommande de consulter l'expérience de livre plus raffinée dont cette technique est un extrait sur www.20thingsilearned.com. Vous y verrez comment les pages peuvent être retournées dans une application réelle et à quel point cette technique devient puissante lorsqu'elle est associée à d'autres fonctionnalités HTML5.

Références

  • Spécification de l'API Canvas