51工具盒子

依楼听风雨
笑看云卷云舒,淡观潮起潮落

包含 N 个 React 组件的砌体网格

英文:

Masonry Grid Comprising N React Components

问题 {#heading}

我正在尝试在我的React项目中实现流行的Pinterest瀑布流视图。我知道在线有无数的资源可以帮助我实现这个目标,但我似乎找不到适合我的用例的资源。

我的情况是这样的:

我有一个React组件,可以有三种预定义的大小:小、中、大。在桌面上,每个这个组件的变体宽度都是200px,而在移动设备上是${windowSize.current[0]*0.45}px。

每个组件的区别在于高度,小卡片的高度是160px,中等卡片是250px,大卡片是320px。

这是动态卡片React代码的一部分:

<div className='LiveRoomCard' style={{
    border: props.liveroom.joined ? '1.5px solid #d500f9' : props.liveroom.pinned ? '1.5px solid black' : '1px solid lightgray',
    marginLeft: isMobile ? `${windowSize.current[0]*0.008}vw` : '2.5vw',
    width: isMobile ? `${windowSize.current[0]*0.45}px` : '200px',
    maxWidth: isMobile ? `${windowSize.current[0]*0.45}px` : '200px',
    height: props.size === 'small' ? '160px' : props.size === 'medium' ? '250px' : '320px',
    maxHeight: props.size === 'small' ? '160px' : props.size === 'medium' ? '250px' : '320px'
}}>
    <div className='LiveRoomCard-image' style={{
        minHeight: props.size === 'small' ? '70px' : props.size === 'medium' ? '150px' : '200px',
        maxHeight: props.size === 'small' ? '70px' : props.size === 'medium' ? '150px' : '200px'
    }}>
        <img src={props.liveroom.backgroundImage ?? cardPlaceHolder} alt='post bg image' style={{
            minHeight: props.size === 'small' ? '70px' : props.size === 'medium' ? '150px' : '200px',
            maxHeight: props.size === 'small' ? '70px' : props.size === 'medium' ? '150px' : '200px'
        }}/>
    </div>
    // 其余部分对于这个问题不重要
</div>

这是CSS的样式:

.LiveRoomCard {
    display: flex;
    flex-direction: column;
    background-color: white;
    height: 320px;
    max-height: 320px;
    border-radius: 15px;
    box-shadow: 2px 2px 2px grey;
    margin-top: 4%;
    margin-left: 2.5vw; /* 在React组件中被覆盖 */
}
.LiveRoomCard-image {
    flex: 1; /* 允许内容占据剩余的顶部空间。LiveRoomCard-details div则占据底部的一小部分 */
}
.LiveRoomCard-image img {
    min-width: 100%;
    max-width: 100%;
    object-fit: cover; /* 这会保持宽高比并覆盖容器 */
    border-radius: 15px 15px 0 0;
}

现在,这些卡片的网格位于父组件中。父组件对每张卡片的大小进行随机分配:

const postCardSizes = ["small", "medium", "large"];
<div className='Liverooms-container' style={{ height: isMobile ? '500px' : '700px' }}>
    {rooms.map(liveroom => {               
        
        const sizeIndex = Math.floor(Math.random() * postCardSizes.length);
        const randomSize = postCardSizes[sizeIndex];
        return (
            <div className='Liverooms-card-container' key={liveroom.roomId}>
                <LiveRoomCard key={liveroom.roomId} size={randomSize}/>
            </div>)
    })}
</div>

这是CSS的样式:

.Liverooms-container {
    display: flex;
    flex-direction: row;
    flex-wrap: wrap;

    overflow-y: scroll;
    overflow-x: scroll;
}
.Liverooms-card-container {
    height: fit-content;
}

我觉得我离目标很近了,因为这是我的桌面输出:包含 N 个 React 组件的砌体网格

而这是在移动设备上的样子:包含 N 个 React 组件的砌体网格

不用说,我试图实现的是在行之间消除不必要的间隙,实现三个预定义高度的组件的真正瀑布流。这是可以实现的,还是我需要完全改变我的方法? 英文:

I am trying to achieve the popular pinterest masonry grid view in my react project. I am aware there are countless resources online that would help me achieve this but I can't quite find one for my use case.

My case:

I have a react component that can come in three pre-fixed sizes: small, medium and large. Each variation of this component has the same width of 200px on a desktop or ${windowSize.current[0]*0.45}px on mobile.

The difference in each component is in the height where a small card will have a pre-fixed height of 160px a medium 250px and a large 320px.

Here's some snippet of what the react of the dynamic card looks like:

&lt;div className=&#39;LiveRoomCard&#39; style={{
border: props.liveroom.joined ? &#39;1.5px solid #d500f9&#39; : props.liveroom.pinned ? &#39;1.5px solid black&#39; : &#39;1px solid lightgray&#39;,
marginLeft: isMobile ? `${windowSize.current[0]*0.008}vw` : &#39;2.5vw&#39;,
width: isMobile ? `${windowSize.current[0]*0.45}px` : &#39;200px&#39;,
maxWidth: isMobile ? `${windowSize.current[0]*0.45}px` : &#39;200px&#39;,
height: props.size === &#39;small&#39; ? &#39;160px&#39; : props.size === &#39;medium&#39; ? &#39;250px&#39; : &#39;320px&#39;,
maxHeight: props.size === &#39;small&#39; ? &#39;160px&#39; : props.size === &#39;medium&#39; ? &#39;250px&#39; : &#39;320px&#39;
}}&gt;
&lt;div className=&#39;LiveRoomCard-image&#39; style={{
minHeight: props.size === &#39;small&#39; ? &#39;70px&#39; : props.size === &#39;medium&#39; ? &#39;150px&#39; : &#39;200px&#39;,
maxHeight: props.size === &#39;small&#39; ? &#39;70px&#39; : props.size === &#39;medium&#39; ? &#39;150px&#39; : &#39;200px&#39;
}}&gt;
&lt;img src={props.liveroom.backgroundImage ?? cardPlaceHolder} alt=&#39;post bg image&#39; style={{
minHeight: props.size === &#39;small&#39; ? &#39;70px&#39; : props.size === &#39;medium&#39; ? &#39;150px&#39; : &#39;200px&#39;,
maxHeight: props.size === &#39;small&#39; ? &#39;70px&#39; : props.size === &#39;medium&#39; ? &#39;150px&#39; : &#39;200px&#39;
}}/&gt;
&lt;/div&gt;
//rest of body that is unimportant for this question
&lt;/div&gt;

