使用CSS Grid实现瀑布流布局

发布于 大漠

自从多列布局,Flexbox布局和Grid布局得到浏览器支持之后,就可以使用这些特性来实现瀑布流的布局,但这些布局或多或少都存有一定的缺陷。前两天看到CSS 布局模块Level 3已经进入到 ED(Editor’s Draft)阶段,该规范就是为瀑布流布局而生的,这个模块中介绍了瀑布流布局,并且作为CSS网格容器的附加布局模式。接下来和大家一起来一探其究竟,另外在文章末尾顺便介绍了如何给不支持瀑布流布局的降级以及如何使用CSS Houdini来实现瀑布流布局。感兴趣的同学,请继续往下阅读。

什么是瀑布流布局

瀑布流布局是一种常见的Web布局模式。在一个容器中有很多项目(通常是图像或像文章的摘要),它们依次安排好照行内方向一个一个地放在列中。当它们移到下一行时,项目将移动到第一行较短(高度较低)项目所留下的空隙中。有点类似于我们生活中“砌砖”的方式:

这种布局有点类似于网格布局中自动放置网格项目(Auto-Placement)的布局,但又没有严格遵循该布局模式。

最著名的瀑布流布当属 Pinterest,比如他的搜索结果页面的布局效果:

有时候你会听到有人把这种布局称为“Pinterest布局”。

背后的故事

CSS网格布局其实已经很多年了,只不过受限于浏览器支持的限制,目前使用率并不算太高。至目前为止,CSS网格布局规范已经有多个版本了,Level 1Level 2 以及定义瀑布流布局模式的 Level 3,而且Level 1 和 Level 2是TR阶段,Level 3是ED阶段。

CSS网格布局是CSS的一种布局模式,它具有强大的布局能力,可以很好的控制方框及其内容的大小和位置。在Web布局的众多方式之中,也只有CSS网格布局是二维布局:即在两个维度上都需要对齐内容的布局

尽管许多布局可以用CSS网格来实现,但是Web上的一些布局用CSS网格也是无法限制的,比如瀑布流布局。

@Rachel Andrew曾经也说过,网格布局和瀑布流布局的结构就不有所不同,对于网格而言,他有严格的行和列,但对于瀑布流布局的结构来说,他并没有严格的行和列:

通常我们已经定义了行,但是列的作用更像是一个Flexbox布局,或者多列布局。即使使用多列布局来实现瀑布流布局和真正的瀑布流布局之前也有着关键区别。在多列布局中,项目是按列显示的。通常在瀑布流布局中,你希望它们是按行显示。

@Rachel Andrew个人建议不要把瀑布流布局作为CSS网格规范的一部分。因为瀑布流布局是一个相对专业的布局模式,实际上瀑布流布局根本也不是一个网格,它看上去更类似于Flexbox布局。

我想也是基于这些个原因,W3C规范才将瀑布流布局当作一个独立的规范,就算是要把他和CSS网格规范关联起来,也只能说CSS瀑布流布局规范(CSS Grid Level 3)是CSS网格布局的一个附本。

浏览器兼容性

CSS Grid Layout Level 3模块目前还仅仅是属于W3C规范的ED阶段。正如@Jen所说,

他不是一尘不变的,该规范中定义的一切都有可能会变化。

所以说,接下来的内容很有可能会随着该规范后续的更新有所变动。另外,就目前为止,只有Firefox和Firefox Nightly浏览器支持该规范,而且需要开启相关的配置。在Firefox和Firefox Nightly浏览器的URL地址中输入about:config,并搜索layout.css.grid-template-masonry-value.enabled,将其值设置为true

你可以尝试着在修改完配置的Firefox或Firefox Nightly浏览器中打开@Rachel Andrew在Codepen上提供的瀑布流案例,可以看到像下图这样的效果:

接下来,我们来看如何实现瀑布流布局效果。

我们可以使用成熟的CSS布局方式实现瀑布流布局

其实在《纯CSS实现瀑布流布局》一文中,和大家一起探讨了一些实现瀑布流布局的CSS方案。这几种方案中最接近的方案就是使用CSS的多列布局。

