profilBlog Server 测试服

cover

CSS 实现类似 Google Photos 的图片布局

学习笔记
编辑

今天在 medium 看到一篇关于 Google Photos 的文章,里面详细的介绍了谷歌的一名工程师是如何设计 Google Photos 的布局的,文章地址:Building the Google Photos Web UI

观察了一下 Google Photos 的布局,发现它的布局非常的有意思,有以下特点:

  • 一行图片的宽度始终刚好占满容器宽度
  • 图片全部保持原始比例,不会被拉伸
  • 图片的高度不一,但是每一行的高度是一样的
  • 图片顺序不会被改变

感觉很神奇,但是那篇文章里的实现方法太过复杂,虽然性能非常好,但是我觉得可以用更简单的方法来实现,于是就有了这篇文章。

我所设想的实现需要满足:

  • 一行图片的宽度始终刚好占满容器宽度
  • 除非比例十分极端,图片保持原始比例,不会被拉伸
  • 图片的高度不一,但是每一行的高度是一样的
  • 图片顺序不会被改变
  • 前端纯 CSS 实现,不需要 JS
  • 性能可以一般,但代码必须简单

实现

首先我在网上搜索了一下,发现有人已经思考过这个问题。比如这篇文章:使用纯 CSS 实现 500px 照片列表布局。虽然文中的做法可以实现,但是我还是觉得有点复杂。

经过一段时间思考,我想到一个使用暴力实现的方法,虽然不够优雅,但是实现起来非常简单,而且性能也不错。

实现思路主要依靠两点:object-fit 属性和 Flex 布局的 flex-grow

step 1: 初步可行

首先定义一个图片的初始最小宽度,比如 200px,根据图片宽高比进行调整:

const initWidth = 200 * image.width / image.height;

这里使用了 JavaScript 计算,但实际上这部分计算应该在服务端进行,所以用什么语言实现都是一样的。 也并没有违背前端纯 CSS 实现的要求,因为这部分计算可以在服务端生成 CSS 代码,然后直接返回给浏览器。 看到后面的例子就会明白。浏览器直接禁用 JavaScript 也不会影响布局。

这样图片在不缩放的情况下是符合宽高比的,但是多张图片放到一行里不一定能沾满一行,需要进行调整。使父元素成为 Flex 容器,设置 flex-wrap: wrap,这样图片就会自动换行了。然后设置图片的 flex-grow 属性,这样图片就会自动填充父元素的宽度。

这里的 flex-grow 的取值非常重要,必须让这一行的每一张图片在宽度增加的同时,高度继续保持一致。显然,如果每一张图片的 flex-grow 都是其自身的宽高比就行了。

const flexGrow = image.width / image.height;

下面我们总结一下,主要代码如下:

<div class="container">
    <div class="image-box" style="width: var(--calc-form-server); flex-grow: ${--calc-form-server}">
        <img width="calc-from-server" height="calc-from-server" class="image"/>
    </div>
    <div class="image-box" style="width: var(--calc-form-server); flex-grow: ${--calc-form-server}">
        <img width="calc-from-server" height="calc-from-server" class="image"/>
    </div>
    <div class="image-box" style="width: var(--calc-form-server); flex-grow: ${--calc-form-server}"
        <img width="calc-from-server" height="calc-from-server" class="image"/>
    </div>
</div>
.container {
    display: flex;
    flex-wrap: wrap;
    outline: 1px solid blue;
    /* outline 仅用于观察 */
}

.image-box {
    max-width: 100%;
    outline: 1px solid green;
    margin: 0.5rem;
    /* outline 和 margin 仅用于观察 */
}

.image {
    display: block;
    height: 100%;
    width: 100%;
    outline: 1px solid red;
    /* outline 仅用于观察 */
}

可以在这里预览效果:Step 01

可以看到,效果还不错,除了...最后一行。

由于最后一行的图片数量不一定能够填满一行,flex-grow 直接沾满了父元素的宽度,导致最后一行的图片高度高得离谱。

step 2: 解决最后一行

既然最后一行 flex-grow 会出问题,那就把最后一行的 flex-grow 干掉不就好了吗?

