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 được tạo kiểu (tôi đang tìm hiểu về bộ chọn ngày). Bạn có thể sử dụng JavaScript để tạo riêng, nhưng cách này tốn kém, độ trung thực thấp và có thể bị giật. Trong bài viết này, chúng ta sẽ tận dụng một số ma trận CSS không thông thường để tạo một thanh cuộn tuỳ chỉnh không yêu cầu JavaScript nào 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 điều nhỏ nhặt? Bạn chỉ muốn xem Bản minh hoạ mèo Nyan và tải thư viện? Bạn có thể tìm thấy mã của bản minh hoạ trong kho lưu trữ GitHub của chúng tôi.

LAM;WRA (Dài và mang tính toán học; sẽ đọc dù sao đi nữa)

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? Ứng dụng này rất tốt, đáng để bạn dành thời gian tìm hiểu. 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ư trong ảnh động, chúng ta đã đạt được hiệu ứng thị sai bằng cách đẩy các phần tử "lùi" 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 trên 100px. Điều đó áp dụng cho tất cả phần tử, ngay cả những phần tử "ở xa hơn". Nhưng chúng ở xa máy ảnh hơn, nên chuyển động được quan sát trên màn hình sẽ nhỏ hơn 100px, 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ẽ khiến phần tử đó trông nhỏ hơn. Chúng ta sẽ khắc phục điều này bằng cách điều chỉnh tỷ lệ phần tử đó trở lại. Chúng ta đã tìm ra toán học chính xác khi tạo trình cuộn hiệu ứng thị sai, vì vậy, tôi sẽ không lặp lại tất cả thông tin 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ôi chắc chắn là không. Thanh cuộn là chỉ báo cho biết mức độ nội dung hiện có đang hiển thị và mức độ tiến trình mà bạn đã thực hiện với tư cách là người đọc. Nếu bạn cuộn xuống, thanh cuộn cũng sẽ cuộn xuống để cho biết 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 bị ẩn. Nếu nội dung có chiều cao gấp đôi chiều cao của khung nhìn, thì thanh cuộn sẽ lấp đầy ½ chiều cao của khung nhìn. Nội dung có chiều cao gấp 3 lần chiều cao của khung nhìn sẽ điều chỉnh thanh cuộn thành ⅓ của khung nhìn, v.v. Bạn sẽ thấy mẫu này. Thay vì cuộn, bạn cũng có thể nhấp và kéo thanh cuộn để di chuyển nhanh hơn trên trang web. Đó 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 cùng chiến đấu từng trận một.

Bước 1: Đặt chế độ đảo ngược

Được rồi, 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 phép biến đổi CSS 3D như đã nêu trong bài viết về hiệu ứng cuộn theo hiệu ứng thị giác. Chúng ta cũng có thể đảo ngược hướng 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ề 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 hình chiếu phối cảnh nào theo nghĩa toán học, rất có thể bạn sẽ sử dụng toạ độ đồ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 phải lo lắng về thông tin chi tiết của w vì chúng ta 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 là vectơ 4 chiều [x, y, z, w=1] và do đó, ma trận cũng cần phải có kích thước 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 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 từng cột theo thứ 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 có ngữ cảnh 3D – vì nếu không có ngữ cảnh 3D, sẽ không có bất kỳ sự méo hình phối cảnh nào và không cần đến 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 méo 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 đổi mỗi góc (đỉnh) của một phần tử thành 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, 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 cuộn xuống 400px, 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 lại gần điểm vanishing (điểm biến mất) hơn khi các điểm đó ở xa hơn trong không gian 3D. Điều này mang lại cả hai hiệu ứng là làm cho các đối tượng trông nhỏ hơn khi ở xa hơn và cũng làm cho các đối tượ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, thì việc 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 quy cách về mô hình kết xuất biến đổi của CSS. Tuy nhiên, để phù hợp với 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 bên trong vùng chứa phối cảnh có giá trị p cho thuộc tính perspective. Giả sử vùng chứa này có thể cuộn và được cuộn xuống 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ử của mình di chuyển xuống khi chúng ta cuộn xuống. Dưới đây là một thủ thuật mà chúng ta có thể áp dụng: Đảo ngược toạ độ w của các góc trong hộp. Nếu toạ độ w là -1, thì tất cả các phép dịch sẽ có hiệu lực theo hướng ngược lại. Vậy chúng ta làm như thế nào? Công cụ CSS sẽ chuyển đổi các góc của hộp thành hệ 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 sẽ không làm gì khác ngoài việc 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].

Ma trận nhận dạng 4x4 có giá trị trừ 1 trên p ở hàng thứ tư, cột thứ ba nhân với ma trận nhận dạng 4x4 có giá trị trừ n ở hàng thứ hai, cột thứ tư nhân với ma trận nhận dạng 4x4 có giá trị trừ 1 ở hàng thứ tư, cột thứ tư nhân với vectơ 4 chiều x, y, z, 1 bằng ma trận nhận dạng 4x4 có giá trị trừ 1 trên p ở hàng thứ tư, cột thứ ba, trừ n ở hàng thứ hai, cột thứ tư và trừ 1 ở hàng thứ tư, cột thứ tư bằng vectơ 4 chiều x, y cộng với n, z, trừ z trên p trừ 1.

