우수사례 - 20thingsilearned.com의 페이지 넘기기 효과

Hakim El Hattab
Hakim El Hattab

소개

2010년 F-i.com과 Google Chrome팀은 브라우저 및 웹에 관해 배운 20가지(www.20thingsilearned.com)라는 HTML5 기반 교육 웹 앱을 공동으로 제작했습니다. 이 프로젝트의 핵심 아이디어 중 하나는 책의 맥락에서 가장 잘 표현할 수 있다는 것이었습니다. 이 책의 내용은 공개 웹 기술에 관한 내용이므로 컨테이너 자체를 이러한 기술을 통해 오늘날 달성할 수 있는 작업의 예시로 만들어 이를 충실히 반영하는 것이 중요하다고 생각했습니다.

'브라우저와 웹에 대해 배운 20가지 사항'의 도서 표지 및 홈페이지
'브라우저와 웹에 관해 배운 20가지'의 도서 표지 및 홈페이지(www.20thingsilearned.com)

실제 책의 느낌을 구현하는 가장 좋은 방법은 아날로그 독서 환경의 장점을 시뮬레이션하면서 탐색과 같은 영역에서 디지털 영역의 이점을 활용하는 것이라고 판단했습니다. 특히 책의 페이지가 한 페이지에서 다른 페이지로 전환되는 방식 등 읽기 흐름을 그래픽 및 양방향 방식으로 처리하는 데 많은 노력을 기울였습니다.

시작하기

이 튜토리얼에서는 캔버스 요소와 다양한 JavaScript를 사용하여 자체 페이지 전환 효과를 만드는 과정을 안내합니다. 변수 선언 및 이벤트 리스너 구독과 같은 일부 기본 코드는 이 도움말의 스니펫에서 생략되었으므로 작동하는 예시를 참고하세요.

시작하기 전에 데모를 확인하여 빌드할 내용을 파악하는 것이 좋습니다.

마크업

캔버스에 그리는 내용은 검색엔진에서 색인을 생성하거나 방문자가 선택하거나 브라우저 내 검색으로 찾을 수 없다는 점에 항상 유의해야 합니다. 따라서 사용할 콘텐츠는 DOM에 직접 배치된 후 사용 가능한 경우 JavaScript로 조작됩니다. 이렇게 하려면 최소한의 마크업이 필요합니다.

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

책의 기본 컨테이너 요소가 하나 있으며 여기에는 책의 여러 페이지와 페이지를 넘길 때 그릴 canvas 요소가 포함됩니다. section 요소 내부에는 콘텐츠의 div 래퍼가 있습니다. 콘텐츠의 레이아웃에 영향을 주지 않고 페이지 너비를 변경하려면 이 래퍼가 필요합니다. div는 고정 너비이고 section는 오버플로를 숨기도록 설정되어 있으므로 section의 너비가 div의 가로 마스크 역할을 합니다.

책을 엽니다.
종이 질감과 갈색 도서 표지가 포함된 배경 이미지가 도서 요소에 추가됩니다.

로직

페이지 전환을 지원하는 데 필요한 코드는 그리 복잡하지는 않지만 절차적으로 생성된 그래픽이 많이 포함되어 있으므로 상당히 광범위합니다. 먼저 코드 전반에서 사용할 상수 값에 관한 설명을 살펴보겠습니다.

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;

CANVAS_PADDING는 뒤집을 때 종이가 책 밖으로 확장되도록 캔버스 주위에 추가됩니다. 여기에 정의된 일부 상수는 CSS에서도 설정되므로 도서 크기를 변경하려면 여기서도 값을 업데이트해야 합니다.

상수
코드 전반에서 상호작용을 추적하고 페이지 전환을 그리기 위해 사용되는 상수 값입니다.

다음으로 페이지별로 페이지 전환 객체를 정의해야 합니다. 이 객체는 페이지 전환의 현재 상태를 반영하기 위해 책과 상호작용할 때마다 지속적으로 업데이트됩니다.

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

먼저 섹션 요소의 z 인덱스를 구성하여 첫 번째 페이지가 맨 위에 있고 마지막 페이지가 맨 아래에 있도록 하여 페이지가 올바르게 레이어링되어 있는지 확인해야 합니다. 플립 객체의 가장 중요한 속성은 progresstarget 값입니다. 페이지를 현재 얼마나 접어야 하는지 결정하는 데 사용됩니다. -1은 왼쪽 끝까지 접힌 것을, 0은 책의 중앙을, +1은 책의 오른쪽 가장자리까지 접힌 것을 의미합니다.

진행 상황
플립의 진행률 및 타겟 값은 -1~+1 크기로 접히는 페이지를 그려야 할 위치를 결정하는 데 사용됩니다.

이제 각 페이지에 정의된 플립 객체가 있으므로 사용자 입력을 캡처하고 사용하여 플립 상태를 업데이트해야 합니다.

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

mouseMoveHandler 함수는 항상 최신 커서 위치를 향해 작업할 수 있도록 mouse 객체를 업데이트합니다.

