CSS Deep-Dive – Ma trận3d() cho thanh cuộn tuỳ chỉnh hoàn hảo về khung hình

Thanh cuộn tuỳ chỉnh cực kỳ hiếm gặp và chủ yếu là do thanh cuộn là một trong những thanh còn lại trên web không tạo kiểu cách (tôi đang xem bạn, bộ chọn ngày). Bạn có thể sử dụng JavaScript để tạo ứng dụng của riêng mình, nhưng cách này rất tốn kém, độ chân thực thấp và có thể gây ra hiệu ứng chậm. Trong bài viết này, chúng ta sẽ tận dụng một số ma trận CSS độc đáo để tạo một trình cuộn tuỳ chỉnh không yêu cầu JavaScript trong khi cuộn, chỉ cần một số mã thiết lập.

TL;DR

Bạn không quan tâm đến những chuyện nhỏ? Bạn chỉ muốn xem Bản minh hoạ mèo Nyan và tải thư viện phải không? Bạn có thể tìm thấy mã của bản minh hoạ trong kho lưu trữ GitHub.

LAM;WRA (Dài và mang tính toán học; vẫn sẽ đọc)

Cách đây không lâu, chúng tôi đã tạo một công cụ cuộn thị sai (Bạn đã đọc bài viết đó chưa? Nội dung này thực sự rất hay, rất đáng để bạn dành thời gian tham gia!). Bằng cách đẩy các phần tử trở lại bằng cách sử dụng các biến đổi CSS 3D, các phần tử đã di chuyển chậm hơn so với tốc độ cuộn thực tế của chúng tôi.

Tóm tắt

Hãy bắt đầu bằng việc tóm tắt cách hoạt động của công cụ cuộn thị sai.

Như minh hoạ trong ảnh động, chúng tôi đã đạt được hiệu ứng thị sai bằng cách đẩy các phần tử "ngược" về phía sau trong không gian 3D, dọc theo trục Z. Cuộn tài liệu thực ra là bản dịch dọc theo trục Y. Vì vậy, nếu chúng ta cuộn xuống 100px, thì mọi phần tử sẽ được dịch lên 100px. Điều này áp dụng cho tất cả các thành phần, ngay cả những thành phần ở phía sau. Nhưng các thành phần này ở xa máy ảnh hơn nên chuyển động quan sát được trên màn hình sẽ nhỏ hơn 100 px, tạo ra hiệu ứng thị sai mong muốn.

Tất nhiên, việc di chuyển một phần tử trở lại không gian cũng sẽ làm cho phần tử đó xuất hiện nhỏ hơn. Chúng ta sẽ khắc phục điều này bằng cách mở rộng tỷ lệ phần tử sao lưu. Chúng tôi đã tìm ra phép toán chính xác khi xây dựng công cụ cuộn thị sai, nên tôi sẽ không lặp lại tất cả chi tiết.

Bước 0: Chúng ta muốn làm gì?

Thanh cuộn. Đó là những gì chúng ta sẽ xây dựng. Nhưng bạn đã bao giờ thực sự nghĩ về những gì họ làm chưa? Tất nhiên là tôi không làm như vậy. Thanh cuộn cho biết lượng nội dung có sẵn đang hiển thị và tiến độ mà người đọc đã đạt được. Nếu bạn cuộn xuống, thanh cuộn cũng sẽ cho biết rằng bạn đang tiến đến cuối. Nếu tất cả nội dung đều vừa với khung nhìn, thanh cuộn thường sẽ bị ẩn. Nếu nội dung có chiều cao gấp 2 lần chiều cao của khung nhìn, thì thanh cuộn sẽ lấp đầy 1⁄2 chiều cao của khung nhìn. Nội dung có giá trị gấp 3 lần chiều cao của khung nhìn sẽ điều chỉnh tỷ lệ thanh cuộn thành 1⁄3 khung nhìn, v.v. Bạn sẽ thấy mẫu. Thay vì cuộn, bạn cũng có thể nhấp và kéo thanh cuộn để di chuyển qua trang web nhanh hơn. Đó là một số lượng hành vi đáng kinh ngạc cho một phần tử không dễ thấy như thế. Hãy chiến đấu từng trận một.