我们使用JavaScript在<body>中动态插入51<div class="item">,并且设置了相应的背景图像:

for (let i = 0; i <= 50; i++) {
    const div = document.createElement("div");
    div.classList.add("item");
    div.style.backgroundImage = `url(https://picsum.photos/500/500?random=${i})`;
    document.body.appendChild(div);
}

.item设置一些基本样式:

.item {
    border-radius: 10px;
    background-size: cover;
    background-position: center;
    height: 10em;
    position: relative;
    display: grid;
    grid-template-rows: 1fr auto;
    margin-bottom: 10px;
    break-inside: avoid;
}

并且给一些.item设置不同的高度:

.item:nth-child(2n) {
    height: 14em;
}
.item:nth-child(3n) {
    height: 18em;
}
.item:nth-child(4n) {
    height: 22em;
}
.item:nth-child(5n) {
    height: 24em;
}
.item:nth-child(6n) {
    height: 30em;
}
.item:nth-child(7n) {
    height: 34em;
}

.item:nth-child(8n) {
    height: 40em;
}

body使用column-countcolumn-gap来设置列数和列与列之间的间距:

body {
    column-count: 4;
    column-gap: 10px;
}

效果如下:

从上面的效果来看,它看起来像瀑布流布局。但是,每外项目的顺序是按列排列的。如果是一个搜索结果页面,期望搜索出来的结果是排列在前面,比如说页面最顶部就能看到搜索的结果,而不是像上面的示例效果,排在前面的都在第一列。

在使用CSS网格模块来构建瀑布流布局时,我原以为 **CSS网格布局中的自动定位(Auto Placement)**特性可以让网格项目自动排列:

规范是这样描述网格自动定位特性

Grid items that aren’t explicitly placed are automatically placed into an unoccupied space in the grid container.

不过这里不详细阐述该特性,如果你对该特性感兴趣,可以阅读下面这几篇文章:

我们在上面的示例基础上稍作调整,使用CSS网格来实现瀑布流布局:

body {
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    grid-auto-flow: dense;
    gap: 10px;
}

.item {
    border-radius: 10px;
    background-size: cover;
    background-position: center;
    height: 10em;
    position: relative;
}

虽然grid-auto-flow: dense;可以实现网格项目自动定位,但它仍然是一个网格布局,无法让网格项目自动往上排列,来填充顶部相邻的网格单元留下的空白:

使用该方案要实现真正的瀑布流布局,仍然离不开JavaScript。使用JavaScript改进的案例可以参阅@Andy Barefoot的《Masonry style layout with CSS Grid》文章中提供的案例:

网格布局的瀑布流特性

上面我们看到的两种CSS实现瀑布流布局的方案可以说不是正宗的。CSS Grid Level 3正在讨论的规范和相关特性才是专门为瀑布流布局服务的。

CSS在grid-template-columnsgrid-template-rows属性上提供了一个新的属性值,即 masonry如果你接触过CSS Grid的话,应该知道,在网格容器上可以使用这两个属性来显式地指定网格的列和行。比如:

.grid-container {
    display: grid;
    grid-template-columns: 300px 1fr 300px;
    grid-template-rows: repeat(2, auto);
    gap: 10px;
}

上面的代码创建了一个两行三列的网格:

如上图所示,隐式的确定了一个网格的列数、行数以及网格的大小,同时也创建了网格线。这些涉及到了网格中的一些术语,为了节约时间,我用一张图来描述这些术语:

也就是说,如果我们要创建一个瀑布流布局,我们就需要在grid-template-columnsgrid-template-rows上显式的设置值为masonry,比如:

body {
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    grid-template-rows: masonry;
}

如果你使用Firefox浏览器查看的话这个示例的话,看到的效果像下面这样:

再来看grid-template-columns上使用masonry

body {
    display: grid;
    grid-template-columns: masonry;
    grid-template-rows: repeat(4, 1fr);
    gap: 10px;
}

效果如下

grid-template-rowsgrid-template-columns取值为masonry两者之间的差异如下图所示:

