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, điều này chủ yếu là do thanh cuộn là một trong những bit còn lại trên web và hầu như không cách điệu được (bạn có thể thấy thanh cuộn này, bộ chọn ngày). Bạn có thể sử dụng JavaScript để tự tạo mã, nhưng cách này tốn kém, độ chân thực thấp và có thể tạo cảm giác chậm. Trong bài viết này, chúng tôi sẽ tận dụng một số ma trận CSS độc đáo để tạo trình cuộn tuỳ chỉnh mà không yêu cầu bất kỳ 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 chuyện nhỏ nhen? Bạn chỉ muốn xem bản minh hoạ về 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.

LAM;WRA (Dài và 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? Việc này rất hay và xứng đáng với thời gian của bạn!). 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ử sẽ di chuyển chậm hơn so với tốc độ cuộn thực tế của chúng tôi.

Recap

Hãy bắt đầu với bản tóm tắt về 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ử "về phía sau" trong không gian 3D, dọc theo trục Z. Cuộn tài liệu là một bản dịch hiệu quả dọc theo trục Y. Vì vậy, nếu chúng ta cuộn xuống, giả sử 100px, mọi phần tử sẽ được dịch lên trên 100px. Điều đó áp dụng cho tất cả các phần tử, kể cả những phần tử "xung lại". Tuy nhiên, các phần tử ở xa máy ảnh nên chuyển động trên màn hình quan sát được của các phần tử đó sẽ nhỏ hơn 100px, mang lại 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ử đó trông nhỏ hơn. Chúng tôi sẽ sửa lại bằng cách điều chỉnh tỷ lệ phần tử sao lưu. Chúng tôi đã tìm ra toán học chính xác khi xây dựng công cụ cuộn thị sai, vì vậy, tôi sẽ không lặp lại mọi chi tiết.

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

Thanh cuộn. Đó là những gì chúng tôi sẽ xây dựng. Nhưng đã bao giờ bạn thực sự nghĩ về những gì chúng làm chưa? Chắc chắn là tôi không có. Thanh cuộn cho biết lượng nội dung có sẵn đang hiển thị và tiến trình đạt được của bạn (với tư cách là người đọc). Nếu bạn cuộn xuống, thanh cuộn cũng sẽ xuất hiện để cho biết bạn đang di chuyển xuống cuối. Nếu tất cả nội dung vừa với khung nhìn thì 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. Đây là hành vi đáng ngạc nhiên của một yếu tố không dễ thấy như vậy. Hãy chiến đấu từng trận một.

Bước 1: Đảo ngược lại mã nhận dạng

Đượ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ề chức năng cuộn thị sai. Chúng ta cũng có thể đảo ngược chiều hướng chứ? Hoá ra chúng ta có thể và đó là cách để xây dựng một thanh cuộn tuỳ chỉnh, hoàn hảo cho khung hình. Để hiểu cách hoạt động của quy trình này, trước tiên chúng ta cần tìm hiểu một số kiến thức 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, rất có thể bạn sẽ sử dụng Toạ độ đồng nhất. Tôi không đi sâu vào khái niệm và lý do khiến chúng hoạt động, nhưng bạn có thể xem chúng như toạ độ 3D với toạ độ thứ tư bổ sung được gọi là w. Toạ độ này phải là 1, trừ phi bạn muốn hiện tượng méo góc nhìn. Chúng ta không cần lo lắng về chi tiết của hàm 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 dựa trên vectơ 4 chiều [x, y, z, w=1] và do đó, các ma trận cũng cần phải 4x4.

Một trường hợp mà bạn có thể thấy rằng 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 cột sau đối số. Vì vậy, chúng ta có thể sử dụng chức năng này để chỉ định thủ công chế độ xoay, bản dịch, v.v. Tuy nhiên, chức năng này cũng cho phép chúng ta thực hiện một công việc khác với toạ độ w đó!

Trước khi có thể sử dụng matrix3d(), chúng ta cần ngữ cảnh 3D – vì nếu không có ngữ cảnh 3D thì sẽ không có bất kỳ sự biến dạng phối cảnh nào và không cần toạ độ đồng nhất. Để tạo ngữ cảnh 3D, chúng ta cần vùng chứa có perspective và một số phần tử bên trong 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 div bằng thuộc tính phối cảnh của CSS.

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

  • Chuyển từng góc (đỉnh) của một phần tử thành toạ độ đồng nhất [x,y,z,w], so 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" điểm đến gần điểm biến mất hơn nữa trong không gian 3D. Điều này đạt được cả hai tác động của việc làm cho mọi thứ xuất hiện nhỏ hơn khi chúng ở xa hơn, đồng thời làm cho chúng "di chuyển chậm hơn" trong quá trình dịch. Vì vậy, nếu một phần tử bị đẩy lùi, thì 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 hiển thị biến đổi của CSS. Tuy nhiên, trong 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, giả sử vùng chứa có thể cuộn và cuộn xuống n pixel.

Ma trận phối cảnh nhân ma trận cuộn nhân ma trận biến đổi phần tử
 bằng ma trận biến đổi 4x4 với âm 1 trên p ở hàng thứ tư
 cột thứ ba nhân 4x4 ma trận nhận dạng với trừ n ở
 hàng thứ hai
 cột thứ 4 nhân 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 di chuyển xuống, do đó tạo ra dấu âm.

Tuy nhiên, đối với thanh cuộn, chúng ta muốn thành phần ngược – chúng ta muốn phần tử di chuyển xuống khi cuộn xuống. Đây là nơi chúng ta có thể sử dụng một mẹo: Đả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 bản dịch sẽ có hiệu lực theo hướng ngược lại. Vậy chúng ta làm điều đó bằng cách nào? Công cụ CSS sẽ xử lý việc chuyển đổi các góc của hộp thành 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 vectơ đó thành [x,y,z,-1].