This is what the css looks like:

.LiveRoomCard {
display: flex;
flex-direction: column;
background-color: white;
height: 320px;
max-height: 320px;
border-radius: 15px;
box-shadow: 2px 2px 2px grey;
margin-top: 4%;
margin-left: 2.5vw; /*overriden in react component*/
}
.LiveRoomCard-image {
flex: 1; /* Allow the content to take remaining top space. The LiveRoomCard-details div then takes the little at the bottom */
}
.LiveRoomCard-image img{
min-width: 100%;
max-width: 100%;
object-fit: cover; /* This maintains aspect ratio and covers the container */
border-radius: 15px 15px 0 0;
}

Now this grid of cards sits in a parent component. The parent component does a random assigning of what size each card would be:

const postCardSizes = [&quot;small&quot;, &quot;medium&quot;, &quot;large&quot;];
&lt;div className=&#39;Liverooms-container&#39; style={{height: isMobile ? &#39;500px&#39; : &#39;700px&#39;}}&gt;
{rooms.map(liveroom =&gt; {               
const sizeIndex = Math.floor(Math.random() * postCardSizes.length);
const randomSize = postCardSizes[sizeIndex];
return (
&lt;div className=&#39;Liverooms-card-container&#39; key={liveroom.roomId}&gt;
&lt;LiveRoomCard key={liveroom.roomId} size={randomSize}/&gt;
&lt;/div&gt;)
})}
&lt;/div&gt;

And here's what the css looks like:

.Liverooms-container {
display: flex;
flex-direction: row;
flex-wrap: wrap;
overflow-y: scroll;
overflow-x: scroll;
}
.Liverooms-card-container {
height: fit-content;
}

I feel like I am almost there as this is what my output looks like on desktop: 包含 N 个 React 组件的砌体网格

And this is what it looks like on mobile:包含 N 个 React 组件的砌体网格