Bước 1: Đảo ngược

Ok, chúng ta có thể làm cho các phần tử di chuyển chậm hơn tốc độ cuộn bằng các biến đổi CSS 3D như đã nêu trong bài viết cuộn trên thị sai. Chúng tôi có thể đảo ngược hướng dẫn này không? Hoá ra chúng ta có thể làm được và đó là cách để xây dựng một thanh cuộn tuỳ chỉnh hoàn hảo với khung hình. Để hiểu cách hoạt động của CSS 3D, trước tiên, chúng ta cần xem xét một số khái niệm cơ bản về CSS 3D.

Để có được bất kỳ loại phép chiếu phối cảnh nào theo nghĩa toán học, nhiều khả năng bạn sẽ sử dụng tọa độ đồng nhất. Tôi sẽ không đi sâu vào chi tiết về khái niệm cũng như lý do chúng hoạt động, nhưng bạn có thể coi chúng như các toạ độ 3D với một toạ độ bổ sung thứ tư có tên là w. Toạ độ này phải là 1, trừ phi bạn muốn có độ méo phối cảnh. Chúng ta không cần lo lắng về thông tin chi tiết của w vì chúng tôi sẽ không sử dụng bất kỳ giá trị nào khác ngoài 1. Do đó, từ giờ trở đi, tất cả các điểm đều dựa trên vectơ 4 chiều [x, y, z, w=1] và do đó, ma trận cũng cần phải là 4x4.

Một trường hợp mà bạn có thể thấy CSS sử dụng toạ độ đồng nhất là khi bạn xác định ma trận 4x4 của riêng mình trong một thuộc tính biến đổi bằng hàm matrix3d(). matrix3d nhận 16 đối số (vì ma trận là 4x4), chỉ định lần lượt từng cột. Vì vậy, chúng ta có thể sử dụng hàm này để chỉ định thao tác xoay, bản dịch, v.v. theo cách thủ công. Tuy nhiên, điều mà hàm này cho phép chúng ta thực hiện lại gây rối với toạ độ w đó!

Trước khi có thể sử dụng matrix3d(), chúng ta cần bối cảnh 3D – vì nếu không có bối cảnh 3D, sẽ không có bất kỳ sự biến dạng phối cảnh nào và sẽ không cần có toạ độ đồng nhất. Để tạo bối cảnh 3D, chúng ta cần một vùng chứa có perspective và một số phần tử bên trong mà chúng ta có thể biến đổi trong không gian 3D mới tạo. Ví dụ:

Một đoạn mã CSS làm biến dạng một div bằng cách sử dụng thuộc tính phối cảnh của CSS.

Các phần tử bên trong vùng chứa phối cảnh được công cụ CSS xử lý như sau:

  • Chuyển từng góc (đỉnh) của một phần tử thành các toạ độ đồng nhất [x,y,z,w], tương ứng với vùng chứa phối cảnh.
  • Áp dụng tất cả các phép biến đổi của phần tử dưới dạng ma trận từ phải sang trái.
  • Nếu phần tử phối cảnh có thể cuộn được, hãy áp dụng ma trận cuộn.
  • Áp dụng ma trận phối cảnh.

Ma trận cuộn là bản dịch dọc theo trục y. Nếu chúng ta di chuyển xuống 400px thì tất cả các phần tử cần được di chuyển lên 400px. Ma trận phối cảnh là một ma trận "kéo" các điểm càng gần điểm biến mất càng về phía sau trong không gian 3D. Điều này đạt được cả hai hiệu ứng làm cho mọi thứ trông nhỏ hơn khi chúng ở xa hơn và cũng làm cho chúng "di chuyển chậm hơn" khi được dịch. Vì vậy, nếu một phần tử bị đẩy lùi, bản dịch 400px sẽ khiến phần tử chỉ di chuyển 300px trên màn hình.

Nếu muốn biết tất cả thông tin chi tiết, bạn nên đọc spec về mô hình kết xuất biến đổi của CSS. Tuy nhiên, để tiện cho bài viết này, tôi đã đơn giản hoá thuật toán ở trên.