然而,现时并没有那么简单:我们无法在服务端得知最后一行有几个元素,CSS 也没有给出指定最后一行的选择器。

那么,怎么办呢?

不妨换一种思路,既然不能干掉最后一行的 flex-grow,那就让它不生效!直接为 container 添加一个伪元素,把它的 flex-grow 设置为无限大,这样最后一行的 flex-grow 就不会生效了。

.container::after {
    content: '';
    flex-grow: 999999;
}

这一步的例子: Step 02

很好,符合预期,现在最后一行的图片高度也正常了。

step 3: 极端情况

现在我们来看一下屏幕较窄时的情况,比如手机屏幕。我们会发现,有些图片并没有占满一行,而是留下了一些空白。

例图02

原因是有些图片高大于宽,因此 flex-grow 会小于 1,当其单独占据一行时,宽度不会占满父元素。

解决办法很简单,直接把 flex-grow 乘 100 倍就行了。

const flexGrow = image.width / image.height * 100;

还有一个问题,当图片宽高比极端时,比如宽高比为 1:100,那么图片的宽度就会非常小,这样图片就会非常长,十分影响美观。还有 宽高比为 100:1 的图片,图片的高度就会非常小,这样图高度非常小,根本看不清图片的内容。

对于这种极端图片,我认为继续保持等比例缩放是不合适的,回退回 object-fit: cover 会更好。

因此最终的 CSS 如下:

.container {
    display: flex;
    flex-wrap: wrap;
    outline: 1px solid blue;
}

.image-box {
    max-height: 800px;
    min-height: 200px;
    max-width: 100%;
    outline: 1px solid green;
    margin: 0.5rem;
}

.container::after {
    content: "";
    flex-grow: 999999;
}

.image {
    display: block;
    height: 100%;
    width: 100%;
    object-fit: cover;
    outline: 1px solid red;
}

在线预览:Step 03

示例代码

import express from "express";
import fs from "fs/promises";
import sharp from "sharp";

const app = express();

app.use("/img", express.static("img"));

const template = `
<!DOCTYPE html>
<html lang="zh-CN">
    <head>
        <title>Google 500px layout</title>
        <style>
            {{% style %}}
        </style>
    </head>
    <body>
        <h1 style="text-align: center">Google 500px 布局</h1>
        {{% root %}}
    </body>
</html>
`.trim();

app.get("/", async (req, res) => {
    const files = await Promise.all((await fs.readdir("img"))
        .filter(name => name.match(/v1-\d{5}\.webp$/))
        .sort(() => 0.5 - Math.random())
        .map(name => {
            return sharp(`img/${name}`).metadata().then(meta => ({
                name: name,
                url: `/img/${name}`,
                height: meta.height,
                width: meta.width,
            }));
        }));

    const elements =files.map(file => {
        return `
            <div class="image-box" style="aspect-ratio: ${file.width} / ${file.height};flex-grow: ${(file.width / file.height * 100).toFixed(5)}; width: ${(300 * file.width / file.height).toFixed(5)}px;">
                <img width="${file.width}" height="${file.height}" alt="${file.name}" class="image" src="${file.url}"/>
            </div>
        `.trim();
    });
    const style = `
            .container {
                display: flex;
                flex-wrap: wrap;
                outline: 1px solid blue;
            }

            .image-box {
                max-height: 800px;
                min-height: 200px;
                max-width: 100%;
                outline: 1px solid green;
                padding: 0.5rem;
            }

            .container::after {
                content: "";
                flex-grow: 999999;
            }

            .image {
                display: block;
                height: 100%;
                width: 100%;
                object-fit: cover;
                outline: 1px solid red;
            }
    `.trim();
    const root = `
        <div class="container">
            ${elements.join("\n            ").trim()}
        </div>
    `.trim();

    const result = template.replace(/\{\{%\s+root\s+%}}/, root)
        .replace(/\{\{%\s+style\s+%}}/, style);

    res.send(result);
});

app.listen(3000, () => {
    console.log("Server is running at http://localhost:3000");
});