It goes without saying that what I am trying to achieve is the elimination of the unnecessary spacing between the rows and achieve a true masonry grid given of components of three pre-fixed heights. Is this something that is achievable or do I have to change my approach completely?

答案1 {#1}

得分: 2

CSS "Masonry"在我们理解和阅读Masonry布局的意义上并不存在,从用户体验的角度来看(或者用不太技术性的术语来说,从布局的读者角度来看)。

是的,从技术上讲,可以使用flex并将flex-direction更改为column,或者使用CSS的columns属性来实现,但我们需要在之前使用脚本来计算每个项目的位置,然后精确设置容器的height,以便项目按预期方式换行。此外,使用任何这些方法,DOM中项目的顺序会与被视为"自然流"的顺序不同(我们会在A列的所有项目之前有B列的所有项目),等等...

这可能不像一个大问题,但当我们调整容器的大小并需要添加或删除列时,它很快会变成一个问题(当发生这种情况时,几乎不可能跟踪每个项目的移动,如果布局还需要滚动,用户将无法跟踪项目的移动方式,这使得他们难以继续"阅读",因为底部的项目会跳到顶部,反之亦然)。处理这个问题的一种方法是在列数发生变化时实时重新排序项目。这会增加不必要的复杂性。

当向布局中添加更多项目时,使用上述的任何"解决方案"都会出现一系列类似的问题,如果我们希望保持"现有项目"在原地并在每个列的底部添加新项目。

简而言之,尝试使用flex或CSS的columns来实现Masonry具有成为编码和用户体验噩梦的所有前提条件。

此外,这绝不是最初Pinterest算法的工作方式。相反,代码非常简洁,我认为很容易理解。

以下是逻辑步骤:

  1. 将容器的position: relative和所有砌体项目的position: absolutedisplay: none(或opacity: 0)设置为初始状态,可以通过CSS来完成,然后通过内联计算样式或分配一个显示类(或删除隐藏类)来覆盖它们。

  2. 根据可用容器width计算可用列数。

  3. 通常,我们会为每列创建一个空数组,用于存储计算它们时每个项目的位置,尽管从技术上讲,我们也可以使用二维矩阵。每列应该保持其当前高度的状态,从0开始。
    重要说明:这些"列"是虚拟的,它们没有对应的DOM元素。砌体项目应该是砌体容器的直接子元素。项目的"列"决定了其translateXtransform值(见下一步)。

  4. 遍历砌体项目并将它们放置在最左边的最短列中(或者如果您喜欢,可以放在最右边的列中)。放置的意思是:
    a)根据列的heightindex计算当前项目的transform: translate(x, y)
    b)通过添加当前项目的height和可选的项目间距将列的height更新为它。
    c)将砌体DOM容器的高度更新为最高列的高度(+适当的间距)。
    拥有一种确定性和一致性的方法来放置下一个项目(而不是随机放置它们)可能看起来并不重要,但从用户体验的角度来看,这很重要。我们希望用户能够在刷新或调整页面大小时(这可能会更改列数)能够跟踪并继续"阅读"内容。重要的是允许他们确定从哪里继续阅读,而不需要不必要的认知努力。一些实现还会将当前滚动位置保留在状态/缓存中,以实现更直观的刷新行为。

  5. 上述循环速度非常快(性能很好),因此通常我们会计算所有项目,然后开始实际显示它们。我见过此脚本的版本,其中在循环内部处理了砌体项目的display/opacity更改,但在我看来,如果我们希望在显示项目时进行任何类型的过渡(或分阶段动画),最好在计算了所有transform之后再处理它们。

  6. 外部容器应该具有overflow: hidden,以防止在窗口/容器调整大小事件上出现奇怪的行为。说到调整窗口大小,最终需要通过一些节流来重新运行上述步骤,不隐藏项目。注意,在transform属性上放置短暂的transition效果在这里非常有效,因为用户可以轻松地看到在列数发生变化时项目的移动位置。此外,过渡效果是合理的,而且不会出现巨大的跳跃。它们就是您期望人类在将项目从n列重新排列为n - 1n + 1列时所做的。

听起来可能需要很多工作和思考,但它具有一定的优雅和简单性。一旦编码完成,这一点就会变得更加明显。

另外,我们还可以在项目滚动出屏幕时关闭项目的不透明度,以便在用户上下滚动时可以以动画方式显示它们。

