;

使用纯 CSS 实现 500px 照片列表布局

文章很长,因为介绍了如何一步一步进化到最后接近完美的效果的,不想读的同学可以直接跳到最后一个大标题之后看代码、demo 及原理就好,或者也可以直接看下面这个链接的源代码。不过还是建议顺序读下去,因为后面的原理需要前面的内容做为铺垫,主要是在处理边角问题上。

先看下效果,要不然各位可能没动力读下去了,实在是有点长,可以试着 resize 或者 zoom 一下看看动态效果: Cats

image

PS:文中的一些 demo 为了方便展示源代码用了 jsbin,但 jsbin 偶尔抽风会显示不出效果,试着在源代码编辑框里不改变代码意思的情况下编辑一下(比如在最后打一下回车)应该就可以了,或者查看一下你浏览器的翻墙设置,因为里面引入了 Google CDN 上的文件,有可能是因为 js 加载不成功导致的。

PS2:Demo 中用到的所有图片都来自 http://500px.com 网站,图片版权归原作者所有。图片地址末尾的数字即为其在 http://500px.com 上的 id,如果你喜欢某张图片,可以通过 https://500px.com/photo/[id]/ 这个地址访问到图片原始页面。

好了,正文开始。

开始之前,先对比一下三种比较常见的图片布局的差异

  1. 花瓣

    • 此种布局为比较常见的等宽布局,所有图片的宽度是一样的 由于图片是等比拉伸(等比拉伸的意思是图片的宽和高变化相同的比例,也就是图片展示的宽高比与原始宽高比一致),而每张图片的宽度又是一样的,所以图片的高度就必然不一样了
    • 这种布局的缺点是,由于每张图片展示的高度的不一致,图片不是按一般的阅读顺序展示的,因为可能连续的多张图片顶部的高度不一样,而人眼又习惯于水平扫描,所以用户就有可能漏看某些照片
    • 图片瀑布的底部一般是对不齐的
    • 虽然底部很难完全对齐,但使用 JS 对图片顺序进行重排能够让底部尽量对齐,所以此种布局在 reflow 的时候(比如resize,zoom)必然要有 JS 的参与
  2. Google Photos,500px,图虫等,以 Google Photos 为代表的即不等宽也不等高的图片布局有如下特点:

    • 图片也没有被非等比拉伸
    • 每行的图片在水平方向上也占满了屏幕,没有多余的空白
    • 因为以上两个条件,所以每行的图片高度必然会不一样,否则无法做到图片在水平方向上占满屏幕
    • 图片是按顺序展示的,比较符合人眼阅读顺序,Google Photos 因为照片有拍摄时间这个属性,必须满足这个条件
    • 底部是对齐的
    • Google Photos 的布局中,当某几个日期的照片太少时,多个日期的照片会合并展示在同一行,当然这不是本文讨论的重点
  3. Instangram

以上介绍的前两种布局都有一个共同点,那就是图片没有经过非等比拉伸,也就是说图片里的内容没有变形,也没有被裁剪,只是放大或者缩小,这是目前图片类应用在展示图片上的一个趋势,应该说,很少有专做图片的网站会把照片非等比拉伸显示(变形拉伸真的给人一种杀马特的感觉…),最次的展示方式也就是把图片裁剪成正方形后展示在一个正方形的区域里,类似于正方形容器的 background-size: cover; 的效果。

另外,在花瓣的布局中,比较宽的图片展示区域会比较小;而在第二种布局中,则是比较高的图片展示区域会比较小。

但是,在第一种布局中,因为宽度是定死了的,所以高宽比小到一定程度的图片,显示区域会非常小。而在第二种布局中,因为不同行的高度是不一样的,如果比较高的图片出现在比较高的行,还是有可能展示的稍大些的。

总体来说,以 Google Photos 为代表的图片布局,在显示效果上更优。关于如何使用 JS 来完成 Google Photos / 500px 布局的算法,这里就不讨论了,读者可以自己思考一下~

