自己の小さなプロジェクトで、マルチカラムレイアウトの必要があり、最近マルチカラムのレイアウト部分を完成させたばかりです。この部分のコードはすでにGithub gistにアップロードされています。書いているときに考えていたのは、もしもっと優雅な方法でマルチカラムレイアウトを迅速に実現できたらどんなに良いかということでした。そこで、以前に MDN を見ていたときに、CSS Houdini で説明されていた CSS Layout API を思い出しました。ちょうど最近マルチカラムを完成させたばかりで、実践するのが比較的簡単です。
警告
CSS Layout API は現在 First Public Working Draft であり、この記事の内容は将来的にいつでも古くなる可能性があります。
警告
現在、** どの ** ブラウザもこの機能をサポートしていません。この記事で説明されているすべてのデモを正常に表示するには、edge/chrome ブラウザを使用し、flags で Experimental Web Platform features を有効にする必要があります。
〇. 結果#
この記事は前置きが長いため、結果を最初に提示します。完全な例はhttps://masonry.daidr.meで確認できます。
将来的にブラウザがこの機能をサポートするようになれば、マルチカラムレイアウトを使用するのは非常に簡単なことになるでしょう。あなたがする必要があるのは、
- 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オブジェクトを取得し、その後、マップのように要素のスタイルを読み取ることができます。
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のサブセットに過ぎません。詳細な資料は、草案の第 5 節で詳しく確認できます。
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 という名前のレイアウト方式を作成しました。上記の 2 つのコードはテンプレートとして考えることができ、そのまま使用できます。
次は悪夢の始まりです🤯 、layout のこれらのパラメータは何で、どのように操作するのか?幸いにも、草案は十分に詳細で、参考用のいくつかの例も提供しています。(この記事では breakToken の使用法については議論しません)
children#
これは多くのLayoutChildオブジェクトで構成される配列で、コンテナ内のすべての子要素を表します。LayoutChildは主に以下の属性またはメソッドを含みます。
LayoutChild.intrinsicSizes()
IntrinsicSizesオブジェクトを取得するための promise を返し、要素の最大 / 最小サイズを取得できます。
LayoutChild.layoutNextFragment(constraints, breakToken)
LayoutFragmentオブジェクトを取得するための promise を返します。LayoutFragmentオブジェクトは主に以下の属性を含みます:
- LayoutFragment.inlineSize:子要素のインライン方向のサイズ、すなわち幅(読み取り専用)
- LayoutFragment.blockSize:子要素のブロック方向のサイズ、すなわち高さ(読み取り専用)
- LayoutFragment.inlineOffset:子要素のインライン方向のオフセット
- LayoutFragment.blockOffset:子要素のブロック方向のオフセット、レイアウトは主にこの 2 つのオフセットに依存します
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オブジェクトは、fixedInlineSizeとfixedBlockSizeの 2 つの属性しか取得できないようです…
styleMap#
これはStylePropertyMapReadOnlyオブジェクトで、コンテナのスタイルを操作するために使用されます。
Ⅱ. マルチカラムの実装を始める#
CSS Layout API を使用してマルチカラムを実装する基本的なロジックは、他の実装方法と基本的に一致しています。
まず、2 つのカスタムプロパティを定義して、後で属性値のフォーマットを簡単にします。
ついでに 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;
以下のコードはテンプレートと見なすことができ、子要素のフラグメントを取得する必要があります。これにより、子要素のオフセットを変更できます。
// 子要素の幅を設定し、フラグメントを取得
let childFragments = await Promise.all(children.map((child) => {
return child.layoutNextFragment({ availableInlineSize: childAvailableInlineSize });
}));
次に、マルチカラムのロジックです。基本的にすべてのマルチカラムのロジックは似ています。私のGithub gistの vue バージョンもこのように実装されています。各列の現在の高さを記録し、新しい要素をレイアウトする際に、最も短い列を選択して挿入操作を行います(順番に挿入すると、各列の高さの差が大きくなります)。
// 子要素の幅を設定し、フラグメントを取得
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);
}
通常のマルチカラムとの唯一の違いは、最後のステップでコンテナの高さを更新する必要があることです。したがって、各子要素をレイアウトするたびに、現在の最も高い列の高さを記録しようとします。
最後に、コンテナの高さと子要素のフラグメントを含むオブジェクトを固定して返す必要があります。
注:草案の説明によれば、ここではFragmentResultオブジェクトを返す必要がありますが、現在、どのブラウザもこのクラスを実装していません…
// autoBlockSizeとchildFragmentsを含むオブジェクトを固定して返す
return { autoBlockSize, childFragments };
完全なコードは、記事の冒頭のリポジトリで見つけることができます。