Estudo de caso - Efeito Page Flip do 20thingsilearned.com

Hakim El Hattab
Hakim El Hattab

Introdução

Em 2010, a F-i.com e a equipe do Google Chrome colaboraram em um aplicativo da Web educacional baseado em HTML5 chamado 20 Things I Learned about Browsers and the Web (www.20thingsilearned.com). Uma das principais ideias por trás desse projeto era que ele seria melhor apresentado no contexto de um livro. Como o conteúdo do livro é muito relacionado a tecnologias da Web aberta, pensamos que era importante permanecer fiel a isso, tornando o próprio contêiner um exemplo do que essas tecnologias nos permitem realizar hoje.

Capa e página inicial de "20 lições que aprendi sobre navegadores e a web"
Capa e página inicial do livro "20 lições que aprendi sobre navegadores e a Web" (www.20thingsilearned.com)

Decidimos que a melhor maneira de ter a sensação de um livro real é simular as partes boas da experiência de leitura analógica e ainda aproveitar os benefícios do mundo digital em áreas como a navegação. O tratamento gráfico e interativo do fluxo de leitura foi muito esforço para ser dedicado, especialmente na forma como as páginas dos livros viram de uma página para outra.

Como começar

Confira neste tutorial o processo de criação do seu próprio efeito de virada de página usando o elemento canvas e bastante JavaScript. Alguns códigos rudimentares, como declarações de variáveis e assinatura do listener de eventos, foram deixados de fora dos snippets deste artigo, portanto, lembre-se de consultar o exemplo funcional.

Antes de começar, confira a demonstração para conhecer o que pretendemos criar.

Marcação

É sempre importante lembrar que o que desenhamos na tela não pode ser indexado por mecanismos de pesquisa, selecionado por um visitante ou encontrado por pesquisas no navegador. Por isso, o conteúdo com o qual trabalharemos é colocado diretamente no DOM e manipulado pelo JavaScript, se disponível. A marcação necessária para isso é mínima:

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

Temos um elemento de contêiner principal para o livro, que, por sua vez, contém as diferentes páginas do livro e o elemento canvas em que vamos desenhar as páginas viradas. Dentro do elemento section, há um wrapper div para o conteúdo. Precisamos dele para mudar a largura da página sem afetar o layout do conteúdo. A div tem uma largura fixa e a section está definida para ocultar o estouro. Isso faz com que a largura da section atue como uma máscara horizontal para a div.

Abrir o Livro.
Uma imagem de plano de fundo contendo textura de papel e a jaqueta de livro marrom é adicionada ao elemento livro.

Lógica

O código necessário para fazer a virada de página não é muito complexo, mas é bastante extenso, porque envolve muitos gráficos gerados processualmente. Para começar, vamos analisar a descrição dos valores constantes que vamos usar em todo o 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;

O CANVAS_PADDING é adicionado ao redor da tela para que o papel possa se estender para fora do livro ao virar. Observe que algumas das constantes definidas aqui também são definidas no CSS. Portanto, se você quiser alterar o tamanho do livro, também será necessário atualizar os valores.

Constantes.
Os valores constantes usados em todo o código para acompanhar a interação e desenhar a virada de página.

Em seguida, precisamos definir um objeto de virada para cada página. Ele é atualizado constantemente à medida que interagimos com o livro para refletir o status atual da virada.

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

Primeiro, precisamos organizar os Z-indexes dos elementos da seção para que a primeira página fique na parte de cima e a última na parte de baixo. As propriedades mais importantes dos objetos de virada são os valores progress e target. Eles são usados para determinar até onde a página deve ser dobrada no momento, -1 significa totalmente para a esquerda, 0 significa o centro morto do livro e +1 significa a borda direita dele.

Progresso.
Os valores de progresso e destino das viradas são usados para determinar onde a página dobrável será desenhada em uma escala de -1 a +1.

Agora que temos um objeto de virada definido para cada página, precisamos começar a capturar e usar a entrada do usuário para atualizar o estado da virada.

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

A função mouseMoveHandler atualiza o objeto mouse para que estejamos sempre trabalhando na localização mais recente do cursor.

