소개
2010년에 F-i.com과 Chrome팀은 20 Things I Learned about Browsers and the Web(www.20thingsilearned.com)이라는 HTML5 기반 교육용 웹 앱을 공동으로 개발했습니다. 이 프로젝트의 핵심 아이디어 중 하나는 책의 맥락에서 가장 잘 표현된다는 것입니다. 이 책의 내용은 오픈 웹 기술에 관한 것이므로 이러한 기술을 통해 오늘날 달성할 수 있는 것의 예시를 컨테이너 자체로 만들어 오픈 웹 기술에 충실하는 것이 중요하다고 생각했습니다.
Google은 현실 세계의 책과 같은 느낌을 내기 위해 탐색과 같은 영역에서 디지털 영역의 이점을 활용하면서 아날로그 독서 환경의 좋은 부분을 시뮬레이션하는 것이 가장 좋은 방법이라고 판단했습니다. 특히 책 페이지가 한 페이지에서 다른 페이지로 넘어가는 방식 등 읽기 흐름의 그래픽 및 대화형 처리에 많은 노력이 기울여졌습니다.
시작하기
이 튜토리얼에서는 캔버스 요소와 다양한 JavaScript를 사용하여 자체 페이지 넘기기 효과를 만드는 과정을 안내합니다. 변수 선언 및 이벤트 리스너 구독과 같은 기본적인 코드는 이 도움말의 스니펫에서 제외되었으므로 작동하는 예시를 참고하세요.
시작하기 전에 데모를 확인하여 빌드할 항목을 파악하는 것이 좋습니다.
마크업
캔버스에 그린 내용은 검색엔진에서 색인을 생성할 수 없고, 방문자가 선택할 수 없으며, 브라우저 내 검색으로 찾을 수 없다는 점을 항상 기억해야 합니다. 따라서 사용할 콘텐츠는 DOM에 직접 배치된 후 JavaScript가 있는 경우 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-색인을 구성하여 페이지가 올바르게 레이어링되어 있는지 확인해야 합니다. 플립 객체의 가장 중요한 속성은 progress 및 target 값입니다.
이러한 값은 현재 페이지가 얼마나 접혀야 하는지 결정하는 데 사용됩니다. -1은 왼쪽 끝까지, 0은 책의 중앙, +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 && page - 1 >= 0) {
// We are on the left side, drag the previous page
flips[page - 1].dragging = true;
}
else if (mouse.x > 0 && 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로 플래그가 지정되어 이제 출시해야 하는 항목이 있는지 확인합니다. 플립이 해제되면 현재 마우스 위치에 따라 플립해야 하는 측면과 일치하도록 타겟 값을 설정합니다.
페이지 번호도 이 탐색을 반영하도록 업데이트됩니다.
렌더링
이제 대부분의 로직이 준비되었으므로 종이를 접는 방법을 캔버스 요소에 렌더링하는 방법을 살펴보겠습니다. 이러한 작업은 대부분 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 스케일로 업데이트합니다.
또한 progress를 target까지의 거리의 일부만큼 증가시킵니다. 이렇게 하면 모든 프레임에서 업데이트되므로 부드럽고 애니메이션화된 전환이 이루어집니다.
모든 프레임에서 모든 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 & 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()해야 합니다.
foldGradient은 접힌 종이의 모양을 채워 사실적인 하이라이트와 그림자를 만드는 데 사용됩니다. 또한 밝은 배경에 대고 종이를 놓을 때 종이가 사라지지 않도록 종이 그림 주위에 매우 얇은 선을 추가합니다.
이제 남은 작업은 위에서 정의한 속성을 사용하여 접힌 종이의 모양을 그리는 것입니다. 종이의 왼쪽과 오른쪽은 직선으로 그려지고 위쪽과 아래쪽은 종이를 접는 느낌을 주기 위해 곡선으로 그려집니다. 이 종이 구부림의 강도는 verticalOutdent 값에 따라 결정됩니다.
작업이 끝났습니다. 이제 완전히 작동하는 페이지 넘기기 탐색이 마련되었습니다.
페이지 넘기기 데모
페이지 넘기기 효과는 올바른 대화형 느낌을 전달하는 것이 전부이므로 이미지를 보는 것만으로는 정확히 알 수 없습니다.
다음 단계
이는 캔버스 요소와 같은 HTML5 기능을 활용하여 달성할 수 있는 작업의 한 가지 예일 뿐입니다. 이 기법이 발췌된 더 세련된 책 환경을 www.20thingsilearned.com에서 확인해 보세요. 실제 애플리케이션에서 페이지 넘기기를 어떻게 적용할 수 있는지, 다른 HTML5 기능과 결합했을 때 얼마나 강력해지는지 확인할 수 있습니다.
참조
- 캔버스 API 사양