Giới thiệu
Năm 2010, F-i.com và nhóm Google Chrome đã cộng tác trên một ứng dụng web giáo dục dựa trên HTML5 có tên là 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à việc trình bày dự án này tốt nhất là 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 điều đó bằng cách biến chính vùng chứa thành ví dụ về những gì các công nghệ này cho phép chúng ta hoàn thành ngày nay.
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 sách thực tế là mô phỏng những phần hay của trải nghiệm đọc tương tự, đồng thời vẫn tận dụng các 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ý quy trình đọc theo cách đồ hoạ và tương tác, đặc biệt là cách các trang của 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 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ị bỏ qua trong 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 gì.
Markup (note: đây là tên ứng dụng)
Bạn phải luô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ông thể được khách truy cập chọn hoặc tìm thấy bằng các nội dung tìm kiếm trong trình duyệt. Vì lý do đó, nội dung mà chúng ta sẽ xử lý được đặt trực tiếp vào DOM, sau đó được JavaScript thao tác nếu có. Bạn chỉ cần sử dụng ít mã đánh dấu:
<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 của cuốn sách và phần tử canvas
mà chúng ta sẽ vẽ các trang lật. 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
đóng vai trò là mặt nạ ngang cho div
.
Logic
Mã cần thiết để hỗ trợ tính nă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 toàn bộ 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 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ị ở đó.
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 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ử phần để 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ị progress
và target
.
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à chính giữa sách và +1 có nghĩa là cạnh phải nhất của sách.
Bây giờ, chúng ta đã có một đối tượng lật được xác định cho mỗi trang, chúng ta cần 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 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 && 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;
}
}
Hàm mouseMoveHandler
cập nhật đối tượng mouse
để chúng ta luôn hướng đến 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ó được nhấn 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 ta cũng đảm bảo rằng có một trang khác theo hướng đó vì chúng ta có thể đang ở trang đầu tiên hoặc trang cuối cùng. Nếu có một 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
.
Khi đến mouseUpHandler
, chúng ta sẽ xem xét tất cả flips
và kiểm tra xem có dragging
nào được gắn cờ là dragging
và hiện đã được phát hành hay không. Khi thả một lượt lật, chúng ta đặt giá trị mục tiêu của lượt lật đó khớp với phía mà lượt lật sẽ lật sang, tuỳ thuộc vào vị trí con chuột hiện tại.
Số trang cũng được cập nhật để phản ánh việc điều hướng này.
Kết xuất
Giờ đây, hầu hết logic của chúng ta đã được thiết lập, chúng ta sẽ tìm hiểu cách kết xuất giấy gấp vào phần tử canvas. Hầu hết việc 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 sẽ gây tốn kém 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ẽ xoá toàn bộ canvas.
Nếu một lá bài đang được kéo, chúng ta sẽ cập nhật giá trị target
của lá bài đó để khớp với vị trí con chuột nhưng theo tỷ lệ -1 đến 1 thay vì số pixel thực tế.
Chúng ta cũng tăng progress
theo một phần nhỏ của khoảng cách đến target
, điều này sẽ dẫn đến quá trình lật mượt mà và có ảnh động vì nó 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 flips
đang hoạt động. Nếu một lượt lật không gần với cạnh sách (trong phạm vi 0,3% của BOOK_WIDTH
) hoặc nếu lượt lật đó được gắn cờ là dragging
, thì lượt lật đó được coi là đang hoạt động.
Bây giờ, tất cả logic đã được thiết lập, chúng ta cần vẽ bản trình bày đồ hoạ của một 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 hình ảnh mà chúng ta cần để vẽ đường gập một cách chân thực. Giá trị progress
của thao tác 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ẽ kéo giấy ra ngoài cạnh trên và dưới của cuốn sách, hiệu ứng này đạt đến đỉnh điểm khi lật gần đến sống sách.
Giờ đây, khi tất cả các giá trị đã được chuẩn bị, bạn chỉ cần 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 & 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ù hệ toạ độ để chúng ta có thể vẽ thao tác lật trang với đầu cuốn 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()
vào ma trận đó khi chúng ta vẽ xong.
foldGradient
là nội dung chúng ta sẽ dùng để lấp đầy hình dạng của tờ giấy đã gập để tạo hiệu ứng đổ bóng và điểm sáng chân thực. Chúng ta cũng thêm một đường rất mỏng xung quanh bản vẽ trên giấy để giấy không biến mất khi đặt trên nền sáng.
Bây giờ, 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ác thuộc tính mà chúng ta đã xác định ở trên. Các cạnh bên trái và bên phải của tờ giấy được vẽ dưới dạng đường thẳng, còn các cạnh trên và dưới được vẽ cong để tạo cảm giác cong của một tờ giấy gấp. Độ mạnh của đường gập giấy này được xác định bằng giá trị verticalOutdent
.
Vậy là xong! Bây giờ, bạn đã có một chức năng điều hướng lật trang hoạt động đầy đủ.
Bản minh hoạ lật trang
Hiệu ứng lật trang là tất cả về việc truyền đạt 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 hết được hiệu ứng đó.
Các bước tiếp theo
Đây chỉ là một ví dụ về những gì có thể thực hiện được bằng cách sử dụng các tính năng HTML5 như phần tử canvas. Bạn nên xem trải nghiệm sách tinh tế hơn mà kỹ thuật này là một trích đoạn tại: www.20thingsilearned.com. Tại đó, bạn sẽ thấy cách áp dụng tính năng lật trang trong một ứng dụng thực tế và mức độ 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
- Quy cách API Canvas