Hộp của chúng ta nằm trong một vùng chứa phối cảnh có giá trị p cho thuộc tính perspective, và giả sử vùng chứa có thể cuộn và được cuộn xuống theo n pixel.

Ma trận phối cảnh nhân với ma trận cuộn nhân thời gian ma trận biến đổi phần tử bằng 4 x 4 ma trận nhận dạng, trong đó ma trận biến đổi phần tử ở hàng thứ tư sẽ có dấu trừ 1 x 4 ở cột thứ ba nhân với 4 x 4 ma trận đơn vị và n ở hàng thứ 4 nhân với ma trận biến đổi phần tử.

Ma trận đầu tiên là ma trận phối cảnh, ma trận thứ hai là ma trận cuộn. Tóm lại: Nhiệm vụ của ma trận cuộn là làm cho một phần tử di chuyển lên khi chúng ta cuộn xuống, do đó mang dấu âm.

Tuy nhiên, đối với thanh cuộn, chúng ta muốn ngược lại – chúng ta muốn phần tử di chuyển xuống khi cuộn xuống. Sau đây là một thủ thuật: Đảo ngược toạ độ w của các góc trong hộp. Nếu toạ độ w là -1, tất cả các bản dịch sẽ có hiệu lực theo hướng ngược lại. Vậy làm cách nào để làm điều đó? Công cụ CSS đảm nhận việc chuyển đổi các góc của hộp sang toạ độ đồng nhất và đặt w thành 1. Đã đến lúc matrix3d() toả sáng!

.box {
  transform:
    matrix3d(
      1, 0, 0, 0,
      0, 1, 0, 0,
      0, 0, 1, 0,
      0, 0, 0, -1
    );
}

Ma trận này không làm gì khác ngoài phủ định w. Vì vậy, khi công cụ CSS chuyển từng góc thành một vectơ có dạng [x,y,z,1], ma trận sẽ chuyển đổi góc đó thành [x,y,z,-1].

4 nhân 4 ma trận đơn vị với dấu trừ một phần trên p ở hàng thứ tư
 cột thứ ba nhân với bốn x bốn ma trận đơn vị, với dấu trừ n ở cột thứ hai

Tôi đã liệt kê một bước trung gian để hiển thị hiệu quả của ma trận biến đổi phần tử. Nếu bạn không thoải mái với toán học ma trận thì cũng không sao. Thời điểm Eureka là ở dòng cuối cùng, chúng ta kết thúc bằng cách cộng độ lệch cuộn n vào toạ độ y thay vì trừ đi. Thành phần này sẽ được dịch xuống dưới nếu chúng ta cuộn xuống.

Tuy nhiên, nếu chúng ta chỉ đặt ma trận này vào ví dụ của mình, thì phần tử sẽ không hiển thị. Điều này là do thông số kỹ thuật CSS yêu cầu bất kỳ đỉnh nào có w < 0 sẽ chặn hiển thị phần tử. Vì toạ độ z hiện tại là 0 và p là 1, nên w sẽ là -1.

Thật may là chúng ta có thể chọn được giá trị của z! Để chắc chắn kết quả cuối cùng là w=1, chúng ta cần đặt z = -2.

.box {
  transform:
    matrix3d(
      1, 0, 0, 0,
      0, 1, 0, 0,
      0, 0, 1, 0,
      0, 0, 0, -1
    )
    translateZ(-2px);
}

Xin chúc mừng, hộp thư của chúng tôi đã trở lại!

Bước 2: Di chuyển

Hộp của chúng ta hiện đã ở đó và trông giống như dự kiến mà không có bất kỳ biến đổi nào. Hiện tại, vùng chứa phối cảnh không cuộn được, vì vậy chúng ta không thể nhìn thấy vùng chứa đó, nhưng chúng tôi biết rằng phần tử sẽ đi hướng khác khi được cuộn. Vậy hãy làm cho vùng chứa cuộn, chúng ta phải không? Chúng ta chỉ cần thêm một phần tử dấu cách chiếm dung lượng:

<div class="container">
    <div class="box"></div>
    <span class="spacer"></span>
</div>

<style>
/* … all the styles from the previous example … */
.container {
    overflow: scroll;
}
.spacer {
    display: block;
    height: 500px;
}
</style>

