Nghiên cứu điển hình – Hiệu ứng lật trang từ 20thingsilearned.com

Hakim El Hattab
Hakim El Hattab

Giới thiệu

Vào năm 2010, F-i.com và nhóm Google Chrome đã cộng tác với nhau để phát triển một ứng dụng web giáo dục dựa trên HTML5 có tên là 20 Things I Learned about browser and the Web (www.20thingsilearned.com). Một trong những ý tưởng chính của dự án này là tốt nhất bạn nên trình bày sách trong bối cảnh của một cuốn sách. Vì nội dung của cuốn sách chủ yếu đề cập đến các công nghệ web mở, chúng tôi cảm thấy điều quan trọng là phải tuân thủ điều đó bằng cách lấy chính vùng chứa làm ví dụ về những gì mà những công nghệ này cho phép chúng tôi hoàn thành hiện nay.

Bìa sách và trang chủ của "20 điều tôi đã tìm hiểu được về trình duyệt và web"
Bìa sách và trang chủ của "20 điều tôi đã tìm hiểu được về trình duyệt và web" (www.20thingsilearned.com)

Chúng tôi quyết định rằng cách tốt nhất để mang lại cảm giác như một cuốn sách trong thế giới thực là mô phỏng những điểm hay của trải nghiệm đọc kiểu tương tự, trong khi vẫn tận dụng được những lợi ích của thế giới kỹ thuật số trong những lĩnh vực như chỉ đường. Chúng tôi đã dành nhiều công sức cho quy trình đọc đồ hoạ và tương tác – đặc biệt là cách các trang sách lật từ trang này sang trang khác.

Bắt đầu

Hướng dẫn này sẽ giới thiệu cho bạn quá trình tạo hiệu ứng lật trang của riêng bạn bằng phần tử canvas và nhiều JavaScript. Một số mã cơ bản, chẳng hạn như khai báo biến và đăng ký trình nghe sự kiện, đã bị đưa ra khỏi các đoạn mã trong bài viết này, vì vậy, hãy nhớ tham khảo ví dụ hoạt động.

Trước khi bắt đầu, bạn nên xem bản minh hoạ để biết chúng tôi muốn tạo ra điều gì.

Markup (note: đây là tên ứng dụng)

Xin lưu ý rằng những gì chúng ta vẽ trên canvas không thể lập chỉ mục bằng công cụ tìm kiếm, do khách truy cập chọn hoặc tìm thấy bằng tìm kiếm trong trình duyệt. Vì lý do đó, nội dung chúng ta sẽ xử lý được đặt trực tiếp vào DOM và sau đó được JavaScript kiểm soát nếu có sẵn. Mã đánh dấu tối thiểu cho việc này là:

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

Chúng ta có một phần tử vùng chứa chính cho cuốn sách, phần tử này chứa các trang khác nhau trong sách và phần tử canvas mà chúng ta sẽ vẽ các trang lật trang. Bên trong phần tử section có một trình bao bọc div cho nội dung – chúng ta cần trình bao bọc này để có thể thay đổi chiều rộng của trang mà không ảnh hưởng đến bố cục nội dung của trang. div có chiều rộng cố định và section được thiết lập để ẩn phần tràn, dẫn đến chiều rộng của section hoạt động như một mặt nạ theo chiều ngang cho div.

Mở Sách.
Hình nền chứa hoạ tiết giấy và áo khoác sách màu nâu được thêm vào phần tử sách.

Logic

Mã cần thiết để hỗ trợ tính năng lật trang không phức tạp nhưng khá rộng vì liên quan đến nhiều đồ hoạ được tạo theo quy trình. Hãy bắt đầu bằng cách xem nội dung mô tả về các giá trị hằng số mà chúng ta sẽ sử dụng xuyên suốt mã.

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 được thêm xung quanh canvas để chúng ta có thể mở rộng giấy ra bên ngoài sách khi lật. Xin lưu ý rằng một số hằng số xác định ở đây cũng được đặt trong CSS, vì vậy, nếu muốn thay đổi kích thước sách, bạn cũng cần cập nhật các giá trị tại đó.

