戴兜

戴兜的小屋

Coding the world.
github
bilibili
twitter

Exploring the CSS Layout API: Implementing Masonry Layout

image

I had a requirement for a masonry layout in a small project I was working on. I recently completed the layout part of the masonry, and the code has been uploaded to Github gist. While writing it, I was thinking: how nice would it be to have a more elegant way to quickly implement a masonry layout. So, I thought of the CSS Layout API described in CSS Houdini that I came across while browsing MDN out of boredom. It just so happens that I recently finished the masonry layout, making it convenient to practice.

Warning

The CSS Layout API is currently still a First Public Working Draft, and the content described in this article may become outdated at any time in the future.

Warning

Currently, no **browser** supports this feature. To properly display all demos described in this article, you need to use the Edge/Chrome browser and enable Experimental Web Platform features in the flags.

〇. Result#

Since the preamble of this article is quite long, I have placed the results at the forefront. You can view the complete example at https://masonry.daidr.me.

image

If browsers support this feature in the future, using a masonry layout will be a breeze. All you need to do is:

  • Include masonry.js
  • Prepare a parent container and some masonry elements (like cards)
  • Add a layout style to this parent element.
<script src="masonry.js" />

<div class="container">
    <div class="card">Masonry element</div>
    <div class="card">Masonry element</div>
    <div class="card">Masonry element</div>
    <!-- ... -->
</div>

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

Ⅰ. Some New Knowledge#

Pitfalls#

I eagerly went to MDN to browse the documentation related to the CSS Layout API, only to find… nothing at all 😵‍💫 … Since there was nothing, I went directly to W3C to take a look, so I opened https://www.w3.org/TR/css-layout-api-1, and after some attempts, I found that even the examples inside couldn't be used properly, realizing that this document was also outdated 😒

Fortunately, the content in the Editor’s Draft has been continuously updated, which gave me the motivation to keep writing. So, let's get started!

Typed OM#

I wonder if anyone else feels awkward when using JS to manipulate styles:

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

Since it returns a string, performing calculations is always cumbersome, and I often get confused between font-size/fontSize/margin-top/marginTop, not to mention the various concatenations of values and units. I've made mistakes like the following more than once:

element2.style.opacity += 0.1;

Typed OM can solve many of the unpleasant experiences we encounter when directly manipulating CSSOM. You can access a StylePropertyMap object through the element's attributeStyleMap property, allowing you to read the element's styles in a map-like manner.

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

It returns a CSSUnitValue object (which could also be a CSSMathValue or its subclasses), allowing us to easily access the numeric part of the property value, simplifying our operations. The browser can even automatically convert relative units like em and rem into absolute unit values. We can also quickly convert units using the built-in to method of CSSUnitValue. Moreover, the browser provides a wealth of factory methods to standardize the expression of CSS property values. For example, using Typed OM, our first example would look like this.

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

Much more comfortable. Throughout the process of using the CSS Layout API, we will frequently see Typed OM. You can find documentation related to Typed OM on MDN.

CSS Properties and Values API#

This interface allows us to register some custom CSS properties and define their formats and default values.

CSS.registerProperty({
    name: "--masonry-gap",   // Name of the custom property
    syntax: "<number>",      // Format of the custom property
    initialValue: 4,         // Default value
    inherits: false          // Whether to inherit from the parent element
});

This interface can be used in JavaScript, and the browser also provides an At Rule for custom property values.

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

Once the custom property is registered, when manipulating styles through Typed OM, the browser will return the corresponding CSSUnitValue (or CSSMathValue) object according to the format you provided. If this is not done, the browser will return a CSSUnparsedValue object carrying the original CSS property value.

The content of the syntax string is actually quite simple; syntax consists of a series of syntax components. By default, the content of the syntax field is *. Additionally, | can be used to represent "or", + can be used to indicate that space-separated property values are accepted, and # indicates that comma-separated property values are accepted. This syntax is merely a subset of Value Definition Syntax. For more detailed information, you can refer to section 5 of the draft.

CSS Layout API#