Ma trận đồng nhất 4 x 4 nhân với âm 1 trên p ở cột thứ 3 nhân 4 x 4 ma trận đồng nhất với âm n ở cột thứ 4 nhân 4 x 4 ma trận đơn vị với trừ 1 ở cột thứ 4 nhân với vectơ 4 chiều x, y, z, 1 bằng 4 với 4 ma trận đơn chiều với trừ 1 trên p ở cột thứ 4, trừ 4 trong cột thứ 4 trừ n trong cột thứ 4 trừ n trong cột thứ 4 trừ x 1 ở cột thứ tư

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

Thật may là chúng ta có thể chọn giá trị của z! Để đảm bảo kết quả 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);
}

Thế là xong, hộp đựng đồ của chúng ta đã trở lại!

Bước 2: Di chuyển

Giờ đây, hộp của chúng ta đã có mặt và trông giống như bình thường, 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, vì vậy chúng ta không thể nhìn thấy nó, nhưng chúng tôi biết rằng phần tử của chúng ta sẽ đi theo hướng khác khi cuộn. Giờ chúng ta hãy cuộn vùng chứa nhé? Chúng ta chỉ cần thêm một phần tử 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>

Bây 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 cho quảng cáo

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 phần khó khăn. Bây giờ, chúng ta cần tạo kiểu cho thành phần 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 "ngón tay cái" và một "đường đi", trong khi đường đi không phải lúc nào cũng hiển thị. Chiều cao của ngón tay cái tỷ lệ thuận trực tiếp với mức độ hiển thị nội dung.

<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, trong khi 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 theo chiều dọc mà ngón cái che phủ phải bằng với tỷ lệ nội dung hiển thị:

ngón tay cái chấm kiểu dấu chấm chiều cao trên chiều cao cuộnerHeight bằng chiều cao của trình cuộn trên chiều cao cuộn của trình cuộn nếu và chỉ khi chiều cao của ngón tay cái chấm kiểu dấu chấm
 bằng chiều cao của trình cuộn trên chiều cao của trình cuộn trên chiều cao cuộn của trình 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 cái trông rất đẹp, nhưng nó di chuyển quá nhanh. Đây là nơi chúng ta có thể lấy kỹ thuật của mình từ thanh cuộn thị sai. Nếu chúng ta di chuyển phần tử lùi hơn nữa, phần tử đó sẽ di chuyển chậm hơn trong khi cuộn. Chúng ta có thể sửa kích thước bằng cách tăng kích thước đó. Nhưng chính xác thì chúng ta nên phản hồi điều đó đến mức nào? Hãy làm một số việc – bạn đoán được – toán học nhé! Tôi hứa, đây là lần cuối cùng.

Thông tin quan trọng là chúng ta phải sao cho cạnh dưới của ngón tay cái thẳng hàng 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ủa mình được scroller.height - thumb.height dịch. Đố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 nhỏ pixel:

Hệ số bằng chiều cao dấu chấm của thanh cuộn trừ chiều cao dấu chấm ngón tay cái trên chiều cao của thanh cuộn chấm trừ chiều cao dấu chấm của thanh 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 một bản dịch dọc theo trục z, như chúng ta đã thực hiện trong bài viết cuộn thị sai. Theo phần 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 chúng ta cần dịch ngón tay cái theo trục z bao nhiêu. Tuy nhiên, hãy lưu ý rằng do các trò gian lận toạ độ của chúng ta, chúng ta cần dịch thêm một -2px khác cùng với 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 của chúng ta sẽ không bị đảo ngược, tuy nhiên, tất cả các bản dịch sau ma trận đặc biệt của chúng ta sẽ! Hãy cùng hệ thống 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ó thanh cuộn! Và đó chỉ là một phần tử DOM mà chúng ta có thể tạo kiểu theo bất kỳ cách nào mình muốn. Về khả năng hỗ trợ tiếp cận, một điều quan trọng cần làm là 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 đó. Nhằm không khiến bài đăng này dài hơn nữa, tôi sẽ không giải thích chi tiết về phần đó. Hãy xem mã thư viện để biết thông tin chi tiết nếu bạn muốn biết cách thực hiện.

Còn iOS thì sao?

A, người bạn cũ của tôi trên iOS. Cũng như cuộn thị sai, chúng tôi 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 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 công cụ cuộn thị sai bằng cách phát hiện Safari trên iOS và dựa vào position: sticky như một giải pháp. Đồng thời, chúng ta sẽ thực hiện chính xác điều tương tự tại đây. Hãy xem bài viết dưới đây để làm mới bộ nhớ.

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

Trên một số hệ thống, chúng tôi sẽ phải xử lý thanh cuộn gốc cố định. Trước đây, không thể ẩn thanh cuộn (ngoại trừ trình chọn giả không chuẩn). Vì vậy, để che giấu, chúng tôi phải dùng đến một số tin tặc (không dùng 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

Giờ đây, chúng ta có thể tạo một thanh cuộn tuỳ chỉnh hoàn hảo với từng khung hình, giống như thanh cuộn trong bản minh hoạ về mèo Nyan.

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

Vậy là xong. Có rất nhiều việc cần làm. Xin cảm ơn bạn đã đọc toàn bộ bài viết này. Đây là một thủ thuật thực sự để làm được điều này và có thể hiếm khi đáng để bỏ công sức, trừ phi 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 có thể làm được, phải không? Việc thực hiện thanh cuộn tuỳ chỉnh khó đến vậy cho thấy rằng CSS còn nhiều việc cần phải 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 rất nhiều.