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

Năm 2010, F-i.com và nhóm Google Chrome đã hợp tác để tạo ra một ứng dụng web giáo dục dựa trên HTML5 có tên là 20 Things I Learned about Browsers and the Web (20 điều tôi học được về trình duyệt và web) (www.20thingsilearned.com). Một trong những ý tưởng chính đằng sau dự án này là dự án sẽ được trình bày tốt nhất 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 nói về các công nghệ web mở, nên chúng tôi cho rằng điều quan trọng là phải giữ đúng tinh thần đó bằng cách biến chính vùng chứa này thành một ví dụ về những gì các công nghệ này cho phép chúng ta đạt được ngày nay.

Bìa sách và trang chủ của cuốn sách "20 điều tôi học được về trình duyệt và web"
Bìa sách và trang chủ của "20 Things I Learned About Browsers and the Web" (20 điều tôi học đượ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ư đang đọc một cuốn sách ngoài đời thực là mô phỏng những điểm hay của trải nghiệm đọc sách trên giấy, đồng thời vẫn tận dụng những lợi ích của thế giới kỹ thuật số trong các lĩnh vực như điều hướng. Chúng tôi đã nỗ lực rất nhiều để xử lý đồ hoạ và tính tương tác của quy trình đọ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ẽ hướng dẫn bạn quy trình tạo hiệu ứng lật trang của riêng mình bằng cách sử dụ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ị loại 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ụ đang hoạt động.

Trước khi bắt đầu, bạn nên xem bản minh hoạ để biết chúng ta đang hướng đến việc xây dựng ứng dụng nào.

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

Bạn luôn cần nhớ rằng những gì chúng ta vẽ trên canvas không thể được công cụ tìm kiếm lập chỉ mục, khách truy cập chọn hoặc tìm thấy bằng các cụm từ tìm kiếm trong trình duyệt. Vì lý do đó, nội dung mà chúng ta sẽ làm việc được đặt trực tiếp trong DOM và sau đó được JavaScript thao tác nếu có. Bạn chỉ cần đánh dấu rất ít cho việc này:

<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, lần lượt chứa các trang khác nhau của cuốn sách và phần tử canvas mà chúng ta sẽ vẽ các trang lật trên đó. 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 của nội dung. div có chiều rộng cố định và section được đặt để ẩn phần tràn, điều này dẫn đến chiều rộng của section hoạt động như một mặt nạ ngang cho div.

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

Logic

Mã cần thiết để hỗ trợ hiệu ứng lật trang không quá 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 phần mô tả về các giá trị hằng số mà chúng ta sẽ sử dụng trong 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 vào xung quanh canvas để chúng ta có thể mở rộng giấy ra ngoài sách khi lật. Xin lưu ý rằng một số hằng số được xác định ở đây cũng được đặt trong CSS, vì vậy, nếu muốn thay đổi kích thước của sách, bạn cũng cần cập nhật các giá trị ở đó.

Hằng số.
Các giá trị hằng số được dùng trong suốt mã để theo dõi lượt tương tác và vẽ hiệu ứng lật trang.

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

// 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 xếp lớp đúng cách bằng cách sắp xếp chỉ mục z của các phần tử 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à các giá trị progresstarget. Các giá trị này được dùng để xác định mức độ gấp trang hiện tại, -1 có nghĩa là gấp hết sang trái, 0 có nghĩa là gấp vào giữa sách và +1 có nghĩa là gấp đến mép ngoài cùng bên phải của sách.

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

Giờ đây, sau khi xác định một đối tượng lật cho mỗi trang, chúng ta cần bắt đầu ghi lại và sử dụng thông tin đầu vào của người dùng để cập nhật trạng thái của thao tác 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 làm việc vớ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 người dùng có nhấn chuột xuống ở trang bên trái hay bên phải hay không để biết hướng mà chúng ta muốn bắt đầu lật. Chúng tôi cũng đảm bảo rằng có một trang khác 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ó lựa chọn lật hợp lệ sau các bước kiểm tra này, chúng tôi sẽ đặt cờ dragging của đối tượng lật tương ứng thành true.

Sau khi đạt đến mouseUpHandler, chúng tôi sẽ xem xét tất cả flips và kiểm tra xem có nội dung nào bị gắn cờ là dragging và hiện cần được phát hành hay không. Khi một thao tác lật được phát hành, chúng ta sẽ đặt giá trị mục tiêu của thao tác đó sao cho khớp với mặt mà thao tác đó sẽ lật sang, tuỳ 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ờ đây, khi hầu hết logic của chúng ta đã được thiết lập, chúng ta sẽ xem xét cách kết xuất giấy gấp lên phần tử canvas. Hầu hết quá trình này diễn ra bên trong hàm render(). Hàm này đượ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 thao tác 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 sẽ đặt lại canvas bằng phương thức clearRect(x,y,w,h). Việc xoá toàn bộ canvas sẽ tiêu tốn nhiều hiệu suất và sẽ hiệu quả hơn nhiều nếu chỉ xoá những vùng mà chúng ta đang vẽ. Để giữ cho hướng dẫn này đúng chủ đề, chúng ta sẽ chỉ xoá toàn bộ canvas.

Nếu một thao tác lật đang được kéo, chúng ta sẽ cập nhật giá trị target của thao tác đó cho phù hợp với vị trí chuột nhưng theo tỷ lệ từ -1 đến 1 thay vì số pixel thực tế. Chúng ta cũng tăng progress theo một phần khoảng cách đến target. Điều này sẽ dẫn đến một tiến trình lật mượt mà và có hiệu ứng vì tiến trình này cập nhật trên mọi khung hình.

Vì chúng ta sẽ xem xét tất cả 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 một thao tác lật không quá gần mép sách (trong vòng 0,3% của BOOK_WIDTH) hoặc nếu thao tác đó được gắn cờ là dragging, thì thao tác đó được coi là đang hoạt động.

Bây giờ, khi đã có tất cả logic, chúng ta cần vẽ biểu diễn đồ hoạ của một thao tác lật, tuỳ thuộc vào trạng thái hiện tại của thao tác đó. Đã đế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ẽ nếp gấp theo cách thực tế. Giá trị progress của hiệu ứng 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. Để tăng chiều sâu cho hiệu ứng lật trang, chúng ta sẽ làm cho tờ giấy kéo dài ra ngoài các cạnh trên và dưới của cuốn sách. Hiệu ứng này đạt đến đỉnh điểm khi một trang được lật gần với gáy sách.

Lật
Đây là hình ảnh nếp gấp trang khi trang đang lật hoặc bị kéo.

Giờ đây, khi tất cả các giá trị đã được chuẩn bị, việc còn lại là vẽ 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 canvas API được dùng để bù hệ toạ độ để chúng ta có thể vẽ hiệu ứng lật trang với phần trên cùng của gáy sách đó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() ma trận đó khi vẽ xong.

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

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

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

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

Bản minh hoạ hiệu ứng lật trang

Hiệu ứng lật trang là để 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 hiệu ứng này không thể hiện được hết giá trị của nó.

Các bước tiếp theo

Lật mạnh
Hiệu ứng lật trang mềm trong hướng dẫn này sẽ trở nên mạnh mẽ hơn nữa 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ể đạt được bằng cách sử dụng các tính năng HTML5, chẳng hạn như phần tử canvas. Tôi khuyên bạn nên xem trải nghiệm đọc sách tinh tế hơn mà kỹ thuật này là một đoạn trích tại: www.20thingsilearned.com. Tại đây, bạn sẽ thấy cách lật trang có thể được áp dụng trong một ứng dụng thực và mức độ mạnh mẽ của kỹ thuật này khi kết hợp với các tính năng khác của HTML5.

Tài liệu tham khảo