Hằng số.
Các giá trị hằng số được dùng trong toàn bộ mã để theo dõi hoạt động tương tác và vẽ hoạt động lật trang.

Tiếp theo, chúng ta cần xác định một đối tượng lật cho từng trang, các trang này sẽ liên tục được cập nhật khi chúng ta tương tác với cuốn sách để phản ánh trạng thái hiện tại của thao tác lật.

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

Trước tiên, chúng ta cần đảm bảo các trang được phân lớp chính xác bằng cách sắp xếp các chỉ mục z của các phần tử trong mục sao cho trang đầu tiên ở trên cùng và trang cuối cùng ở dưới cùng. Các thuộc tính quan trọng nhất của đối tượng lật là giá trị progresstarget. Chúng dùng để xác định xem hiện tại trang phải gập lại bao xa, -1 có nghĩa là hoàn toàn di chuyển sang trái, 0 có nghĩa là phần nằm giữa cuốn sách và +1 có nghĩa là mép trên cùng bên phải của sách.

Tiến trình.
Tiến trình và giá trị mục tiêu của các lượt lật được dùng để xác định vị trí vẽ trang gập trên tỷ lệ -1 đến +1.

Bây giờ, chúng ta đã xác định một đối tượng lật cho mỗi trang để bắt đầu chụp và sử dụng dữ liệu đầu vào của người dùng để cập nhật trạng thái của tính năng lật.

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

Hàm mouseMoveHandler cập nhật đối tượng mouse để chúng ta luôn hướng tới vị trí con trỏ gần đây nhất.

Trong mouseDownHandler, chúng ta bắt đầu bằng cách kiểm tra xem chuột được nhấn ở bên trái hay bên phải trang để biết mình muốn bắt đầu lật sang hướng nào. Chúng tôi cũng đảm bảo rằng một trang khác tồn tại theo hướng đó vì chúng tôi có thể đang ở trang đầu tiên hoặc trang cuối cùng. Nếu có tuỳ chọn lật hợp lệ sau các bước kiểm tra này, chúng ta sẽ đặt cờ dragging của đối tượng lật tương ứng thành true.

Sau khi truy cập mouseUpHandler, chúng tôi sẽ xem xét toàn bộ flips và kiểm tra xem có bất kỳ lệnh nào trong số đó bị gắn cờ là dragging và hiện đã được phát hành hay không. Khi lật được, chúng ta đặt giá trị mục tiêu sao cho khớp với cạnh mà tính năng lật phụ thuộc vào vị trí hiện tại của chuột. Số trang cũng được cập nhật để phản ánh thao tác điều hướng này.

Kết xuất

Giờ thì hầu hết logic của chúng ta đã sẵn sàng, chúng ta sẽ cùng tìm hiểu cách kết xuất giấy gấp vào phần tử canvas. Hầu hết điều này xảy ra bên trong hàm render(), được gọi 60 lần mỗi giây để cập nhật và vẽ trạng thái hiện tại của tất cả các lượt lật đang hoạt động.

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

}
}

Trước khi bắt đầu kết xuất flips, chúng ta đặt lại canvas bằng cách sử dụng phương thức clearRect(x,y,w,h). Việc xoá toàn bộ canvas gây ra chi phí hiệu suất lớn và sẽ hiệu quả hơn nhiều nếu bạn chỉ xoá những khu vực mà chúng tôi đang vẽ. Nhằm giữ nguyên hướng dẫn này theo chủ đề, chúng ta sẽ chỉ xoá toàn bộ canvas.

Nếu một lượt lật đang được kéo, chúng tôi sẽ cập nhật giá trị target để khớp với vị trí chuột nhưng theo tỷ lệ -1: 1 thay vì pixel thực tế. Chúng tôi cũng tăng progress lên một phần khoảng cách đến target, điều này sẽ dẫn đến quá trình lật mượt mà và sinh động vì nó cập nhật trên mọi khung hình.

