瀑布流实现思路

瀑布流是一种用于大量图片显示的方式,由于图片大小往往难以确定,因此可以固定图片的宽度或是宽度,而后沿着非固定的方向无限扩展。

如果以简笔画的形式画一个瀑布,可以看出瀑布中的水流以不同长度的竖线显示。如果把竖线看作我们要展示的内容,那么整个结果就和简笔画下的瀑布很像。

简笔画瀑布简笔画瀑布

如图,这时 Google 图片页面,图片结果采用横向的瀑布流显示,而结果中的很多内容(比如第一张)则是竖向瀑布流。

瀑布流效果展示瀑布流效果展示

固定大小显示

最简单的思路是固定所有的图片大小,然后以类似表格的方式进行展示。这样在展示时只需要顺序显示即可。

如果显示内容是文字或其他内容通常较容易实现,只需要设置宽和高即可,但是如果是任意大小的图片则较难实现。
如果单纯设置宽或高,另一端自动处理,则可能会导致出现白边(如下面所示)

针对该问题,有两种解决思路:

  • 通过 js 获取图片大小,并且在加载完毕后计算后重新设置宽高
  • 将图片设为背景,且以cover模式显示

重新计算图片大小

在老版本的博客中,采用的是这种思路:

  1. js 选取到需要操作的图片
  2. 获取图片的高度和宽度
  3. 将图片宽或高拉伸至父容器相应值
  4. 计算另一个维度的偏移量

如偏移高度方向,只需要设置margin-top(containerHeightimageHeight)/2(containerHeight - imageHeight)/2

父容器还需要设置overflow: hidden来隐藏超出的部分。

function displayImage() {
    $('.displayItem').each(function () {
        var parentH = parseInt($(this).parent('.displayHolder').css("max-height"));
        var thisH = $(this).height()
        if (parentH < thisH) {
            var offset = (parentH - thisH) * 0.5;
            $(this).css("margin-top", offset + "px");
        }
    });
}

该方案需要在图片加载后执行 JavaScript 代码(可以设置图片的onload回调完成)

背景图

如果将图片设置成background-image,而后设置宽度高度,以及background-size: cover,即可自动将图片设置成合适的位置、大小。

该方案非常简单,但由于背景图片如img标签不同,无法响应复制、在新窗口打开图片操作。如果没有特殊的需求可以视为是一种极为优雅的解决方案。

如果需要进一步的配置,则仍然需要 JavaScript 配合操作。监听onclick事件,打开一个图片浮层。在目前版本的博客中,所有图片都采用这个方式。

这里使用的代码如下,实现了点击后打开图片浮层,并且允许滚轮放大缩小、鼠标移动图片位置、esc键关闭浮层(具体效果可以随便点一个文章中的图片确认)
更详细的代码可见 blotter_page/components/image.tsx

export const setImageLightbox = (img: HTMLImageElement) => {
  const parent = img.parentElement;
  const { src, alt, title } = img;
  parent.removeAttribute('href');
  parent.onclick = () => CreateBox({ src, alt, title });
};

function CreateBox(props: { src: string; alt?: string; title?: string }) {
  const { src, alt = '', title = '' } = props;
  const body = document.body;
  const top = window.scrollY;
  body.style.position = 'fixed';
  body.style.top = `${-top}px`;

  const box = document.createElement('div');
  box.className = 'image-lightbox';
  document.body.appendChild(box);

  const close = document.createElement('span');
  close.innerText = '×';
  box.appendChild(close);

  const p = document.createElement('p');
  p.innerText = !!title ? title : alt;
  if (!!p.innerHTML) box.appendChild(p);

  const img = document.createElement('img');
  img.src = src;
  img.alt = alt;
  img.title = title;
  box.appendChild(img);

  const ratio = img.naturalWidth / img.naturalHeight;
  var grabbing = false;
  var offsetX = 0;
  var offsetY = 0;
  var mouseX = 0;
  var mouseY = 0;

  img.onmousedown = (e) => {
    img.ondragstart = () => false;
    img.style.cursor = 'grabbing';
    grabbing = true;
    mouseX = e.offsetX;
    mouseY = e.offsetY;
  };
  img.onmousemove = (e) => {
    if (grabbing) {
      offsetX += e.offsetX - mouseX;
      offsetY += e.offsetY - mouseY;
      img.style.marginLeft = `${offsetX}px`;
      img.style.marginTop = `${offsetY}px`;
    }
  };
  img.onmouseup = (e) => {
    img.style.cursor = 'grab';
    grabbing = false;
  };
  img.onclick = (e) => {
    e.stopPropagation();
  };

  const judgeWheel = (e: WheelEvent) => {
    const height = img.height - e.deltaY;
    img.style.maxHeight = `unset`;
    img.style.maxWidth = `unset`;
    img.style.height = `${height}px`;
    img.style.width = `${height * ratio}px`;
  };
  const judgeKey = (e: KeyboardEvent) => {
    if (e.keyCode === 27) remove();
  };
  const remove = () => {
    document.removeEventListener('keydown', judgeKey);
    document.removeEventListener('mousewheel', judgeWheel);
    box.remove();

    body.style.position = '';
    body.style.top = '';
    window.scrollTo(0, top);
  };

  document.addEventListener('keydown', judgeKey);
  document.addEventListener('mousewheel', judgeWheel);

  box.onclick = remove;
  close.onclose = remove;
}