Em mouseDownHandler, começamos verificando se o mouse foi pressionado na página esquerda ou direita para saber para qual direção queremos começar a virar. Também garantimos que outra página exista nessa direção, já que podemos estar na primeira ou na última página. Se uma opção válida de virada estiver disponível após essas verificações, definiremos a flag dragging do objeto de virada correspondente como true.

Ao alcançar o mouseUpHandler, analisamos todos os flips e verificamos se algum deles foi sinalizado como dragging e agora precisa ser lançado. Quando um flip é liberado, definimos o valor de destino para corresponder ao lado em que ele vai virar, dependendo da posição atual do mouse. O número da página também é atualizado para refletir essa navegação.

Renderização

Agora que a maior parte da nossa lógica está pronta, veremos como renderizar o papel dobrável no elemento de tela. A maior parte disso acontece dentro da função render(), que é chamada 60 vezes por segundo para atualizar e renderizar o estado atual de todas as viradas ativas.

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 começar a renderizar a flips, redefinimos a tela usando o método clearRect(x,y,w,h). A limpeza de toda a tela tem um alto custo de desempenho, e seria muito mais eficiente limpar apenas as regiões em que estamos desenhando. Para manter este tutorial no assunto, deixaremos de limpar toda a tela.

Se um flip estiver sendo arrastado, atualizaremos o valor target para corresponder à posição do mouse, mas em uma escala de -1 a 1, em vez de pixels reais. Também incrementamos a progress em uma fração da distância até a target. Isso resulta em uma progressão suave e animada da virada, já que ela é atualizada a cada frame.

Como estamos passando por todo o flips em cada frame, precisamos garantir que apenas os que estejam ativos sejam redesenhados. Se uma virada não estiver muito perto da borda do livro (dentro de 0,3% de BOOK_WIDTH) ou se for sinalizada como dragging, ela será considerada ativa.

Agora que toda a lógica está pronta, precisamos desenhar a representação gráfica de um flip, dependendo do estado atual. É hora de analisar a primeira parte da função 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 seção do código começa pelo cálculo de várias variáveis visuais necessárias para desenhar a dobra de maneira realista. O valor progress do flip que estamos desenhando tem um papel importante nesse caso, já que é nesse local que queremos que a dobra da página apareça. Para adicionar profundidade ao efeito de virada de página, fazemos com que o papel se estenda para fora das margens superior e inferior do livro. Esse efeito está em seu auge quando uma virada está próxima à lombada do livro.

Virar
Esta é a aparência da dobra da página quando ela é virada ou arrastada.

Agora que todos os valores estão preparados, só falta desenhar o 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();

O método translate(x,y) da API canvas é usado para deslocar o sistema de coordenadas para que possamos desenhar a virada de página com a parte superior da lombada atuando como posição 0,0. Também precisamos usar save() na matriz de transformação atual da tela e usar restore() nela quando terminarmos o desenho.

Tradutor
Esse é o ponto de onde desenhamos a virada de página. O ponto 0,0 original está no canto superior esquerdo da imagem, mas ao mudar isso, via translate(x,y), simplificamos a lógica de exibição.

A foldGradient é usada para preencher a forma do papel dobrado para oferecer realces e sombras realistas. Também adicionamos uma linha muito fina ao redor do desenho do papel para que ele não desapareça quando for colocado em fundos claros.

Agora só resta desenhar a forma do papel dobrado usando as propriedades definidas acima. Os lados esquerdo e direito do papel são desenhados como linhas retas e os lados superior e inferior são curvos para dar a sensação de dobrar. A intensidade da dobra do papel é determinada pelo valor verticalOutdent.

Pronto! Agora você tem uma navegação de virada de página totalmente funcional.

Demonstração de virada de página

O efeito de virada de página tem a ver com comunicar a sensação interativa correta, portanto, olhar imagens não faz jus ao processo.

Próximas etapas

Hard flip
Neste tutorial, a virada de página flexível se torna ainda mais eficiente quando usada com outros recursos semelhantes a livros, como uma capa dura interativa.

Este é apenas um exemplo do que pode ser feito utilizando recursos HTML5, como o elemento canvas. Recomendo que você confira a experiência de livros mais refinada, da qual essa técnica é um trecho em: www.20thingsilearned.com (link em inglês). Lá, você verá como a virada de página pode ser aplicada em um aplicativo real e como ela é eficiente quando combinada com outros recursos HTML5.

Referências

  • Especificação da API Canvas