戴兜的小屋
戴兜的小屋

CSS Layout API初探:瀑布流布局实现

CSS Layout API初探:瀑布流布局实现

自己在写的小项目中有瀑布流的需求,不久之前刚刚完成瀑布流的布局部分,这部分代码也已经上传到了Github gist。写的时候我就在思考:如果能有更优雅的方式快速实现瀑布流布局该多好。于是,我便想到了之前无聊时翻看MDN时,CSS Houdini里边所描述的CSS Layout API。正好最近刚写完瀑布流,实践起来比较方便。

warning
CSS Layout API目前还是First Public Working Draft,本文所述内容在将来随时可能过时。

warning
目前没有**任何**浏览器支持该特性,为了正常展示本文所述的所有demo,你需要使用edge/chrome浏览器并在flags中将Experimental Web Platform features启用。

〇. 结果

因为这篇文章前戏很长,所以将结果放在了最前面呈现,完整的示例可以前往 https://masonry.daidr.me 查看。

https://s2.loli.net/2022/01/14/YSyWtb3hkI14z7s.jpg

如果将来浏览器支持了该特性,那么使用瀑布流布局将会是一件易如反掌的事情,你需要做的,仅仅是

  • 引入 masonry.js
  • 准备一个父级容器,和一些瀑布流元素(例如卡片)
  • 为这个父级元素加上一个布局样式。
<script src="masonry.js" />

<div class="container">
    <div class="card">瀑布流元素</div>
    <div class="card">瀑布流元素</div>
    <div class="card">瀑布流元素</div>
    <!-- ... -->
</div>

<style>
.container {
    display: layout(masonry);
}
</style>

Ⅰ. 一些新的知识

我兴致冲冲地去MDN翻阅与CSS Layout API相关的文档,结果发现…居然什么都没有 :roll: …既然没有的话,直接去w3c上看看吧,于是,我打开了https://www.w3.org/TR/css-layout-api-1,结果经过我的一番尝试,连里边的示例都没法正常使用,才发现这个文档也过时了 :eek:

不过好在Editor’s Draft里面的内容一直在更新,这才让我有了继续写下去的动力。那么,让我们开始吧!

Typed OM

不知道大家在使用js操作样式时,是否会感到百般别扭:

let newWidth = 10;
element1.style.width = `${newWidth}px`

因为返回的是字符串,进行运算的时候总是很狼狈,傻傻搞不清楚font-size/fontSize/margin-top/marginTop,更别提各种数值和单位的拼接,我已经不止一次犯过下面这样的错误了:

element2.style.opacity += 0.1;

Typed OM便可以来解决我们直接操作CSSOM时发生的诸多不愉快。你可以通过元素的attributeStyleMap属性获取到一个StylePropertyMap对象,之后,便可以以map的方式读取元素的样式了。

element3.attributeStyleMap.get('opacity'); // CSSUnitValue {value: 0.5, unit: 'number'}
element3.attributeStyleMap.get('width'); // CSSUnitValue {value: 10, unit: 'px'}

返回的是一个CSSUnitValue对象(也可能是CSSMathValue或其子类的对象),我们可以很轻松地获取到属性值的数值部分,简化我们的操作。浏览器甚至能够自动转换em、rem等相对单位,得到绝对单位数值。我们还可以通过CSSUnitValue内置的to方法,进行快速的单位转换。不仅如此,浏览器还提供了大量的工厂方法来规范化表达css的属性值,比如我们的第一个例子,使用Typed OM进行操作就会是下面的样子。

let newWidth = 10;
element1.attributeStyleMap.set('width', CSS.px(newWidth));

舒服多了。在使用CSS Layout API的过程中,我们会经常看到Typed OM的身影。在MDN可以找到Typed OM相关的文档

CSS Properties and Values API

这个接口能够让我们注册一些自定义的css属性,并定义格式和默认值。

CSS.registerProperty({
    name: "--masonry-gap",   // 自定义属性的名称
    syntax: "<number>",      // 自定义属性的格式
    initialValue: 4,         // 默认值
    inherits: false          // 是否从父元素继承
});

不仅可以在JavaScript中使用该接口,浏览器也提供了自定义属性值的 At Rule

@property --masonry-gap {
    syntax: '<number>';
    initial-value: 4;
    inherits: false;
}

自定义属性注册完成后,之后再通过Typed OM操作样式,浏览器便会按照你所提供的格式,返回对应的CSSUnitValue(或CSSMathValue)对象。倘若不这么做,浏览器将会返回一个携带原始css属性值的CSSUnparsedValue对象。

syntax字符串的内容其实很简单,syntax由一堆syntax component组成,默认情况下,syntax字段的内容是*。除此之外,还可以使用 | 来表示或, + 来表示接受使用空格分割的属性值, # 表示接受使用逗号分割的属性值。这里的syntax仅仅是Value Definition Syntax的一个子集。更详细的资料,可以去草案的第五节详细了解。