Và giờ hãy cuộn hộp! Hộp màu đỏ sẽ di chuyển xuống.

Bước 3: Đặt kích thước

Chúng ta có một phần tử di chuyển xuống khi trang cuộn xuống. Đó thực sự là một vấn đề khó khăn. Bây giờ, chúng ta cần tạo kiểu cho thanh cuộn trông giống như một thanh cuộn và tăng tính tương tác hơn một chút.

Thanh cuộn thường bao gồm một "thumb" và một "bản nhạc", trong khi không phải lúc nào bản nhạc cũng hiển thị. Chiều cao của ngón cái tỷ lệ thuận trực tiếp với lượng nội dung hiển thị.

<script>
    const scroller = document.querySelector('.container');
    const thumb = document.querySelector('.box');
    const scrollerHeight = scroller.getBoundingClientRect().height;
    thumb.style.height = /* ??? */;
</script>

scrollerHeight là chiều cao của phần tử có thể cuộn, còn scroller.scrollHeight là tổng chiều cao của nội dung có thể cuộn. scrollerHeight/scroller.scrollHeight là phần nội dung có thể nhìn thấy. Tỷ lệ không gian dọc mà ngón cái bao phủ phải bằng với tỷ lệ nội dung hiển thị:

ngón tay cái chấm chiều cao dấu chấm trên cuộnerHeight bằng chiều cao cuộn qua cuộn chấm chiều cao cuộn nếu và chỉ khi chiều cao dấu chấm kiểu ngón cái bằng chiều cao cuộn nhân chiều cao cuộn cuộn cao hơn chiều cao cuộn chấm.
<script>
    // …
    thumb.style.height =
    scrollerHeight * scrollerHeight / scroller.scrollHeight + 'px';
    // Accommodate for native scrollbars
    thumb.style.right =
    (scroller.clientWidth - scroller.getBoundingClientRect().width) + 'px';
</script>

Kích thước của ngón tay cái trông đẹp, nhưng di chuyển quá nhanh. Đây là nơi chúng ta có thể lấy kỹ thuật từ trình cuộn thị sai. Nếu chúng ta di chuyển phần tử trở lại xa hơn, phần tử sẽ di chuyển chậm hơn trong khi cuộn. Chúng tôi có thể điều chỉnh kích thước bằng cách mở rộng quy mô. Nhưng chính xác thì chúng ta nên đẩy lùi đến mức nào? Hãy cùng thử một số phép toán nhé! Đây là lần cuối cùng, tôi hứa.

Thông tin quan trọng là chúng ta muốn cạnh dưới của ngón cái khớp với cạnh dưới của phần tử có thể cuộn khi cuộn xuống. Nói cách khác: Nếu đã cuộn scroller.scrollHeight - scroller.height pixel, chúng ta muốn scroller.height - thumb.height dịch ngón cái. Đối với mỗi pixel của trình cuộn, chúng ta muốn ngón tay cái của mình di chuyển một phần nhỏ pixel:

Hệ số bằng chiều cao của điểm cuộn trừ chiều cao dấu chấm cuộn trên chiều cao của dấu chấm cuộn trừ đi chiều cao của dấu chấm cuộn.

Đó là hệ số tỷ lệ của chúng tôi. Bây giờ, chúng ta cần chuyển đổi hệ số tỷ lệ thành bản dịch dọc theo trục z, điều mà chúng ta đã thực hiện trong bài viết cuộn trên thị sai. Theo mục liên quan trong quy cách: Hệ số tỷ lệ bằng p/(p − z). Chúng ta có thể giải phương trình này cho z để tính toán số lượng chúng ta cần dịch ngón cái dọc theo trục z. Tuy nhiên, hãy lưu ý rằng do các thủ đoạn lừa đảo toạ độ, nên chúng ta cần dịch thêm một -2px dọc theo z. Ngoài ra, xin lưu ý rằng các phép biến đổi của một phần tử được áp dụng từ phải sang trái, nghĩa là tất cả các bản dịch trước ma trận đặc biệt sẽ không bị đảo ngược, tuy nhiên, mọi bản dịch sau ma trận đặc biệt sẽ xảy ra! Hãy hệ thống hoá việc này!

