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

Hakim El Hattab
Hakim El Hattab

소개

2010년 F-i.com과 Chrome팀은 HTML5 기반 교육용 웹 앱인 20 Things I Learned about Browsers and the Web(www.20thingsilearned.com)을 개발했습니다. 이 프로젝트의 핵심 아이디어 중 하나는 책의 맥락에서 표현하는 것이 가장 좋다는 것이었습니다. 이 책은 오픈 웹 기술에 대한 내용이 매우 많으므로 컨테이너 자체를 이러한 기술로 오늘날 달성할 수 있는 기술의 예시로 삼아 이를 충실히 이행하는 것이 중요하다고 생각했습니다.

'브라우저와 웹에 대한 20가지 정보'의 책 표지 및 홈페이지
'브라우저와 웹에 대한 20가지 정보'의 책 표지 및 홈페이지(www.20thingsilearned.com)

실제 책과 같은 느낌을 주는 가장 좋은 방법은 아날로그 독서 환경의 좋은 부분을 시뮬레이션하면서 탐색과 같은 영역에서 디지털 영역의 이점을 활용하는 것이라고 판단했습니다. 읽기 흐름을 그래픽과 대화형으로 처리하는 데 많은 노력이 필요했습니다. 특히 책의 페이지가 한 페이지에서 다른 페이지로 넘기는 방식이 그러했습니다.

시작하기

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

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

마크업

캔버스에 그리는 콘텐츠는 검색엔진에 의해 색인화되거나 방문자가 선택하거나 브라우저 내 검색을 통해 검색될 수 없다는 사실을 항상 기억해야 합니다. 따라서 작업할 콘텐츠가 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로 신고되었으며 이제 해제되어야 하는지 확인합니다. 뒤집을 때를 놓으면 현재 마우스 위치에 따라 뒤집을 면과 일치하도록 타겟 값을 설정합니다. 이러한 탐색을 반영하여 페이지 번호도 업데이트됩니다.

렌더링

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

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를 검토하므로 활성 상태인 프레임만 다시 그려야 합니다. 뒤집기가 책 가장자리에 매우 가깝지 않거나 (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 위치가 되는 척추 상단을 사용하여 페이지 뒤집기를 그릴 수 있도록 좌표 시스템을 오프셋하는 데 사용됩니다. 또한 그리기를 완료하면 캔버스의 현재 변환 행렬을 save()하고 여기에 restore()를 지정해야 합니다.

번역
이 지점에서 페이지를 넘겨 가며 그릴 수 있습니다. 원래 0,0 포인트는 이미지 왼쪽 상단에 있지만 0,0 점을 변경하면 translate(x,y)를 통해 그리기 로직이 간소화됩니다.

foldGradient는 접힌 종이의 모양을 채워 사실적인 강조와 그림자를 제공합니다. 또한 밝은 배경에 종이를 배치해도 종이가 사라지지 않도록 그림 주위에 매우 가는 선을 추가합니다.

이제 남은 작업은 위에서 정의한 속성을 사용하여 접힌 종이의 모양을 그리는 것입니다. 종이의 왼쪽과 오른쪽은 직선으로 그려지고, 위쪽과 아래쪽은 종이를 접을 때처럼 구부러지도록 곡선이 그려져 있습니다. 이 종이 구부러진 강도는 verticalOutdent 값으로 결정됩니다.

작업이 끝났습니다. 이제 모든 기능을 갖춘 페이지 넘기기 탐색이 준비되었습니다.

페이지 넘기기 데모

페이지 넘기기 효과는 적절한 상호작용 느낌을 전달하는 데 중요하므로 이미지를 보는 것만으로는 충분하지 않습니다.

다음 단계

하드플립
이 튜토리얼의 소프트 페이지 넘기기는 대화형 하드커버 등 책과 비슷한 다른 기능과 함께 사용하면 훨씬 더 강력해집니다.

이는 캔버스 요소와 같은 HTML5 기능을 사용하여 수행할 수 있는 작업의 한 가지 예일 뿐입니다. www.20thingsilearned.com에서 이 기법을 발췌한 보다 상세한 책을 살펴보실 것을 권장합니다. 여기에서 페이지 넘기기가 실제 애플리케이션에 어떻게 적용될 수 있는지, 다른 HTML5 기능과 함께 사용할 때 페이지 넘기기가 얼마나 강력한지 확인할 수 있습니다.

참조