Tạo Masonry layout với grid & vanilla Js
Bài đăng này đã không được cập nhật trong 4 năm
Masonry layout thì khá là nổi tiếng rồi, mình không nhắc lại nữa, ví dụ điển hình là pinterest
Trong article này mình sẽ demo một giải pháp đơn giản và ngắn gọn nhất để tạo layout dạng này, chỉ với một chút css và javascript
Starting Point
1. HTML
<div class="sun-masonry">
<img src="https://picsum.photos/id/99/200/300" />
<img src="https://picsum.photos/id/55/300/400" />
<img src="https://picsum.photos/id/11/500/300" />
<img src="https://picsum.photos/id/199/200/250" />
...
</div>
2. SCSS
$w: Min(10em, 100%);
$s: 10px;
* { margin: 0 }
html { background: #555; padding: 30px 0; }
.sun-masonry {
display: grid;
grid-template-columns: repeat(auto-fit, $w);
justify-content: center;
grid-gap: $s;
> * { width: $w }
}
RESULT
*Ở đây chỉ có 1 chú ý nhỏ là mình dùng Scss và sử dụng function Min của scss, nếu dùng min thì bị conflig với function của css. Tức là grid-template-columns: repeat(auto-fit, $w)
tương đương với tạo một layout auto column, không giới hạn số lượng nếu điều kiện thoả mãn, và điều kiện ở đây chính là $w: Min(10em, 100%)
, mỗi column có width nhỏ nhất là 10em ~ 160px.
Giải pháp đơn giản là margin top âm cho các items cùng column để lấp đi các khoảng trống hiện tại. Và công thức để margin top chính xác sẽ xoay quanh thuộc tính getBoundingClientRect()
Phương thức Element.getBoundingClientRect() returns size và vị trí của element đó, vị trí là vị trí tương đối của element đối với viewport nhé .
Ý tưởng là dựa vào getBoundingClientRect() ta sẽ lấy được khoảng cách tính từ bottom của item đến top của viewport, chính là giá trị "bottom" ở ảnh trên. Như vậy ở column đầu tiên, item thứ nhất giả sử lấy dc kết quả là "a", item thứ 2 nằm ngay dưới item thứ nhất có kết quả là "b" thì item thứ 2 sẽ có khoảng margin-top âm 1 đoạn là c = b - a. Và nếu cứ tiếp tục làm như vậy thì layout của mình sẽ được như ý
Apply lần lượt cho từng item:
Collect tất cả items
let grids = document.getElementsByClassName('sun-masonry');
grids.gap = parseFloat(getComputedStyle(grids[0]).gridRowGap)
grids.items = [...grids[0].childNodes].filter(c => c.nodeType === 1)
grids.column = 0
- Ta lấy giá trị
gap
, tức là khoảng cách giữa các items, phục vụ cho việc tính toán, h không phải làb - a
nữa mà làb - a + gap
- Array items là tất cả childNodes mà có nodeType = 1, nếu không, giả sử trong layout của bạn có 1 đoạn text, thì nó sẽ return và mảng các text này nữa, nodeType = 1 đảm bảo rằng nó là một elementNode, và không lấy thêm child của item nữa.
- column: số lượng column hiện tại, intiatal đặt là 0, sau này layout render ra ta sẽ tính và gán lại giá trị cho nó, bởi vì số lượng column render ra còn tuỳ thuộc vào kích thước màn hình nữa.
Tính toán
- Lấy số lượng column được render ra:
console.log(getComputedStyle(grids).gridTemplateColumns)
// Output: "160px 160px 160px 160px 160px 160px"
// Tương đương với layout đang render ra 6 cột, mỗi cột có width là 160px
Chỉ có layout dạng grid mới có giá trị của gridTemplateColumns
, và nó sẽ trả ra cho bạn kiểu sau đây: XXpx XXpx XXpx XXpx ...
, nếu bạn đã quen làm với grid layout rồi thì "XX" tương ứng với width của column ở vị trí đó, và các column cách nhau 1 dấu cách, từ đây ta có thể hoàn toàn đếm được số lượng column đã được render ra:
let column = getComputedStyle(grids[0]).gridTemplateColumns.split(' ').length;
Và ta gán lại giá trị của grid.column
đang set là 0 ở trên thành giá trị mới
grids.column = column;
Ta sẽ lấy giá trị a getBoundingClientRect().bottom
của các items, và giá trị b getBoundingClientRect().top
của các items ở row thứ 2, do là row thứ nhất thì các items không cần margin gì cả vì đã sát lề top rồi.
Chính vì vậy mà ta sẽ slice arraygrids.items
của chúng ta với giá trị slice là row đầu tiên.
Slice sẽ return ra array mới và bỏ đi n items đầu tiên (bằng với giá trị grids.column
), ta sẽ loop ở trên nó để lấy giá trị getBoundingClientRect().bottom
của từng phần tử.
grids.items.slice(column).forEach((n, i) => {
// Với c là các items sau khi grid items đã slice rồi
// i là index, index được sử dụng ở mảng grids.items gốc, nghĩa là full items, chưa bị slice
let prevItem = grids.items[i].getBoundingClientRect().bottom; /* bottom edge of item above */
let nextItem = n.getBoundingClientRect().top; /* top edge of current item */
console.log(prevItem)
console.log(nextItem)
let margin = prevItem - nextItem + grids.gap
console.log(margin)
// Apply style cho items thứ grids.item[column + 1] trở đi
n.style.marginTop = `${margin}px`
})
Ồ, các giá trị tính toán đều ra 0, nhưng nếu copy và paste nguyên function vào console, nó sẽ console ra các giá trị !== 0, tại sao vậy nhỉ.
Đó là do ảnh chưa load xong, dom chưa render xong, chính vì vậy mà các tính toán của mình nó sẽ không chính xác nữa, mình phải đưa nó vào event load
để đảm bảo content đã load xong, vị trí đã xác định
addEventListener(
"load",
function() {
let column = getComputedStyle(grids[0]).gridTemplateColumns.split(' ').length
grids.column = column
grids.items.slice(column).forEach((n, i) => {
....
})
},
false
);
Và đây là kết quả:
Refactor
Có 3 vấn đề cần phải giải quyết, đó là:
- Có nhiều hơn 1 grid layout
- Khi resize thì chúng ta cần update lại tính toán của mình.
- Reset lại toàn bộ các style margin-top mà ta đã add vào.
- Khi resize mà không thay đổi số column thì cũng không cần tính toán lại
- Khi chỉ có 1 column thì không cần tính toán
1. Lấy tất cả layout
let grids = [...document.querySelectorAll('.sun-masonry')];
2. Check nếu có tồn tại ít nhất một ".sun-masory" selector
if(grids.length) {
grids = grids.map(grid => ({
container: grid, // định danh riêng cho từng selector
gap: parseFloat(getComputedStyle(grid).gridRowGap),
items: [...grid.childNodes].filter(c => c.nodeType === 1),
column: 0
}));
....
3. Update lại function layout theo các thay đổi trên
function layout() {
grids.forEach(grid => {
let column = getComputedStyle(grid.container).gridTemplateColumns.split(' ').length
if(grid.column !== column) { // check nếu resize nhưng số lượng column không thay đổi
grid.column = column
// Với c là các items sau khi grid items đã slice rồi
// i là index, index được sử dụng ở mảng grids.items gốc, nghĩa là full items, chưa bị slice
grid.items.forEach(c => c.style.removeProperty('margin-top')) // reset lại toàn bộ margin top đã add vào nếu có
if(grid.column > 1) { // check nếu chỉ có 1 column thì không phải tính toán nữa
grid.items.slice(column).forEach((n, i) => {
let prevItem = grid.items[i].getBoundingClientRect().bottom;
let nextItem = n.getBoundingClientRect().top;
let margin = prevItem - nextItem + grid.gap
n.style.marginTop = `${margin}px`
})
}
}
})
}
Cuối cùng là add code vào event "load" và "resize"
addEventListener('load', e => {
layout();
addEventListener('resize', layout, false)
}, false);
Result
Làm màu thêm với một chút style
Resize để nhận thấy sự thay đổi nhé!
Bài viết được tham khảo từ css-tricks, với chủ đề chính là demo khả năng tạo layout masonry với chỉ css, tuy nhiên, tính năng này chưa chính thức, và chỉ mới hoạt động trên firefox mà thôi. Chúng ta cùng đón chờ xem, được thì đỡ phải tính toán mệt đầu
All rights reserved