思考完成后可以看看这个页面对这个布局的动态演示,打开页面,等图片全部加载完成后 点击页面顶部的 layout 按钮。

image

Demo 演示了如下的布局方式:先按照相同的高度把图片排列起来,然后按行对每行的图片进行等比放大,放大到当前行的所有图片正好跟容器两边对齐,布局完成。

OK,下面根据上面的分析稍微总结一下评判图片布局优劣的一些标准:

第一次看到类似 Google Photos 照片列表的布局已经不记得是在哪里了,当时只是觉得这种布局肯定需要 JS 参与,因为每行图片高度相同的情况下不可能那么恰到好处的在容器两端对齐,且所有图片之间的间距大小也一样(如果间距大小不一样但两端对齐,可以使用 inline 的图片加上 text-justify 来实现,在图片较小的时候(比如搜索引擎的图片结果)也不失为一种选择)。然而通过观察,发现每行的高度并不相同,就确认了必然需要 JS 参与才能完成那样的布局。

然而当越来越多的开始网站使用这样的布局时,做为一个热衷于能用 CSS 实现就不用 JS 的前端工程师,我就在考虑,能否仅用 CSS 实现这样的布局呢?尤其是不要在 resize 时重新计算布局。

在经过一些尝试后,我发现可在一定程度上用纯 CSS 实现类似的布局。这里说的一定程度上仅使用 CSS 实现布局,我的意思是:布局一但渲染完成,布局后序的 resize,zoom 都可以在没有 JS 参与的情况下保持稳定,也就是说,首次的渲染甚至可以通过服务器完成,整个过程可以没有 JS 参与,所以说成是用纯 CSS 实现也不过分。

实现过程

下面就来介绍一下我是如何只通过 CSS 一步一步实现的这个布局的:

一开始,我们将图片设置为相同的高度:

<style>
  img {
    height: 200px;
  }
</style>
<div>
   <img src="" />
   <img src="" />
   <img src="" />
   <img src="" />
   <img src="" />
</div>

这样并不能让图片在水平方向上占满窗口,于是我想到了 flex-grow 这个属性,让 img 元素在水平方向变大占满容器,整个布局也变成了 flex 的了:

div {
  display: flex;
  flex-wrap: wrap;
}
img {
  height: 200px;
  flex-grow: 1;
}

把 flex container 的 flex-wrap 设置为 wrap,这样一行放不下时会自动折行,每行的图片因为 grow 的关系会在水平方向上占满屏幕,效果看上去已经很接近我们想要的了,但每张图片都会有不同程度的非等比拉伸,图片的内容会变形,这个好办,可以用 object-fit: cover; 来解决,但这么一来图片又会被裁剪一部分。

最终 demo

image

注意图片都被裁剪了,尤其第一张。

不过上述的 DOM 结构显然是没办法在实际中使用的:

为 img 标签增加父元素

接下来我们把 DOM 结构改成下面这样的:

<section>
   <div>
      <img src=""/>
   </div>
   <div>
      <img src=""/>
   </div>
   <div>
      <img src=""/>
   </div>
   <div>
      <img src=""/>
   </div>
</section>

我们为图片增加了一个容器。依然把图片设置为定高,如此一来,每个 div 将被图片撑大,这时如果我们给 div 设置一个 flex-grow: 1; ,每个 div 将平分每行剩余的空间,div 会变宽,于是图片宽度并没有占满 div。

如果我们将 img 的 width 设置为 100% 的话,在 IE 和 Firefox 下,div 已经 grow 的空间将不会重新分配(我觉得这是个很有意思的现象,图片先把 div 撑大,div grow 之后又把图片拉大),但在 Chrome 下,为 img 设置了 width: 100%; 之后,grow 的空间将被重新分配(我并没有深究具体是如何重新分配的),重新分配后的结果是每个容器的宽度更加接近,这并不是我们想要的。