Vì đang xem xét toàn bộ flips trên mọi khung hình, nên chúng ta cần đảm bảo chỉ vẽ lại những khung hình đang hoạt động. Nếu thao tác lật không quá gần cạnh của cuốn sách (trong phạm vi 0,3% của BOOK_WIDTH) hoặc nếu được gắn cờ là dragging, thì thao tác đó được coi là đang hoạt động.

Giờ đây, khi đã có tất cả logic, chúng ta cần vẽ hình biểu diễn dạng đồ hoạ của lượt lật tuỳ thuộc vào trạng thái hiện tại của lượt lật. Đã đến lúc xem xét phần đầu tiên của hàm 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';

Phần mã này bắt đầu bằng cách tính toán một số biến trực quan mà chúng ta cần để vẽ đường ranh giới phần hiển thị theo cách thực tế. Giá trị progress của lượt lật mà chúng ta đang vẽ đóng vai trò quan trọng ở đây, vì đó là nơi chúng ta muốn đường gập trang xuất hiện. Để thêm chiều sâu cho hiệu ứng lật trang, chúng tôi làm cho tờ giấy mở rộng ra ngoài cạnh trên cùng và cạnh dưới của cuốn sách, hiệu ứng này đạt đến đỉnh điểm khi lật sách ở gần gáy sách.

Lật
Màn hình gập của trang sẽ trông như sau khi trang bị lật hoặc bị kéo.

Giờ đây, tất cả các giá trị đã được chuẩn bị, tất cả những gì còn lại chỉ là việc vẽ trang giấy!

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

Phương thức translate(x,y) của API canvas được dùng để bù cho hệ thống điều phối, để chúng ta có thể vẽ lật trang với đầu của cột đóng vai trò là vị trí 0,0. Xin lưu ý rằng chúng ta cũng cần save() ma trận biến đổi hiện tại của canvas và restore() vào ma trận đó khi vẽ xong.

Dịch
Đây là điểm mà chúng tôi vẽ tính năng lật trang. Điểm 0,0 ban đầu nằm ở góc trên cùng bên trái của hình ảnh nhưng bằng cách thay đổi giá trị đó, thông qua dịch(x,y), chúng ta sẽ đơn giản hoá logic vẽ.

foldGradient là lớp mà chúng ta sẽ lấp đầy hình dạng của giấy gấp để tạo ra các vùng sáng và bóng chân thực. Chúng tôi cũng thêm một đường rất mỏng xung quanh bản vẽ giấy để giấy không biến mất khi đặt trên nền sáng.

Tất cả những gì còn lại bây giờ là vẽ hình dạng của giấy gập bằng cách sử dụng các thuộc tính chúng ta đã xác định ở trên. Các cạnh bên trái và bên phải của giấy được vẽ dưới dạng các đường thẳng, các cạnh trên và dưới được uốn cong để mang cảm giác như một tờ giấy đang gấp. Cường độ của đường cong giấy này được xác định bằng giá trị verticalOutdent.

Vậy là xong! Giờ đây, bạn đã có một tính năng điều hướng lật trang với đầy đủ chức năng.

Bản demo lật trang

Hiệu ứng lật trang xoay quanh việc truyền tải cảm giác tương tác phù hợp, vì vậy, việc xem hình ảnh của trang sẽ không chính xác.

Các bước tiếp theo

Lật cứng
Tính năng lật trang mềm trong hướng dẫn này sẽ trở nên hiệu quả hơn khi kết hợp với các tính năng khác giống như sách, chẳng hạn như bìa cứng tương tác.

Đây chỉ là một ví dụ về những gì có thể được thực hiện bằng cách sử dụng các tính năng HTML5, chẳng hạn như phần tử canvas. Bạn nên tham khảo trải nghiệm đọc sách chi tiết hơn mà kỹ thuật này được trích dẫn tại: www.20thingsilearned.com. Tại đó, bạn sẽ thấy tính năng lật trang có thể được áp dụng như thế nào trong ứng dụng thực tế và hiệu quả của tính năng này khi kết hợp với các tính năng HTML5 khác.

Tài liệu tham khảo