也可以同时在grid-template-columnsgrid-template-rows设置值为masonry,只不过这个时候grid-template-columns使用的值为none

在CSS Grid布局中,在Grid容器中他有内联轴(Inline Axis)也称为行轴和块轴(Block Axis)也称为列轴:

如果要使用瀑布流布局,那么网格轴(grid-template-columnsgrid-template-rows)至少有一个值是masonry。也就是说,显式在grid-template-columnsgrid-template-rows设置值为masonry时,该轴就变成了瀑布轴(Masonry Axis),另一个轴将是网格轴:

这样就允许我们在网格轴中使用CSS网格的全部功能。比如可以指定网格线,网格轨道大小,在网格轨道中放置网格项目,也可以对网格行或列进行合并等。比如下面这个示例

body {
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    grid-template-rows: masonry;
    gap: 10px;
}

div:nth-of-type(1) {
    grid-column: 1 / 3;
}

div:nth-of-type(2) {
    grid-area: 1 / 3 / 2 / 5;
}

div:nth-of-type(3) {
    grid-column: span 3;
}

masonry-auto-flow

masonry-auto-flow是专门为瀑布流布局定义的属性。该属性的主要使用是 让你在瀑布流布局中可以控制瀑布流布局中的项目流(每个网格项目)。它主要接受的值主要有:

masonry-auto-flow: 	[ pack | next ] || [definite-first | ordered ] 

它的默认值为pack,该属性只能用于瀑布流布局的网格容器上。

首先,可以通过masonry-auto-flow: ordered将导致瀑布流布局中忽略有确定位置的项目,这样就可以使用order修改文档顺序。其次,还可以通过指定masonry-auto-flow指定值为next,来将网格项目依次放置在网格轴上,而不是像上面描述的那样将它们放置在剩余空间最多的轨道上。比如下面这个示例

<!-- HTML -->
<div class="grid-container">
    <div class="grid-item">1</div>
    <div class="grid-item">2</div>
    <div class="grid-item">3</div>
    <div class="grid-item">4</div>
</div>

/* CSS */
.grid-container {
    display: inline-grid;
    grid: masonry / repeat(3, 1fr);
    border: 1px solid;
    masonry-auto-flow: next;
}

效果如下:

justify-tracksalign-tracks

CSS Box Alignment Module Level 3 规范提供了用于Flexbox容器,Grid容器,Flex项目和Grid项目上的对齐属性。在瀑布流布局新增了justify-tracksalign-tracks属性。当瀑布流是在块轴(Column)方向时,justify-tracks有效;当瀑布流是在内联轴(Row)方向时,align-tracks有效。

align-tracks: [normal | <baseline-position> | <content-distribution> | <overflow-position>? <content-position>]#

justify-tracks: [normal | <content-distribution> | <overflow-position>? [ <content-position> | left | right ] ]#

<baseline-position> = [ first | last ]? && baseline
<content-distribution> = space-between | space-around | space-evenly | stretch
<overflow-position> = unsafe | safe
<content-position> = center | start | end | flex-start | flex-end

align-tracksjustify-tracks的默认值都是normal,而且都应该应用在是瀑布流布局的网格容器上。他们的属性值和align-contentjustify-content相同,不同的是align-tracksjusttify-tracks可以接受以逗号(,)分隔的多个值。

先来感受一下,align-contentjustify-content在Flexbox容器和Grid容器中的效果:

这里暂且忽略Flexbox容器上justify-contentalign-content。单独来看网格容器,假设网格轨道整体占据的空间小于网格容器,那么就可以在容器中对齐网格轨道。针对块方向和文本方向的轴线,分别使用 align-content 对齐到块方向的轴线,使用 justify-content 对齐到文本方向的轴线。

有关于网格布局中的盒模型对齐更详细的介绍可以查看MDN上的文档

瀑布流也是网格布局中的一种,那么align-contentjustify-content也可以运用到瀑布流容器上,但不同的是,对于瀑布流轴,这两个属性是不生效的,在瀑布流轴上,分别被align-tracksjustify-tracks替代。比如下面这个示例:

body {
    display: grid;
    grid-template-columns: repeat(4, 160px);
    grid-template-rows: masonry;
    gap: 10px;

    justify-content: var(--justify-content);
    align-content: var(--align-content);
    align-tracks: var(--align-tracks);
}

就上面示例而言,当grid-template-rows设置值为masonry时,内联轴(Row Axis)是对应的瀑布流轴,块轴(Column Axis)是网格轴。在这种情况之下,align-content在瀑布轴上是不生效的,同时被align-tracks替代(这个时候align-tracks相当于align-content),具体效果如下

再来看另外一个场景,就是grid-template-columns设置为masonry,这个时候内联轴(Row Axis)是网格轴,块轴(Column Axis)是瀑布轴。在这种情况之下,justify-content在瀑布轴上不生效,同时被justify-tracks替代,具体效果如下

前面我们提到过,align-tracksjustify-tracks可以同时取多个值。比如下面这个示例,我们有四列的瀑布流网格布局,给align-tracks设置多个值:

body {
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    grid-template-rows: masonry;
    gap: 10px;

    align-tracks: start, center, end, space-between;
}

效果如下

使用JavaScript做降级处理

目前为止,纯CSS实现的瀑布流布局仅得到Firefox或Firefox Nightly浏览器的支持。如果希望在其他浏览器也要实出瀑布流布局的效果,需要一定的JavaScript的脚本。@Ana Tudor 在她的《A Lightweight Masonry Solution》教程中对这方面做过详细的介绍。

我们来看一个简单的示例。在HTML中有一个瀑布流布局的容器,比如:

<!-- HTML -->
<div class="grid__masonry"></div>

使用JavaScript动态往该容器中插入一些网格项目:

for (let i = 0; i <= 50; i++) {
    const div = document.createElement("div");
    div.classList.add("item");
    div.style.backgroundImage = `url(https://picsum.photos/500/500?random=${i})`;
    document.querySelector(".grid__masonry").appendChild(div);
}

可以根据上面介绍的方式,使用CSS实现一个简单的瀑布流布局效果:

.grid__masonry {
    display: grid;
    grid-template-rows: masonry;
    grid-template-columns: repeat(4, 1fr);
    gap: 10px;
}

根据@Ana Tudor 在她的《A Lightweight Masonry Solution》教程提供的示例代码,我们可以使用下面的JavaScript代码,让其他浏览器也实现瀑布流布局效果:

let grids = [...document.querySelectorAll('.grid__masonry')];

if(grids.length && getComputedStyle(grids[0]).gridTemplateRows !== 'masonry') {
    grids = grids.map(grid => ({
        _el: grid, 
        gap: parseFloat(getComputedStyle(grid).gridRowGap), 
        items: [...grid.childNodes].filter(c => c.nodeType === 1 && +getComputedStyle(c).gridColumnEnd !== -1), 
        ncol: 0
    }));

    function layout() {
        grids.forEach(grid => {
            /* get the post relayout number of columns */
            let ncol = getComputedStyle(grid._el).gridTemplateColumns.split(' ').length;

            /* if the number of columns has changed */
            if(grid.ncol !== ncol) {
                /* update number of columns */
                grid.ncol = ncol;

                /* revert to initial positioning, no margin */
                grid.items.forEach(c => c.style.removeProperty('margin-top'));

                /* if we have more than one column */
                if(grid.ncol > 1) {
                    grid.items.slice(ncol).forEach((c, i) => {
                        /* bottom edge of item above */
                        let prev_fin = grid.items[i].getBoundingClientRect().bottom, 

                        /* top edge of current item */
                        curr_ini = c.getBoundingClientRect().top ;
                        
                        c.style.marginTop = `${prev_fin + grid.gap - curr_ini}px`
                    })
                }
            }
        })
    }

    addEventListener('load', e => {
        layout(); /* initial load */
        addEventListener('resize', layout, false) /* on resize */
    }, false);
}

最终的效果如下:

使用CSS Houdini实现瀑布流布局

在不久的将来,我们除了前面提到的一些实现瀑布流布局的方案之外,还可以使用 CSS HoudiniCSS Layout API来定义一个瀑布流布局。