试了几种样式组合后,我发现把 img 标签的 min-width 和 max-width 都设置为 100% 的话,在 Chrome 下的显示效果就跟 IE 和 Firefox 一样了。最后我们将 img 的 object-fit 属性设置为 cover,图片就被等比拉伸并占满容器了,不过与前一种布局一样,每行的高度是一样的,另外图片只显示了一部分,上下两边都被裁剪掉了一些。

上面布局完整的 demo

image

看起来跟前一个布局没什么两样,但是现在我们可以在容器内部加上一些额外的标签来显示图片信息了。

在这种布局下,如果图片高度设置的比较小,布局已经没有什么大碍,因为图片越小就意味着每行的图片越多而且剩余的空间越小并且剩余空间被更多的图片瓜分,那每个容器的宽高比就越接近图片的真实宽高比,多数图片都能显示出其主要部分。

棘手的最后一行

唯一的问题是最后一行,当最后一行图片太少的时候,比如只有一张,因为 grow 的关系,它将占满一整行,而高度又只有我们设置的 200px,这时图片被展示出来的部分可能是非常少的,更不用说如果图片本身上比较高,而展示区域又比较宽的情况了。

针对这种情况,我们可以让列表最后的几张图片不 grow,这样就不至于出现太大的变形,我们可以算出每行的平均图片数量,然后用下面的 CSS 阻止“最后一行”的图片 grow:

div:nth-last-child(5),
div:nth-last-child(4),
div:nth-last-child(3),
div:nth-last-child(2),
div:nth-last-child(1) {
  flex-grow: 0;
}

然后配合 media query,在屏幕不同宽度时,让“最后一行”的元素个数在窗口宽度变化时也动态变化:

@media (max-width: 1000px) and (min-width: 900px) {
  div:nth-last-child(5),
  div:nth-last-child(4),
  div:nth-last-child(3),
  div:nth-last-child(2),
  div:nth-last-child(1) {
    flex-grow: 0;
  }
}
@media (max-width: 1100px) and (min-width: 1000px) {
  div:nth-last-child(7),
  div:nth-last-child(6),
  div:nth-last-child(5),
  div:nth-last-child(4),
  div:nth-last-child(3),
  div:nth-last-child(2),
  div:nth-last-child(1){
    flex-grow: 0;
  }
}

上面的代码写起来是相当麻烦的,因为每个屏幕宽度范围内又要写多个 nth-last-child 选择器,虽然我们可以用预处理器来循环迭代出这些代码,但最终生成出来的代码还是有不少重复。

有没有办法只指定最后多少个元素就行了,而不是写若干个 nth-last-child 选择器呢?其实办法也是有的,想必大家应该还记得 CSS 的 ~ 操作符吧,a ~ b 将选择在 a 后面且跟 a 同辈的所有匹配 b 的元素,于是我们可以这么写:

div:nth-last-child(8),
div:nth-last-child(8) ~ div {
  flex-grow: 0;
}

先选中倒数第 8 个元素,然后选中倒数第 8 个元素后面的所有同辈结点,这样,就选中了最后的 8 个元素,进一步,我们可以直接将选择器改写为 div:nth-last-child(9) ~ div,就可以只用一个选择器选择最后的 8 个元素了。

上面的几种选择尾部若干元素的不同选择器,实际上效果是不太一样的:

选择最后若干张图片这种方式还是不够完美,因为你无法确定你选择的 flex item 一定在最后一行,万一最后一行只有一张图片呢,这时倒数第二行的前几张图片就会 grow 的很厉害(因为倒数第二行的后面 n-1 张都不 grow),或者最后两行图片的数量都没有这么多,那倒数第二行就没有元素 grow 了,就占不满这一行了,布局就会错乱。

那么有没有办法只让最后一行的元素不 grow 呢?一开始我也了很多方法,甚至在想有没有一个 :last-line 伪类什么的(因为有个 :first-line),始终没有找到能让最后一行不 grow 的方法,然而最后竟然在搜索一个其它话题时找到了办法:

那就是在最后一个元素的后面再加一个元素,让其 flex-grow 为一个非常大的值比如说 999999999,这样最后一行的剩余空间就基本全被这一个元素的 grow 占掉了,其它元素相当于没有 grow,更进一步,我们可以用伪元素来做这件事(不过 IE 浏览器的伪元素是不支持 Flexbox 属性的,所以还是得用一个真实的元素做 placeholder):

section::after {
  content: '';
  flex-grow: 999999999;
}

到这里,我们基本解决这个布局遇到的所有问题。

Demo,resize 或者 zoom 然后观察最后一行的图片。

image

现在这种布局下最后一行的图片其实总是显示完全且没有拉伸和变形的。

但还有最后一个问题,同前一种布局一样,如果你在线上去加载使用这种方式布局的网页,你会发现页面闪动非常厉害,因为图片在下载之前是不知道宽高的,我们并不能指望图片加载完成后让它把容器撑大,用户会被闪瞎眼。其实真正被闪瞎的可能是我们自己,毕竟开发时要刷新一万零八百遍。

所以,我们必须预先渲染出图片的展示区域(实际上几乎所有图片类网站都是这么做的),所以这里还是要小用一些 js,这些工作也可以在服务器端做,或者是用任何一个模板引擎(下面的代码使用了 angular 的模板语法)。

这个布局一旦吐出来,后续对页面所有的动作(resize,zoom)都不会使布局错乱,同时也不需要 JS 参与,符合前文所说的用纯 CSS 实现:

<style>
  section {
    padding: 2px;
    display: flex;
    flex-wrap: wrap;
    &::after {//处理最后一行
      content: '';
      flex-grow: 999999999;
    }
  }
  div {
    margin: 2px;
    position: relative;
    height: 200px;
    flex-grow: 1;
    background-color: violet;
    img {
      max-width: 100%;
      min-width: 100%;
      height: 200px;
      object-fit: cover;
      vertical-align: bottom;
    }
  }
</style>
<section>
    // 下一行的**表达式**是计算当图片以 200 的高度等比拉伸展示时宽度的值
    <div ng-repeat="img in imgs" style="width:px;"></div>
</section>

我们给图片的父容器设置与图片比例相同的初始大小,然后为它设置 flex-grow: 1; 等待它 grow,最终的效果将与上面种布局是一样的,但可以看到图片在加载过程中布局是没有抖动的:

image

到这里,我们总算实现了图片的非等宽布局。Demo,注意 HTML 模板里计算宽度的表达式。

那么这个布局的展示效果究竟如何呢?

实际上我专门写了代码计算每张图片被展示出来的比例到底有多少:在图片高度为 150px 左右时,约有三分之一的图片展示比例在 99% 以上。最差的图片展示比例一般在 70% 左右浮动,平均每张图片展示比例在 90% 以上。图片越矮/屏幕越大,展示效果会越好;图片越高/屏幕越小,展示效果就越差。

因为这种方案最后也被我抛弃了,所以就不放计算展示比例的 demo 了。

看到这里,你应该是觉得被坑了,因为这并没有实现标题中说的 Google Photos / 500px 照片列表的布局

因为每行的高度是一样的,就必然导致大部分图片没有完全展示,跟 Google Photos / 500px 那些高大上的布局根本就不一样!

其实正文从现在才正式开始,下面介绍的方式也是我在实现了上面的布局后很久才想出来的,前面的内容只是介绍一些解决边角问题用的。

可以看到,前面的实现方式并没有让每张图片的内容全部都显示出来,因为每行的高度是一样的,而想要实现 500px 的布局,每行图片的高度很多时候是不一样的。

一开始我觉得,CSS 也就只能实现到这种程度了吧,直到我遇到了另一个需求:

我想用一个正方形的容器展示内容,并且希望无论浏览器窗口多宽,这些正方形的容器大小在一个范围内并且总是能铺满窗口的水平宽度而不留多余的空间(除了元素之间的空白),乍一看这个需求可能需要 JS 参与:读出当前浏览器窗口的宽度,然后计算正方形容器的 size,然后渲染。

可以看这个 demo,试着拉动一下窗口宽度然后看效果。

image

拉动过程中可以看到,正方形的容器会实时变大,大到一定程度后又变小让每行多出一个正方形容器。 如果只看这一个 demo,可能各位不一定能一下子想到如何实现的,但如果只有一个正方形容器,它的边长总是浏览器宽度的一半,想必很多人都知道的,长宽比固定的容器要怎么实现吧?

我们知道(事实上很多人都不确定,所以这可以做为一个面试题),margin 和 padding 的值如果取为百分比的话,这个百分比是相对于父元素的宽度的,也就是说,如果我给一个 block 元素设置 padding-bottom(当然,也完全可以是 padding-top,甚至可以两个一起用)为 100% 的话,元素本身高度指定为 0,那么这个元素将始终是一个正方形(因为它的高度总是跟父元素的宽度一样,而宽度 100% 也跟父元素的宽度一样),并且会随着容器宽度的变化而变化,想要改变正方形的大小,只需要改变父容器的宽度就可以了:

看这个 demo,拉动窗口可以看到色块会变大,但始终保持正方形。当然,如果参照物是浏览器窗口,那么在现代浏览器中,这个效果可以用 vw / vh 实现;但如果参照物不是浏览器窗口,就只能用垂直 padding 来实现了。

于是我就想到,如果不给 flex item 的元素设置高度,而是让其被一个子元素撑开,并且这个子元素的宽度是100%,padding-bottom 也是 100%,那么 flex item 以及这个用来撑大父元素的子元素就会同时保持为正方形了,于是就实现了上面的那种正方形阵列布局。

但仅仅这样还不够,最后一行又会出问题,如果最后一行的元素个数跟前面的行不一样的话,它们虽然会保持正方形,但是因为 grow 的关系,会比较大,那如何保证最后一行的元素也跟前面的行大小相同呢,这时使用一个元素并设置很大的 flex-grow 让其占满最后一行剩余空间的做法已经不可行了,因为我们需要让最后一行的元素恰到好处的跟前面行的元素 grow 时多出一样的空间。

其实解决方案也很简单,把最后一行不当最后一行就行了!此话怎讲呢?

在最后添加多个占位符,保证可见的最后一个元素永远处于视觉上的最后一行,而让占位符占据真正的最后一行,然后把这些占位符的高度设置为 0 。具体添加多少个占位符呢?显然是一行最多能显示多少个元素,就添加多少个了,比如前面的 demo 就添加了 8 个占位符,你可以在源代码里面看一下。另外为了更好的语义,其实可以用其它的标签当做占位符,这样就不用写出上面那种晦涩的选择器了。

这样一来,始终能占满水平宽度的正方形阵列布局也实现了。

本来我以为,到这里就结束了,即使用上最先进的 Flexbox 布局,CSS 也无法实现图片不裁减不拉伸且对齐的完美布局。

- FAKE EOF -

4 月 2 号的早上我醒来的时候,突然想到,既然可以让一个容器始终保持正方形,那岂不是也可以让这个容器始终保持任何比例?显然是可以的,只要我们把用于撑大父元素的那个元素的 padding-bottom 设置为一个我们想要的值就可以了!这样一来,说不定可以实现图片布局中,所有图片都完全展示且占满水平宽度的布局(也就是 Google Photos / 500px 的布局)!

当然,前面提到过,由于图片加载缓慢,图片布局方案往往都会提前知道图片的宽高来进行容器的预渲染,然后图片加载完成后直接放进去。

所以这里我们仍然需要用 JS 或者服务器来计算一下图片的宽高比例,然后设置到 padding-bottom 上面去,以保证容器的宽高比始终是其内部图片的宽高比。