横向瀑布流

如果内容本身不是图片,或者不希望固定宽高,那么就要使用瀑布流来进行展示。
最容易的是横向的瀑布流——所有内容的高度相同,但是高度随机。

只需要使用flex即可实现该功能。
换一种更简单的描述而言,横向瀑布流其实只是自适应宽度(自动切换到下一行),可以通过调整浏览器大小来查看下面色块的自适应功能。

<div style="display:flex;width:100%;flex-wrap:wrap">
    <div style="height:50px;width:100px;background:#ff0000"></div>
    <div style="height:50px;width:50px;background:#ffff00"></div>
    <div style="height:50px;width:30px;background:#123456"></div>
    <div style="height:50px;width:200px;background:#00ff00"></div>
    <div style="height:50px;width:20px;background:#0000ff"></div>
    <div style="height:50px;width:30px;background:#66ccff"></div>
    <div style="height:50px;width:20px;background:#abcdef"></div>
    <div style="height:50px;width:10px;background:#fedcba"></div>
    <div style="height:50px;width:100px;background:#567890"></div>
    <div style="height:50px;width:120px;background:#098765"></div>
    <div style="height:50px;width:90px;background:#9fca3f"></div>
    <div style="height:50px;width:100px;background:#ff0000"></div>
    <div style="height:50px;width:50px;background:#ffff00"></div>
    <div style="height:50px;width:30px;background:#123456"></div>
    <div style="height:50px;width:200px;background:#00ff00"></div>
    <div style="height:50px;width:20px;background:#0000ff"></div>
    <div style="height:50px;width:30px;background:#66ccff"></div>
    <div style="height:50px;width:20px;background:#abcdef"></div>
    <div style="height:50px;width:10px;background:#fedcba"></div>
    <div style="height:50px;width:100px;background:#567890"></div>
    <div style="height:50px;width:120px;background:#098765"></div>
    <div style="height:50px;width:90px;background:#9fca3f"></div>
</div>

纵向瀑布流

纵向瀑布流分为两种实现思路

  • 使用column-count样式
  • 使用 JavaScript 重新调整

CSS设置列数

如下所示,只需要简单的设置column-count:2即可快速将原本一列的内容变为两列内容(实际使用中高度可能还需要进一步设置),整体效果很好。

该方案存在的最大问题是,其顺序是先填满一列,再填满另一列。在很多情况下,要展示的内容可能存在先后顺序,因此其并不适用。

<div style="width:100%;column-count:2">
    <div style="width:100%;height:50px;background:#ff0000"></div>
    <div style="width:100%;height:70px;background:#ffff00"></div>
    <div style="width:100%;height:20px;background:#123456"></div>
    <div style="width:100%;height:130px;background:#00ff00"></div>
    <div style="width:100%;height:40px;background:#0000ff"></div>
    <div style="width:100%;height:150px;background:#66ccff"></div>
    <div style="width:100%;height:60px;background:#abcdef"></div>
    <div style="width:100%;height:20px;background:#fedcba"></div>
    <div style="width:100%;height:30px;background:#567890"></div>
    <div style="width:100%;height:110px;background:#098765"></div>
    <div style="width:100%;height:35px;background:#9fca3f"></div>
</div>

JS绝对定位