Finally, we arrive at the main event! The layout logic requires the use of the Worklet interface provided by the browser, which allows scripts to run independently of the JS execution environment for high-performance operations such as drawing, layout, and audio processing. Therefore, we need a script to load the layout logic-related code into the LayoutWorklet. (Don't forget to check browser compatibility.)

// masonry.js

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

Next is the code that needs to be loaded into the LayoutWorklet.

// layout-masonry.js

registerLayout('masonry', class {
    // Declare the CSS properties you need to read here
    static inputProperties = ['--masonry-gap', '--masonry-column'];

    // This method is used to determine the size of elements in a flexible layout; it can be left empty but cannot be omitted
    async intrinsicSizes(children, edges, styleMap) { }

    // Layout logic
    async layout(children, edges, constraints, styleMap, breakToken) { }
});

This creates a layout method called masonry. The two pieces of code above can be seen as a template that can be used directly.

Next comes the nightmare 🤯, what are the parameters of layout, and how do we operate them? Fortunately, the draft provides enough detail and examples for reference. (This article will not discuss the usage of breakToken.)

children#

This is an array composed of many LayoutChild objects, representing all the child elements within the container. LayoutChild mainly contains the following properties or methods:

LayoutChild.intrinsicSizes()

Returns a promise to obtain an IntrinsicSizes object, which can get the maximum/minimum size of the element.

LayoutChild.layoutNextFragment(constraints, breakToken)

Returns a promise to obtain a LayoutFragment object. The LayoutFragment object mainly contains the following properties:

  • LayoutFragment.inlineSize: The size of the child element in the inline direction, i.e., width (read-only)
  • LayoutFragment.blockSize: The size of the child element in the block direction, i.e., height (read-only)
  • LayoutFragment.inlineOffset: The offset of the child element in the inline direction
  • LayoutFragment.blockOffset: The offset of the child element in the block direction; layout mainly relies on these two offsets.

LayoutChild.styleMap

Returns a StylePropertyMapReadOnly object for manipulating the styles of child elements.

edges#

This is a LayoutEdges object (all properties are read-only) used to obtain the margins inside and outside the container, as well as the distances caused by the scrollbar between the content box and the border box.

  • LayoutEdges.inlineStart: Distance in the inline start direction
  • LayoutEdges.inlineEnd: Distance in the inline end direction
  • LayoutEdges.blockStart: Distance in the block start direction
  • LayoutEdges.blockEnd: Distance in the block end direction
  • LayoutEdges.inline: Sum of distances in the inline direction
  • LayoutEdges.block: Sum of distances in the block direction

This may not be very intuitive, so here is a diagram provided in the draft for the rtl direction (which is exactly the opposite of ltr):

image

constraints#

This is a LayoutConstraints object (all properties are read-only) used to obtain the size information of the element (referring to the container here).

  • LayoutConstraints.availableInlineSize: Available size in the inline direction
  • LayoutConstraints.availableBlockSize: Available size in the block direction
  • LayoutConstraints.fixedInlineSize: Determined size in the inline direction
  • LayoutConstraints.fixedBlockSize: Determined size in the block direction
  • LayoutConstraints.percentageInlineSize: Size in the inline direction (expressed as a percentage)
  • LayoutConstraints.percentageBlockSize: Size in the block direction (expressed as a percentage)

However, it seems that the LayoutConstraints object currently provided by browsers can only retrieve the fixedInlineSize and fixedBlockSize properties…

styleMap#

This is a StylePropertyMapReadOnly object used to manipulate the styles of the container.

Ⅱ. Starting to Implement Masonry Layout#

The basic logic for implementing a masonry layout using the CSS Layout API is actually quite similar to other implementation methods.

First, we define two custom properties to facilitate the formatting of property values later.

Let's also load layout-masonry.js into the 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');
}

All subsequent code, unless otherwise specified, will be within the layout logic of layout-masonry.js.

First, we obtain the width of the container's content box:

// Get the available width of the container (horizontal size - sum of left and right padding)
const availableInlineSize = constraints.fixedInlineSize - edges.inline;

Next, we get the number of columns for the masonry layout (since the value is an integer and the default value is 4, we don't need to do any processing; just read it in).

// Get the defined number of masonry columns
const column = styleMap.get('--masonry-column').value;

Next, we need to obtain the spacing for each column. This situation is a bit more complex. Fortunately, all relative and absolute units will be automatically converted to pixels when passed in, so we only need to handle percentages and calc functions. The calc function in CSS supports nesting, so we will use recursion to perform the calculations and convert percentages to pixel values.

// layout-masonry.js external
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.')
    }
}
// Get the defined masonry gap
let gap = styleMap.get('--masonry-gap');
// Convert calculated properties and percentages to pixel values
gap = calc(gap, availableInlineSize);

We need to calculate the width of the child elements based on the number of columns and the gap.

// Calculate the width of child elements
const childAvailableInlineSize = (availableInlineSize - ((column + 1) * gap)) / column;

The following code can be considered a template; we need to obtain the fragments of the child elements, as only then can we modify the offsets of the child elements.

// Set the width of child elements and get fragments
let childFragments = await Promise.all(children.map((child) => {
    return child.layoutNextFragment({ availableInlineSize: childAvailableInlineSize });
}));

Next comes the masonry logic, which is generally similar across all masonry implementations. In my Github gist, the Vue version is implemented in the same way. We need to keep track of the current height of each column and select the shortest column to insert new elements during layout (if we insert in order, it will cause too much height difference between columns).

// Set the width of child elements and get fragments
let childFragments = await Promise.all(children.map((child) => {
    return child.layoutNextFragment({ availableInlineSize: childAvailableInlineSize });
}));

let autoBlockSize = 0; // Initialize container height
const columnHeightList = Array(column).fill(edges.blockStart); // Initialize the height of each column, filled with the container's top margin
for (let childFragment of childFragments) {
    // Get the current shortest column
    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 });

    // Calculate the position of the child element
    childFragment.inlineOffset = gap + shortestColumn.index * (childAvailableInlineSize + gap) + edges.inlineStart;
    childFragment.blockOffset = gap + shortestColumn.value;

    // Update the current column's height (original height + child element height)
    columnHeightList[shortestColumn.index] = childFragment.blockOffset + childFragment.blockSize;

    // Update the container height (if the height of the shortest column does not exceed the original height of the container, the container height remains unchanged)
    autoBlockSize = Math.max(autoBlockSize, columnHeightList[shortestColumn.index] + gap);
}

The only difference from a regular masonry layout might be that in the last step, we need to update the height of the container. Therefore, after laying out each child element, we try to record the height of the currently tallest column.

Finally, we need to return an object containing the container height and the child element fragments.

Note: According to the description in the draft, this should return a FragmentResult object, but currently, no browser has implemented this class…

// Return an object containing autoBlockSize and childFragments
return { autoBlockSize, childFragments };

The complete code can be found in the repository at the beginning of the article.

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.