瀑布流实现思路
瀑布流是一种用于大量图片显示的方式,由于图片大小往往难以确定,因此可以固定图片的宽度或是宽度,而后沿着非固定的方向无限扩展。
如果以简笔画的形式画一个瀑布,可以看出瀑布中的水流以不同长度的竖线显示。如果把竖线看作我们要展示的内容,那么整个结果就和简笔画下的瀑布很像。
如图,这时 Google 图片页面,图片结果采用横向的瀑布流显示,而结果中的很多内容(比如第一张)则是竖向瀑布流。
固定大小显示
最简单的思路是固定所有的图片大小,然后以类似表格的方式进行展示。这样在展示时只需要顺序显示即可。
如果显示内容是文字或其他内容通常较容易实现,只需要设置宽和高即可,但是如果是任意大小的图片则较难实现。
如果单纯设置宽或高,另一端自动处理,则可能会导致出现白边(如下面所示)
针对该问题,有两种解决思路:
- 通过 js 获取图片大小,并且在加载完毕后计算后重新设置宽高
- 将图片设为背景,且以
cover
模式显示
重新计算图片大小
在老版本的博客中,采用的是这种思路:
- js 选取到需要操作的图片
- 获取图片的高度和宽度
- 将图片宽或高拉伸至父容器相应值
- 计算另一个维度的偏移量
如偏移高度方向,只需要设置margin-top
为
父容器还需要设置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绝对定位
最完美(复杂)的解决方案,采用这种手段可以绝对精确地对位置进行定位。
大致思路是:
- 查询到所有需要瀑布流展示的元素
- 设定多个列并初始化高度为 0
- 按照顺序遍历元素
- 找到高度最小的列
- 设置当前元素的
top
为当前列的高度,left
根据列的序号计算 - 当前列高度增加当前元素的高度数值
- 更新瀑布流容器的高度为各列的最大值
下面是在测试过程中使用的代码(但最后因为刷新问题弃用了)
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 刷新问题,即使只有短短一瞬间,但是仍然能看到屏幕闪烁。如果是页面加载过程中影响不太大,但是如果是页面加载完毕后,会显得很突兀。
因此,在实际使用中我弃用了上述方案,而是换了一个类似的思路实现。
博客瀑布流最终方案
根据上述内容,首先确定了以下要求:
- 显示效果应该尽可能保证两列高度一致(因此不能直接按照数据序号的奇偶性分成两列)
- 数据应该尽可能保证顺序,但为了上一点,可以略微调整顺序(因此上述的设置列数的纵向瀑布流不可用)
- 要显示的是文章的信息卡片,由于部分文章存在图片且摘要长度不可控,高度是不确定的(因此固定大小不可用)
综合上述三点,借用了上面绝对定位的思路,对其进行了简单的修改。由于文章卡片高度的主要影响因素在于有无图片,因此将所有卡片简化为两种高度:
- 无图片:高度为
- 有图片:高度为
接着按顺序将其填充到高度最低的列中。
按照这种思路,可以保证最后结果两侧高度差最大为 ,并且显示顺序一定是上面的顺序优先于下面的顺序,同一行内可能存在从左往右或从右往左。
最后则是对最下面一行的处理。按照阅读习惯,左侧的列应该是不短于右侧的列的。
因此如果发现最后高度左侧小于右侧,需要进行调整。可能的情况共有如下几种:
1 | 2 | 3 | 4 |
---|---|---|---|
对于情况 和 只需要直接将右侧的最后一个放至左侧即可。而对于情况 ,则可以将两者交换。
需要额外考虑的是情况 ,将右侧从下往上的第一个高度为 的元素移至左侧对应位置(需要结合原本顺序来判断对应位置)。