最完美(复杂)的解决方案,采用这种手段可以绝对精确地对位置进行定位。
大致思路是:

  1. 查询到所有需要瀑布流展示的元素
  2. 设定多个列并初始化高度为 0
  3. 按照顺序遍历元素
  4. 找到高度最小的列
  5. 设置当前元素的top为当前列的高度,left根据列的序号计算
  6. 当前列高度增加当前元素的高度数值
  7. 更新瀑布流容器的高度为各列的最大值

下面是在测试过程中使用的代码(但最后因为刷新问题弃用了)

function waterfall(containerID, columnCount) {
  const container = document.getElementById(containerID);
  if (!!!container) return;
  const posts = container.children;
  if (posts.length <= 1 || columnCount <= 1) return;

  const width = container.clientWidth / columnCount;

  var height = Array(columnCount).fill(0);

  for (var i = 0; i < posts.length; i++) {
    var p = posts[i] as HTMLElement;
    p.style.width = `${width}px`;
    p.style.position = 'absolute';
    p.style.padding = '10px';
  }

  const element = document.querySelector('selector');
  if (element) {
    const clone = element.cloneNode(true);
    element.replaceWith(clone);
  }

  // wait dom redraw
  setTimeout(() => {
    for (var i = 0; i < posts.length; i++) {
      var p = posts[i] as HTMLElement;

      var idx = 0;
      height.reduce((a, b, i) => {
        if (a > b) {
          idx = i;
          return b;
        } else return a;
      });

      p.style.left = `${width * idx}px`;
      p.style.top = `${height[idx]}px`;

      height[idx] += p.clientHeight;
    }

    container.style.position = 'relative';
    container.style.height = `${height.reduce((a, b) => (a > b ? a : b))}px`;
  }, 0);
}

某种意义上来说,这种方案可以高度自定义,可以根据实际需求对元素进行排序。但是存在一个非常致命的问题:DOM 刷新

从代码中可以看到,改代码需要获取元素的高度,而对于很多元素,由于换行问题,在不同的宽度下高度是不同的。由于定位依赖于高度,因此需要确保读到的高度就是最终显示的高度。
而如果使用 JS 操作 DOM,实际上并不是实时刷新的。比如在设置item.style.width = "100px"后,实际上 DOM 并没有变化(该问题在所有图形界面、以及三大前端框架中都存在,目的是为了避免重新刷新)。当然,解决办法也很简单,直接setTimeout即可,根据查询,大部分建议是设置20ms的等待,在回调中进行后面的操作。但我这里似乎0ms的等待即可刷新 DOM。

但是这部分的问题并不在于刷新 DOM,而是由于 DOM 刷新问题,即使只有短短一瞬间,但是仍然能看到屏幕闪烁。如果是页面加载过程中影响不太大,但是如果是页面加载完毕后,会显得很突兀。
因此,在实际使用中我弃用了上述方案,而是换了一个类似的思路实现。

博客瀑布流最终方案

根据上述内容,首先确定了以下要求:

  • 显示效果应该尽可能保证两列高度一致(因此不能直接按照数据序号的奇偶性分成两列)
  • 数据应该尽可能保证顺序,但为了上一点,可以略微调整顺序(因此上述的设置列数的纵向瀑布流不可用)
  • 要显示的是文章的信息卡片,由于部分文章存在图片且摘要长度不可控,高度是不确定的(因此固定大小不可用)

综合上述三点,借用了上面绝对定位的思路,对其进行了简单的修改。由于文章卡片高度的主要影响因素在于有无图片,因此将所有卡片简化为两种高度:

  • 无图片:高度为 11
  • 有图片:高度为 22

接着按顺序将其填充到高度最低的列中。
按照这种思路,可以保证最后结果两侧高度差最大为 11,并且显示顺序一定是上面的顺序优先于下面的顺序,同一行内可能存在从左往右或从右往左。

最后则是对最下面一行的处理。按照阅读习惯,左侧的列应该是不短于右侧的列的。

因此如果发现最后高度左侧小于右侧,需要进行调整。可能的情况共有如下几种:

1 2 3 4

对于情况 1133 只需要直接将右侧的最后一个放至左侧即可。而对于情况 22,则可以将两者交换。
需要额外考虑的是情况 44,将右侧从下往上的第一个高度为 11 的元素移至左侧对应位置(需要结合原本顺序来判断对应位置)。