Tôi đã liệt kê một bước trung gian để hiển thị hiệu ứng của ma trận biến đổi phần tử. Nếu bạn không quen với toán học ma trận, thì điều đó không sao. Khoảnh khắc Eureka là ở dòng cuối cùng, chúng ta sẽ thêm độ dời cuộn n vào toạ độ y thay vì trừ đi độ dời đó. Phần tử sẽ được dịch xuống 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ụ, thì phần tử này sẽ không hiển thị. Điều này là do quy cách CSS yêu cầu mọi đỉnh có w < 0 sẽ chặn việc hiển thị phần tử. Và vì toạ độ z hiện là 0 và p là 1, nên w sẽ là -1.

May mắn thay, chúng ta có thể chọn giá trị của z! Để đảm bảo 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);
}

Và thật bất ngờ, hộp của chúng ta đã trở lại!

Bước 2: Di chuyển

Bây giờ, hộp của chúng ta đã xuất hiện và trông giống như khi không có bất kỳ phép biến đổi nào. Hiện tại, vùng chứa phối cảnh không thể cuộn được nên chúng ta không thể thấy vùng chứa này, nhưng chúng ta biết rằng phần tử của chúng ta sẽ đi theo hướng khác khi cuộn. Vậy hãy cùng cuộn vùng chứa nhé. 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 đỏ di chuyển xuống.

Bước 3: Đặt kích thước cho hình ảnh

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

Thanh cuộn thường bao gồm một "con trỏ" và một "dải", trong khi dải không phải lúc nào cũng hiển thị. Chiều cao của hình thu nhỏ tỷ lệ thuận 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 hiển thị. Tỷ lệ không gian dọc mà ngón tay cái che phải bằng tỷ lệ nội dung hiển thị:

chiều cao dấu chấm của kiểu dấu chấm ngón tay cái trên scrollerHeight bằng chiều cao thanh cuộn trên chiều cao cuộn dấu chấm thanh cuộn nếu và chỉ khi chiều cao dấu chấm của kiểu dấu chấm ngón tay cái bằng chiều cao thanh cuộn nhân với chiều cao thanh cuộn trên chiều cao cuộn dấu chấm thanh cuộn.
<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ừ thanh cuộn hiệu ứng 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 giá trị này trở lại bao nhiêu? Hãy cùng làm một số phép tí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 tay cái căn chỉnh với cạnh dưới của phần tử có thể cuộn khi cuộn xuống hết. Nói cách khác: Nếu đã cuộn scroller.scrollHeight - scroller.height pixel, chúng ta muốn ngón tay cái được dịch bởi scroller.height - thumb.height. Đối với mỗi pixel của thanh cuộn, chúng ta muốn ngón tay cái di chuyển một phần của pixel:

Hệ số bằng chiều cao chấm của cuộn 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 ta. Bây giờ, chúng ta cần chuyển đổi hệ số tỷ lệ thành một phép dịch dọc theo trục z, như chúng ta đã làm trong bài viết về hiệu ứng cuộn song song. Theo phần có liên quan trong thông số kỹ thuật: 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ìm ra lượng cần dịch ngón tay 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, hãy 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 phép dịch trước ma trận đặc biệt của chúng ta sẽ không bị đảo ngược, tuy nhiên, tất cả các phép dịch sau ma trận đặc biệt của chúng ta sẽ bị đảo ngược! Hãy cùng mã hoá điều 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 ta có một thanh cuộn! Và đó chỉ là một phần tử DOM mà chúng ta có thể tạo kiểu theo ý muốn. Một điều quan trọng cần làm về khả năng hỗ trợ tiếp cận là làm cho ngón tay cái phản hồ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 thông tin chi tiết nếu bạn muốn xem cách thực hiện.

Còn iOS thì sao?

À, người bạn cũ của tôi trên iOS Safari. Cũng như khi cuộn theo hiệu ứng thị sai, chúng ta gặp phải một vấn đề ở đây. Vì đ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 cho hiệu ứng 3D bị làm phẳng và toàn bộ hiệu ứng cuộn của chúng ta sẽ ngừng hoạt động. Chúng tôi đã giải quyết vấn đề này trong thanh cuộn hiệu ứng thị giác 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 chính xác như vậy ở đây. Hãy xem bài viết về hiệu ứng thị sai để làm mới trí 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 gốc, cố định. 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 không 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 rất giỏi trong việc tránh những công việc không cần thiết như vẽ hoặc tạo ảnh động cho những thứ nằm ngoài màn hình. Tin xấu là những trò gian lận về ma trận của chúng ta khiến Chrome nghĩ rằng ảnh gif con 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. Đó là một công việc rất lớn. Tôi rất cảm ơn bạn đã đọc toàn bộ bài viết. Đây là một số thủ thuật thực sự để làm cho thanh cuộn này hoạt động và có thể hiếm khi đáng để nỗ lực, 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ẽ giúp bạn tạo hiệu ứng cuộn liên kết hoàn hảo như thế này dễ dàng hơn nhiều.