CSS Houdini的CSS Layout API相对来说是一个复杂的技术体系,有关于这方面的详细介绍可以阅读:

@iamvdo在CSS Houdini示例集锦中就向大家展示了一个使用CSS Houdini实现的瀑布流布局

使用CSS Layout API中的registerLayout()定义一个masonry的布局,代码如下:

// masonry.js
registerLayout('masonry', class {
    static get inputProperties() {
        return [ '--padding', '--columns' ];
    }

    async intrinsicSizes() { /* TODO implement :) */ }
    async layout(children, edges, constraints, styleMap) {
        const inlineSize = constraints.fixedInlineSize;

        const padding = parseInt(styleMap.get('--padding').toString());
        const columnValue = styleMap.get('--columns').toString();

        // We also accept 'auto', which will select the BEST number of columns.
        let columns = parseInt(columnValue);
        if (columnValue == 'auto' || !columns) {
            columns = Math.ceil(inlineSize / 350); // MAGIC NUMBER \o/.
        }

        // Layout all children with simply their column size.
        const childInlineSize = (inlineSize - ((columns + 1) * padding)) / columns;
        const childFragments = await Promise.all(children.map((child) => {
            return child.layoutNextFragment({fixedInlineSize: childInlineSize});
        }));

        let autoBlockSize = 0;
        const columnOffsets = Array(columns).fill(0);
        for (let childFragment of childFragments) {
            // Select the column with the least amount of stuff in it.
            const min = columnOffsets.reduce((acc, val, idx) => {
                if (!acc || val < acc.val) {
                    return {idx, val};
                }

                return acc;
            }, {val: +Infinity, idx: -1});

            childFragment.inlineOffset = padding + (childInlineSize + padding) * min.idx;
            childFragment.blockOffset = padding + min.val;

            columnOffsets[min.idx] = childFragment.blockOffset + childFragment.blockSize;
            autoBlockSize = Math.max(autoBlockSize, columnOffsets[min.idx] + padding);
        }

        return {autoBlockSize, childFragments};
    }
});

有关于这段JavaScript中详细的阐述,可以阅读 @张鑫旭 老师的《研究了下Houdini中的CSS Layout API》教程。

这样就可以使用CSS.layoutWorklet.addModule()引入前面已定义好的瀑布流布局的JS文件,比如masonry.js

if ('layoutWorklet' in CSS) {
    // 把自定义的瀑布流布局脚本添加到Layout Worklet中
    CSS.layoutWorklet.addModule('masonry.js');
}

这样就可以在div#masonry使用display:layout(masonry)

<!-- HTML -->
<div id="masonry">
    <div class="masonry__item"></div>
    <!-- ... -->
    <div class="masonry__item"></div>
</div>    

/* CSS */
#masonry {
    display: layout(masonry);
    --padding: 20;
    --columns: 3;
}

正如 @张鑫旭 老师所说:

CSS Layout API是非常强大的特性,可以让Web布局有更多的想象空间!

正如这个示例所示,使用CSS Layout API可以自定义更多的布局方式,而且使用起来也更灵活,但学习他有一定的成本(可能成本也较高),需要在CSS和JavaScript两个方面都有一定的造诣。因此,CSS Layout API以后注定是小部分开发者的玩具,最终出现的局面一定是少部分人创造,大部分人直接使用。

小结

我们是幸福的,这几年CSS在高速发展,有很多新的CSS特性提出,并且得到浏览器的支持。正如今天我们所聊的瀑布流布局,它是一个新特性,而且是个实验性特性。虽然说还没得到众多主流浏览器支持,但并不用过于担心,我想在不久的将来,这个特性就会得到更多的浏览器支持。当然,如果你在构建一个新的内部项目,那么可以尝试着使用这个新特性,也如文章中所阐述的,也可以基于这个新特性,用一点点JavaScript脚本让更多浏览器支持瀑布流布局效果。

另外,你也可以尝试着用用该布局特性,如果遇到问题,或者无法完成在以前的实现中能够完成的任务,可以向CSSWG提出你的问题。这样,我们才能更快的用上这个新特性。