CSS Layout API

终于到了咱们的重头戏!布局的相关逻辑需要使用浏览器提供的Worklet接口,这个接口允许脚本独立于js运行环境,进行诸如绘图、布局、音频处理等需要高性能的操作。所以,我们需要一个脚本,用于将布局逻辑相关的代码载入到LayoutWorklet中。(别忘了检查一下浏览器兼容性)

// masonry.js

if ('layoutWorklet' in CSS) {
    CSS.layoutWorklet.addModule('layout-masonry.js');
}

接下来就是需要被载入到LayoutWorklet中的代码

// layout-masonry.js

registerLayout('masonry', class {
    // 在这里声明之后你需要读取的css属性
    static inputProperties = ['--masonry-gap', '--masonry-column'];

    // 这个方法用于在弹性布局中确定元素尺寸,可以空着,但不能没有
    async intrinsicSizes(children, edges, styleMap) { }

    // 布局逻辑
    async layout(children, edges, constraints, styleMap, breakToken) { }
});

这样我们就创建了一个名为masonry的布局方式,上面两段代码可以看作是一套模板,直接拿来用就行。

接下来就是噩梦了 :shock: ,layout的这几个参数是什么,该如何操作?好在草案写得足够详细,也提供了一些示例以供参考。(这篇文章不会讨论breakToken的用法)

children

是一个许多LayoutChild对象组成的数组,代表着容器内的所有子元素。LayoutChild主要包含下面这些属性或方法

LayoutChild.intrinsicSizes()

返回一个promise,用以得到IntrinsicSizes对象,可以获取元素的最大/最小尺寸

LayoutChild.layoutNextFragment(constraints, breakToken)

返回一个promise,用以得到LayoutFragment对象,LayoutFragment对象主要包含下面这些属性:

  • LayoutFragment.inlineSize:子元素内联方向上的尺寸,即宽度(只读)
  • LayoutFragment.blockSize:子元素块级方向上的尺寸,即高度(只读)
  • LayoutFragment.inlineOffset:子元素内联方向上的偏移
  • LayoutFragment.blockOffset:子元素块级方向上的偏移,布局主要就靠这两个偏移了

LayoutChild.styleMap

返回一个StylePropertyMapReadOnly对象,用来操作子元素的样式

edges

是一个LayoutEdges对象(属性均只读),用来获取容器内外边距、滚动条导致的content box与border box产生的距离

  • LayoutEdges.inlineStart:内联起始方向的距离
  • LayoutEdges.inlineEnd:内联结束方向的距离
  • LayoutEdges.blockStart:块级起始方向的距离
  • LayoutEdges.blockEnd:块级结束方向的距离
  • LayoutEdges.inline:内联方向的距离和
  • LayoutEdges.block:块级方向的距离和

可能不是很直观,这里放一张草案里提供的rtl方向下的图(和ltr正好相反):

https://s2.loli.net/2022/01/14/P9tKiBFgxOJUfDb.png

constraints

是一个LayoutConstraints对象(属性均只读),用来获取元素(这里是指容器)的尺寸信息

  • LayoutConstraints.availableInlineSize:内联方向上的可用尺寸
  • LayoutConstraints.availableBlockSize:块级方向上的可用尺寸
  • LayoutConstraints.fixedInlineSize:内联方向上的确定尺寸
  • LayoutConstraints.fixedBlockSize:块级方向上的确定尺寸
  • LayoutConstraints.percentageInlineSize:内联方向上的尺寸(百分比表示)
  • LayoutConstraints.percentageBlockSize:块级方向上的尺寸(百分比表示)

不过似乎目前浏览器提供的 LayoutConstraints 对象只能获取到 fixedInlineSizefixedBlockSize 这两个属性…

styleMap

是一个 StylePropertyMapReadOnly 对象,用来操作容器的样式

Ⅱ. 开始实现瀑布流

使用CSS Layout API实现瀑布流的基本逻辑其实和其他实现方式基本是一致的。

我们先来定义两个自定义属性,方便之后进行属性值的格式化。

顺便把layout-masonry.js载入到layoutWorklet中

// masonry.js

if ('layoutWorklet' in CSS) {
    CSS.registerProperty({
        name: '--masonry-column',
        syntax: '<number>',
        inherits: false,
        initialValue: 4
    });

    CSS.registerProperty({
        name: '--masonry-gap',
        syntax: '<length-percentage>',
        inherits: false,
        initialValue: '20px'
    });

    CSS.layoutWorklet.addModule('layout-masonry.js');
}

接下来的所有代码若没有额外说明则均在layout-masonry.js的layout逻辑内部。

首先,我们来获取容器的内容盒子宽度:

// 获取容器的可用宽度(水平尺寸 - 左右内边距之和)
const availableInlineSize = constraints.fixedInlineSize - edges.inline;