mouseDownHandler에서는 먼저 마우스가 왼쪽 페이지 또는 오른쪽 페이지 중 어느 쪽에서 눌렸는지 확인하여 어느 방향으로 전환할지 확인합니다. 첫 번째 또는 마지막 페이지에 있을 수 있으므로 해당 방향에 다른 페이지가 있는지도 확인합니다. 이러한 검사 후에 유효한 뒤집기 옵션을 사용할 수 있으면 상응하는 뒤집기 객체의 dragging 플래그를 true로 설정합니다.

mouseUpHandler에 도달하면 모든 flips를 살펴보고 dragging로 신고된 flips가 있는지 확인한 후 출시해야 합니다. 뒤집기가 해제되면 현재 마우스 위치에 따라 뒤집어야 하는 쪽과 일치하도록 타겟 값을 설정합니다. 페이지 번호도 이 탐색을 반영하도록 업데이트됩니다.

렌더링

이제 대부분의 로직이 준비되었으므로 캔버스 요소에 접히는 종이를 렌더링하는 방법을 살펴보겠습니다. 대부분은 render() 함수 내에서 발생하며, 이 함수는 초당 60번 호출되어 모든 활성 플립의 현재 상태를 업데이트하고 그립니다.

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

}
}

flips 렌더링을 시작하기 전에 clearRect(x,y,w,h) 메서드를 사용하여 캔버스를 재설정합니다. 전체 캔버스를 지우면 성능이 크게 저하되므로 그리려는 영역만 지우는 것이 훨씬 효율적입니다. 이 튜토리얼의 주제에 맞게 전체 캔버스를 지우는 것으로 마무리하겠습니다.

플립이 드래그되면 마우스 위치와 일치하도록 target 값을 업데이트하지만 실제 픽셀이 아닌 -1~1 크기로 업데이트합니다. 또한 progresstarget까지의 거리의 일부로 증분합니다. 이렇게 하면 모든 프레임에서 업데이트되므로 카드가 부드럽게 뒤집히는 애니메이션이 실행됩니다.

모든 프레임에서 모든 flips를 살펴보므로 활성 상태인 flips만 다시 그려야 합니다. 페이지가 도서 가장자리에 매우 가깝지 않거나 (BOOK_WIDTH의 0.3% 이내) dragging로 플래그가 지정된 경우 활성 상태로 간주됩니다.

이제 모든 로직이 준비되었으므로 현재 상태에 따라 카드 뒤집기의 그래픽 표현을 그려야 합니다. 이제 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';

이 코드 섹션은 접힌 부분을 사실적으로 그리기 위해 필요한 여러 시각적 변수를 계산하는 것으로 시작합니다. 여기서는 그리는 플립의 progress 값이 중요한 역할을 합니다. 페이지 접힌 부분이 표시되는 위치이기 때문입니다. 페이지 스크롤 효과에 깊이를 더하기 위해 종이를 책의 상단과 하단 가장자리 밖으로 확장합니다. 이 효과는 스크롤이 책의 등쪽에 가까울 때 가장 두드러집니다.

뒤집기
페이지를 넘기거나 드래그할 때 페이지 접힌 부분이 다음과 같이 표시됩니다.

이제 모든 값이 준비되었으므로 종이를 그리는 작업만 남았습니다.

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

캔버스 API의 translate(x,y) 메서드는 좌표계를 오프셋하여 0,0 위치로 작동하는 스pine 상단으로 페이지 전환을 그릴 수 있도록 하는 데 사용됩니다. 또한 그리기가 완료되면 캔버스의 현재 변환 매트릭스를 save()하고 restore()해야 합니다.

번역
여기에서 페이지 전환을 그립니다. 원래 0,0 지점은 이미지의 왼쪽 상단에 있지만 translate(x,y)를 통해 이를 변경하여 그리기 로직을 단순화합니다.

foldGradient는 접힌 종이의 도형을 채워 현실적인 강조 표시와 그림자를 적용하는 데 사용합니다. 또한 밝은 배경에 놓을 때 종이가 사라지지 않도록 종이 그림 주위에 매우 얇은 선을 추가합니다.

이제 남은 작업은 위에 정의한 속성을 사용하여 접힌 종이의 도형을 그리는 것입니다. 종이의 왼쪽과 오른쪽은 직선으로 그리고 위쪽과 아래쪽은 구부러진 종이의 느낌을 전달하기 위해 곡선으로 그립니다. 이 종이 굴곡의 강도는 verticalOutdent 값에 따라 결정됩니다.

작업이 끝났습니다. 이제 완전히 작동하는 페이지 전환 탐색이 설정되었습니다.

페이지 전환 데모

페이지 전환 효과는 올바른 양방향 느낌을 전달하는 것이 중요하므로 이미지를 보는 것만으로는 효과를 제대로 파악할 수 없습니다.

다음 단계

하드 플립
이 튜토리얼의 부드러운 페이지 전환은 대화형 하드 커버와 같은 다른 책과 같은 기능과 함께 사용하면 더욱 강력해집니다.

이는 캔버스 요소와 같은 HTML5 기능을 활용하여 달성할 수 있는 작업의 한 가지 예에 불과합니다. 이 기법이 발췌된 더 세련된 책 환경을 살펴보세요(www.20thingsilearned.com). 실제 애플리케이션에 페이지 전환을 적용하는 방법과 다른 HTML5 기능과 함께 사용하면 얼마나 강력해지는지 확인할 수 있습니다.

참조