我们先让所有图片以 200px 的高度展示,写出如下模板代码:

<div style="display:flex;flex-wrap:wrap;">
  <div ng-repeat="img in imgs" style="width:px;">
  这个公式计算了图片高度为 200 时的宽度的值
    <div style="padding-bottom:%"></div>
    上面这个公式让此元素及其父元素的比例与图片原始比例相同,因为是垂直方向的 padding,所以是高度除以宽度,又因为是百分比,所以除以 100
  </div>
</div>

在上面布局中,因为 flex-wrap 的关系,每一行不够放的时候后面的内容就会折行,并且留出一些空白,每个容器的宽高比都是跟未来放入其内部的图片的宽高比是一样的,为了便于展示,我将图片大小设置为容器大小的四分之一,应该明显可以看出图片的右下角处于容器的中心位置。

Demo

image

下一步,我们只需要让所有的容器元素都 grow 就可以了,那么是把所有的元素的 flex-grow 设置为 1 吗?

实际上如果设置了并看了效果,我们会发现并不是,因为我们希望每行元素在 grow 的时候,保持原有比例且高度相同。

Demo

image

可以看到如果给所有的 flex item 设置 flex-grow: 1; 的话,容器跟图片的比例并不一致(虽然比较接近),这里我将图片宽度设置了为容器的宽度以便观察。

通过一些简单的计算我们会发现,在每行的图片中,每张图片在水平方向上占用的宽度正好是其宽度在这一行所有图片宽度之和中所占的比例

在前面不 grow 的情况下,每张图片的容器的宽度已经是按比例分配了,而想要实现前一行描述的分配方式,每行的剩余空间,我们希望它仍然按照目前容器宽度所占的比例来分配,于是,每个容器的 grow 的值,正好就是它的宽度,只不过不要 px 这个单位。

最终的代码如下:

<div>flex,wrap
  //实际上因为 flex-grow 是按比例分配,所以第二个公式里的 *200 可以不要,这要我们就只需要改前一个 200 了
  <div style="width:px;flex-grow:" ng-repeat="img in imgs">
    <div style="padding-bottom:%"></div>
  </div>
</div>

这样一来,容器会占满当前行,并且保持与未来内部所放入的图片相同的宽高比:

Demo,可以看到,每张图片都被完整展示出来了:

image

至于最后一行怎么处理,前面已经介绍过了,用一个 flex-grow 极大的元素占满剩余空间就可以了。

这种布局在渲染完成后,你可以放心的 resize 和 zoom,布局都不会错乱,而且没有 JS 的参与。

到这里,我们终于实现了类似 Google Photos / 500px 网站的图片布局。

总结一下这个方案的原理

这种布局的优点:

最后说一下这种方案的一些缺点:

关于降级

由于 IE 9 都是不支持 Flexbox 的,所以这个方案必然需要优雅降级。在不支持的浏览器上,让图片都以正方形展示应该也不会太差,然后用 float 或者 inline-block 来折行,这里就不细说了。

最后,本文其实只实现了 500px 的图片的布局(即所有图片在一个容器里),实际上并没有实现 Google Photos 的布局,Google Photos 的布局比 500px 的还要复杂很多,仔细观察就会发现,其是按日期排列并且不同日期在同一行显示的时候也可以两边对齐,这种布局后来我也有了纯 CSS 的解决方案。如果各位意犹未尽,可以在文后留言,我会将实现方案再整理一篇文章出来~

本文到此结束,谢谢围观!文中如有纰漏之处,还请各位大神留言指正~

最后的最后,广告时间:

本人决定创业开办前端培训班,地点杭州,9月20左右开课,费用优惠包住宿,详情请点击我的专栏文章:下定决心,就是要开前端培训班,如果有朋友想学,欢迎介绍。如果没有也希望你能进去点个赞让更多人看到~写文章不易,创业更不易~先行谢过了!