戴兜

戴兜的小屋

Coding the world.
github
bilibili
twitter

CSS Layout API初探:瀑布流佈局實現

image

自己在寫的小專案中有瀑布流的需求,不久之前剛剛完成瀑布流的佈局部分,這部分代碼也已經上傳到了Github gist。寫的時候我就在思考:如果能有更優雅的方式快速實現瀑布流佈局該多好。於是,我便想到了之前無聊時翻看 MDN 時,CSS Houdini 裡邊所描述的 CSS Layout API。正好最近剛寫完瀑布流,實踐起來比較方便。

警告

CSS Layout API 目前還是 First Public Working Draft,本文所述內容在將來隨時可能過時。

警告

目前沒有 ** 任何 ** 瀏覽器支持該特性,為了正常展示本文所述的所有 demo,你需要使用 edge/chrome 瀏覽器並在 flags 中將 Experimental Web Platform features 啟用。

〇. 結果#

因為這篇文章前戲很長,所以將結果放在了最前面呈現,完整的示例可以前往 https://masonry.daidr.me 查看。

image

如果將來瀏覽器支持了該特性,那麼使用瀑布流佈局將會是一件易如反掌的事情,你需要做的,僅僅是

  • 引入 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 相關的文檔,結果發現… 居然什麼都沒有😵‍💫 … 既然沒有的話,直接去 w3c 上看看吧,於是,我打開了https://www.w3.org/TR/css-layout-api-1,結果經過我的一番嘗試,連裡邊的示例都沒法正常使用,才發現這個文檔也過時了😒

不過好在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 的佈局方式,上面兩段代碼可以看作是一套模板,直接拿來用就行。

接下來就是噩夢了🤯 ,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 正好相反):

image

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 };

完整的代碼可以在文章開頭的倉庫中找到。-

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。