前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >CSS Layout API初探:瀑布流布局实现

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

作者头像
戴兜
修改2023-03-03 14:42:21
8820
修改2023-03-03 14:42:21
举报
文章被收录于专栏:daidr

自己在写的小项目中有瀑布流的需求,不久之前刚刚完成瀑布流的布局部分,这部分代码也已经上传到了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 查看。

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

  • 引入 masonry.js
  • 准备一个父级容器,和一些瀑布流元素(例如卡片)
  • 为这个父级元素加上一个布局样式。
代码语言:javascript
复制
<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相关的文档,结果发现…居然什么都没有

…既然没有的话,直接去w3c上看看吧,于是,我打开了https://www.w3.org/TR/css-layout-api-1,结果经过我的一番尝试,连里边的示例都没法正常使用,才发现这个文档也过时了

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

Typed OM

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

代码语言:javascript
复制
let newWidth = 10;
element1.style.width = `${newWidth}px`

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

代码语言:javascript
复制
element2.style.opacity += 0.1;

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

代码语言:javascript
复制
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进行操作就会是下面的样子。

代码语言:javascript
复制
let newWidth = 10;
element1.attributeStyleMap.set('width', CSS.px(newWidth));

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

CSS Properties and Values API

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

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

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

代码语言:javascript
复制
@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中。(别忘了检查一下浏览器兼容性)

代码语言:javascript
复制
// masonry.js

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

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

代码语言:javascript
复制
// 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的布局方式,上面两段代码可以看作是一套模板,直接拿来用就行。

接下来就是噩梦了

,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正好相反):

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中

代码语言:javascript
复制
// 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逻辑内部。

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

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

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

代码语言:javascript
复制
//获取定义的瀑布流列数
const column = styleMap.get('--masonry-column').value;

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

代码语言:javascript
复制
// 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.')
    }
}
代码语言:javascript
复制
// 获取定义的瀑布流间距
let gap = styleMap.get('--masonry-gap');
// 将计算属性和百分比处理成像素值
gap = calc(gap, availableInlineSize);

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

代码语言:javascript
复制
// 计算子元素的宽度
const childAvailableInlineSize = (availableInlineSize - ((column + 1) * gap)) / column;

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

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

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

代码语言:javascript
复制
// 设定子元素宽度,获取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对象,但是目前没有任何一个浏览器实现了这个类…

代码语言:javascript
复制
// 固定返回一个包含autoBlockSize和childFragments的对象
return { autoBlockSize, childFragments };

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

code{background: #f5f2f0;}

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2022-01-14,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 〇. 结果
  • Ⅰ. 一些新的知识
      • Typed OM
        • CSS Properties and Values API
          • CSS Layout API
            • children
            • edges
            • constraints
            • styleMap
        • Ⅱ. 开始实现瀑布流
        相关产品与服务
        容器服务
        腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
        领券
        问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档