就是这样。 英文:

CSS "Masonry" doesn't really exist in the sense we understand/read the Masonry layout, from a UX POV (or, in less technical terms, as readers of the layout).

Yes, technically, it could be achieved using flex and changing flex-direction to column or with CSS columns property, but we'd need a script to calculate each item's position before-hand and then set the container's height precisely so the items wrap as intended.
Also, with any of these approaches, the order of the items in DOM would be different than what is perceived as "natural flow" (we'd have all items in column A before all items in column B), and so on...

This might not seem like much of a problem, but it quickly becomes one when we resize the container and need to add or remove columns (it's basically impossible to follow where each item goes when this happens and, if the layout also requires scrolling, the user will not be able to follow how items move, making it difficult for them to continue "reading", as items from the bottom will jump at the top and vice-versa). One way to deal with this is to reorder the items on the fly when the number of columns changes. An undue complexity.

A very similar set of problems arises when adding more items to the layout, with any of the above "solutions", if we want to keep the "existing items" in place and add new ones at the bottom of each column.

In short, trying to do Masonry with flex or CSS columns has all the premises to turn into a coding and UX nightmare.

Besides, that's not at all how the initial Pinterest algorithm worked. On the contrary, the code was surprisingly small, elegant and, I'd argue, easy to follow.

Here are the logical steps:

  1. set the container's position: relative and all the masonry items to position: absolute and display: none (or opacity: 0). All these, along with the item's width, can be done via CSS, from the start, and then get overridden by inline computed styles or by assigning a revealing class (or removing a hiding one).

  2. calculate the number of available columns based on the available container width.

  3. Typically we'd create an empty array for each column, for storing each item's position as we calculate them, although technically we could also use a 2d matrix. Each column should keep a state of its current height, starting from 0.
    Important note: the "columns" are virtual, they don't have a corresponding DOM element. The masonry items should be immediate children of the masonry container. An item's column determines its translateX transform value (see next step).

  4. loop through the masonry items and place them in the shortest left-most column (or right-most, if you prefer). By placing we mean:
    a) calculate the current item's transform: translate(x, y) based on the column's height and index;
    b) update column's height by adding the current item's height and the optional item gap(s) to it; <sup>1</sup>
    c) update the masonry DOM container's height to equal to the tallest column's height (+ appropriate gap(s)).
    Having a deterministic and consistent way of placing the next item (vs placing them randomly) might not seem important, but it is, from a UX perspective. We want our users to be able to follow and be able to continue "reading" the content if they refresh or resize the page (which might change the number of columns). It is important to allow them to determine where they need to continue reading from, without undue cognitive effort. Some implementations also keep the current scroll position in state/cache to allow for more intuitive refresh behavior

  5. The above loop is a lot faster (performant) than one might think, so typically we'd calculate all items and then start actually displaying them. I've seen versions of this script where changing display/opacity of the masonry item was handled inside the loop but, in my estimation, if we want any type of transition (or staggering animations) in displaying the items it is better to handle them after we've calculated all transforms.

  6. The outer container should have overflow: hidden, so we don't get weird behavior on window/container resize event. Speaking of which, on window resize, eventually with some throttling, we have to re-run the above steps (without hiding the items). Note placing a short transition on the transform property works really well here, as the user can easily visualise where the items go when the number of columns changes. Also, the transitions make sense and are not huge jumps. They are what you'd expect a human to do when they rearrange the items from n columns into n - 1 or n + 1 columns.

Might sound like a lot to do and think about, but it has a certain elegance and simplicity to it. It becomes more obvious once you're done coding it.

Optionally, we could turn the opacity of items off as they go out the screen, so it can be animated back on as the user scrolls up and down.

That's all there is to it.


<sup>1</sup> - this step can get tricky when you deal with items of dynamic height, which could only be determined after rendering the item. The solution is to render the item in a hidden container of the same width as the column, detached from DOM, and measure it. Every single item has to be measured before it is placed in the grid. To avoid delays in rendering items caused by a hanging previous item still waiting for its image to load is to use placeholders. However, this means after the hanging item does render, all items after it must be repositioned (but this is fast; it's the measuring that's the most resource intensive).


赞(1)
未经允许不得转载:工具盒子 » 包含 N 个 React 组件的砌体网格