接下来,我们来获取瀑布流列数(因为值是整数且默认值为4,我们无需做任何处理,读进来就好)

//获取定义的瀑布流列数
const column = styleMap.get('--masonry-column').value;

接着,我们需要得到每列的间距,此时情况就复杂了。不过好在所有相对单位和绝对单位在传入时都会自动转换成px,所以实际上我们只需要处理百分比和calc函数,css里边的calc函数是支持嵌套的,所以我们这里使用递归来完成计算,同时将百分比转换为像素值。

// layout-masonry.js 外部
function calc(obj, inlineSize) {
    if (obj instanceof CSSUnitValue && obj.unit == 'px') {
        return obj.value;
    } else if (obj instanceof CSSMathNegate) {
        return -obj.value;
    } else if (obj instanceof CSSUnitValue && obj.unit == 'percent') {
        return obj.value * inlineSize / 100;
    } else if (obj instanceof CSSMathSum) {
        return Array.from(obj.values).reduce((total, item) => total + calc(item, inlineSize), 0);
    } else if (obj instanceof CSSMathProduct) {
        return Array.from(obj.values).reduce((total, item) => total * calc(item, inlineSize), 0);
    } else if (obj instanceof CSSMathMax) {
        let temp = Array.from(obj.values).map((item) => calc(item, inlineSize));
        return Math.max(...temp);
    } else if (obj instanceof CSSMathMin) {
        let temp = Array.from(obj.values).map((item) => calc(item, inlineSize));
        return Math.min(...temp);
    } else {
        throw new TypeError('Unsupported expression or unit.')
    }
}
// 获取定义的瀑布流间距
let gap = styleMap.get('--masonry-gap');
// 将计算属性和百分比处理成像素值
gap = calc(gap, availableInlineSize);

我们需要根据列数和间隔计算出子元素的宽度

// 计算子元素的宽度
const childAvailableInlineSize = (availableInlineSize - ((column + 1) * gap)) / column;

下面的代码可以算是模板,我们需要获取子元素的fragment,只有这样我们才可以修改子元素的偏移

// 设定子元素宽度,获取fragments
let childFragments = await Promise.all(children.map((child) => {
    return child.layoutNextFragment({ availableInlineSize: childAvailableInlineSize });
}));

紧接着,就是瀑布流的逻辑了,基本上所有瀑布流的逻辑是类似的。在我的Github gist中vue的版本也是这么实现的。我们需要记录每一列的当前高度,在布局新元素时,选取其中最短的一列进行插入操作(倘若按照顺序插入会导致每列的高度差距过大)

// 设定子元素宽度,获取fragments
let childFragments = await Promise.all(children.map((child) => {
    return child.layoutNextFragment({ availableInlineSize: childAvailableInlineSize });
}));

let autoBlockSize = 0; //初始化容器高度
const columnHeightList = Array(column).fill(edges.blockStart); //初始化每列的高度,用容器的上边距填充
for (let childFragment of childFragments) {
    // 得到当前高度最小的列
    const shortestColumn = columnHeightList.reduce((curShortestColumn, curValue, curIndex) => {
        if (curValue < curShortestColumn.value) {
            return { value: curValue, index: curIndex };
        }

        return curShortestColumn;
    }, { value: Number.MAX_SAFE_INTEGER, index: -1 });

    // 计算子元素的位置
    childFragment.inlineOffset = gap + shortestColumn.index * (childAvailableInlineSize + gap) + edges.inlineStart;
    childFragment.blockOffset = gap + shortestColumn.value;

    // 更新当前列的高度(原高度 + 子元素高度)
    columnHeightList[shortestColumn.index] = childFragment.blockOffset + childFragment.blockSize;

    // 更新容器高度(若最短列的高度没有超过容器原高度,则容器高度保持不变)
    autoBlockSize = Math.max(autoBlockSize, columnHeightList[shortestColumn.index] + gap);
}

与普通瀑布流唯一的不同可能是在最后一步,我们需要更新容器的高度,所以每布局一个子元素,都尝试记录目前最高那列的高度。

最后,我们需要固定返回一个包含容器高度和子元素fragment的对象

注:按照草案中的描述,此处应该返回一个FragmentResult对象,但是目前没有任何一个浏览器实现了这个类…

// 固定返回一个包含autoBlockSize和childFragments的对象
return { autoBlockSize, childFragments };

完整的代码可以在文章开头的仓库中找到。

赞赏

戴兜

文章作者

狱杰进行回复 取消回复

textsms
account_circle
email

戴兜的小屋

CSS Layout API初探:瀑布流布局实现
自己在写的小项目中有瀑布流的需求,不久之前刚刚完成瀑布流的布局部分,这部分代码也已经上传到了Github gist。写的时候我就在思考:如果能有更优雅的方式快速实现瀑布流布局该多好。于…
扫描二维码继续阅读
2022-01-14