<script>
    // ... code from above...
    const factor =
    (scrollerHeight - thumbHeight)/(scroller.scrollHeight - scrollerHeight);
    thumb.style.transform = `
    matrix3d(
        1, 0, 0, 0,
        0, 1, 0, 0,
        0, 0, 1, 0,
        0, 0, 0, -1
    )
    scale(${1/factor})
    translateZ(${1 - 1/factor}px)
    translateZ(-2px)
    `;
</script>

Chúng tôi có thanh cuộn! Và đó chỉ là một phần tử DOM mà chúng tôi có thể tạo kiểu theo ý muốn. Về mặt hỗ trợ tiếp cận, một điều quan trọng cần làm là khiến cho ngón tay cái phản hồi với thao tác nhấp và kéo, vì nhiều người dùng đã quen tương tác với thanh cuộn theo cách đó. Tôi sẽ không giải thích chi tiết cho phần đó vì sao không đăng bài này lâu hơn nữa. Hãy xem mã thư viện để biết chi tiết nếu bạn muốn biết cách thực hiện.

Còn iOS thì sao?

À, người bạn cũ của tôi trên iOS Safari. Giống như việc cuộn thị sai, chúng ta sẽ gặp vấn đề ở đây. Vì chúng ta đang cuộn trên một phần tử, nên chúng ta cần chỉ định -webkit-overflow-scrolling: touch, nhưng điều đó sẽ làm phẳng 3D và toàn bộ hiệu ứng cuộn sẽ ngừng hoạt động. Chúng tôi đã giải quyết vấn đề này trong trình cuộn thị sai bằng cách phát hiện iOS Safari và dựa vào position: sticky làm giải pháp và chúng ta sẽ làm y như vậy ở đây. Hãy xem bài viết về thị sai để làm mới bộ nhớ của bạn.

Còn thanh cuộn của trình duyệt thì sao?

Trên một số hệ thống, chúng ta sẽ phải xử lý thanh cuộn cố định gốc. Trước đây, bạn không thể ẩn thanh cuộn (ngoại trừ với một bộ chọn giả không chuẩn). Vì vậy, để che giấu nó, chúng ta phải dùng đến vài vụ tin tặc (không có toán học). Chúng ta gói phần tử cuộn trong một vùng chứa bằng overflow-x: hidden và làm cho phần tử cuộn rộng hơn vùng chứa. Thanh cuộn gốc của trình duyệt hiện đã nằm ngoài khung hiển thị.

Vây

Kết hợp tất cả lại với nhau, giờ đây chúng ta có thể tạo một thanh cuộn tuỳ chỉnh hoàn hảo về khung hình – như thanh trong bản minh hoạ về con mèo Nyan của chúng tôi.

Nếu không nhìn thấy mèo Nyan, tức là bạn đang gặp phải lỗi mà chúng tôi tìm thấy và báo cáo khi xây dựng bản minh hoạ này (nhấp vào ngón cái để làm mèo Nyan xuất hiện). Chrome thực sự hiệu quả trong việc tránh các công việc không cần thiết như tô hoặc tạo ảnh động cho những thứ nằm ngoài màn hình. Tin xấu là các trò chơi khăm của chúng tôi khiến Chrome nghĩ rằng ảnh GIF mèo Nyan thực sự nằm ngoài màn hình. Hy vọng vấn đề này sẽ sớm được khắc phục.

Vậy là xong. tốn rất nhiều công sức. Cảm ơn bạn đã đọc toàn bộ nội dung. Đây là một số thủ thuật thực sự để làm cho tính năng này hoạt động và có thể hiếm khi đáng để thử, ngoại trừ khi thanh cuộn tuỳ chỉnh là một phần thiết yếu của trải nghiệm. Nhưng bạn rất vui khi biết rằng điều đó là có thể, không? Việc thanh cuộn tuỳ chỉnh khó thực hiện cho thấy rằng CSS vẫn còn việc cần làm. Nhưng đừng lo lắng! Trong tương lai, AnimationWorklet của Houdini sẽ tạo ra các hiệu ứng liên kết cuộn hoàn hảo với khung hình như thế này dễ dàng hơn nhiều.