使用 View Transitions API 实现全页面主题切换动画

17 分钟
动画翻译React

这是一篇译文,原文:https://akashhamirwasia.com/blog/full-page-theme-toggle-animation-with-view-transitions-api

Telegram 应用程序在其暗黑模式切换时有一个非常有趣的动画效果,暗色主题从开关处扩展覆盖整个应用程序。我一直想知道这在底层是如何实现的,以及如何在网页上复制这种效果。我很高兴地告诉大家,由于浏览器即将推出的视图转换 API,这在网页上终于成为可能

本文将介绍如何使用视图转换 API 为暗黑主题切换开关实现以下动画效果。我将在 React 项目中演示,但视图转换 API 是浏览器的基础功能,因此您可以使用原生 JavaScript 或任何其他框架/库来实现

视图转换 API 是如何工作的?

这里我构建的交互一旦理解了视图过渡 API 的工作原理,就会变得更容易理解:

  1. 当访问 API 来启动视图过渡时,浏览器会将页面的当前状态捕获为一个截图。这称为页面的“旧”状态。
  2. 然后,API 运行一些代码来更新页面到下一个需要进行动画的状态。
  3. 现在浏览器已准备好下一个状态,并将其捕获为另一个截图。这称为页面的“新”状态。此时,用户仍然看到的是页面的“旧”状态,而所有这些对“新”状态的更新仅发生在内存中,但尚未在显示器上渲染。
  4. 有了页面的“旧”状态和“新”状态的截图,浏览器将它们加载到 ::view-transition-old 和 ::view-transition-new 伪元素中。
  5. 最后,在这两个伪元素之间运行一个 CSS 动画,以显示 ::view-transition-new 伪元素。
  6. 当动画结束时,这些伪元素被移除,浏览器接着在屏幕上渲染已经加载好的页面“新”状态(来自步骤 3)。

在 React 中实现动画

以下代码是一个基本的暗主题切换开关的实现,该开关将其状态存储在 React 的状态变量中,并通过一个关联的 effect 来根据开关的状态切换 <html> 元素上的 dark 类。为简单起见,切换开关组件使用了 Radix UI 实现,但你也可以使用任何组件库或自定义的开关组件。

import { useState, useEffect } from 'react';
import * as Switch from './Switch';
import { IconMoon, IconSun } from './Icons';

export default function App() {
  const [isDarkMode, setIsDarkMode] = useState(false);

  const toggleDarkMode = (isDarkMode) => {
    setIsDarkMode(isDarkMode);
    // Animation code goes here
  };

  useEffect(() => {
    if (isDarkMode) {
      document.documentElement.classList.add('dark');
    } else {
      document.documentElement.classList.remove('dark');
    }
  }, [isDarkMode]);

  return (
    <div className="h-screen w-screen flex items-center justify-center bg-white dark:bg-gray-950">
      <Switch.Root checked={isDarkMode} onCheckedChange={toggleDarkMode}>
        <Switch.Thumb>
          {isDarkMode ? <IconMoon /> : <IconSun />}
        </Switch.Thumb>
      </Switch.Root>
    </div>
  );
}

要使用视图过渡 API,我们需要调用 document.startViewTransition() 函数,并传递一个回调函数,该回调函数定义页面应如何更新到下一个需要进行动画的状态(即“视图过渡 API 工作原理”中的第 2 步)。

我们的初步想法是将 setIsDarkMode 调用简单地移动到这个回调函数中。毕竟,这是触发页面更新为暗主题的操作。虽然这个思路是正确的,但我们对其理解存在一个小问题。React 在状态更新后不会立即更新 DOM。DOM 更新是异步的,可能会在 setState 调用后很久才发生。因此,在调用 setIsDarkMode(isDarkMode) 后,当 document.startViewTransition() 的回调结束时,不能保证 DOM 已经处于其新状态(即在这个例子中是暗主题状态)。

这是一个问题,因为 startViewTransition() 需要页面的新状态,以便执行动画。那么我们如何解决这个问题呢?

flushSync() 来救场!

幸运的是,React 提供了 flushSync() 函数,它会在状态变量更新后同步应用所有的 DOM 更新。虽然 React 文档对这个函数有一个大大的警告,指出使用它是不常见的,并且可能会影响性能,但正如本文所述,在视图过渡 API 中使用它是完全没问题的。

但如果我没有使用 React,该怎么办呢?

如果你使用的是其他框架,那么很可能那个框架也有类似的功能,可以同步应用 DOM 更新。在 Vue 中,这个函数是 nextTick,在 Svelte 中是 tick

要在代码中集成 flushSync(),我们可以简单地将 setIsDarkMode() 调用包装在一个回调函数中,并将其传递给 flushSync()。这可以确保 startViewTransition 在其回调结束时拥有页面的最新状态。

import { useState, useEffect } from 'react';
import { flushSync } from 'react-dom';
import * as Switch from './Switch';
import { IconMoon, IconSun } from './Icons';

export default function App() {
  const [isDarkMode, setIsDarkMode] = useState(false);

  const toggleDarkMode = (isDarkMode) => {
    document.startViewTransition(() => {
      flushSync(() => {
        setIsDarkMode(isDarkMode);
      });
    });
  };

  useEffect(() => {
    if (isDarkMode) {
      document.documentElement.classList.add('dark');
    } else {
      document.documentElement.classList.remove('dark');
    }
  }, [isDarkMode]);

  return (
    <div className="h-screen w-screen flex items-center justify-center bg-white dark:bg-gray-950">
      <Switch.Root checked={isDarkMode} onCheckedChange={toggleDarkMode}>
        <Switch.Thumb>
          {isDarkMode ? <IconMoon /> : <IconSun />}
        </Switch.Thumb>
      </Switch.Root>
    </div>
  );
}

只需几行代码,当主题切换时,我们就在页面上实现了一个不错的默认淡入淡出动画。这比完全没有动画要好得多!

实现“生长动画”

要自定义视图过渡 API 提供的默认淡入淡出动画,我们可以在 ::view-transition-old(root)::view-transition-new(root) 伪元素上简单地应用 CSS 动画(参见视图过渡 API 的工作原理第 5 步)。这些伪元素中的 (root) 目标是应用于 <html> 元素的视图过渡。

我们想要实现的“生长动画”在纯 CSS 中直接实现比较棘手,因为切换开关的位置可能会在不同的断点发生变化,并且仅通过 CSS 很难确定其确切位置。在这种情况下,我们可以使用 JavaScript 中的 animate() 函数以编程方式运行 CSS 动画,这将使我们能够灵活地定位开关的确切位置。

什么是生长动画?

如果我们尝试分析生长动画,会发现它由三个部分组成:

  1. 从开关的位置开始,一个圆开始生长。
  2. 在圆内渲染页面的“新”状态。
  3. 圆继续生长,直到覆盖整个屏幕。

让我们先解决第 2 部分。

如何限制页面元素在屏幕上的显示区域?换句话说,我们能否创建一个圆形的剪切蒙版,只显示该圆内的元素?

答案是肯定的!这可以通过 CSS 中的 clip-path 属性实现!clip-path 属性接收一个形状,该形状被用作应用此属性的元素的剪切蒙版。我们需要一个圆形的蒙版,幸运的是,clip-path 已经支持 circle 函数来实现这一功能。

这是 circle 函数的语法:

circle(<radius of the circle> at <x coord of center> <y coord of center>)

圆心的位置是相对于元素的左上角的。

现在,第 1 和第 3 部分应该变得简单了。我们需要对 clip-path 属性进行动画处理,使其从一个半径为 0 且圆心在开关处的圆,动画到一个覆盖整个屏幕的圆,且圆心仍然位于开关处。开关元素的位置可以通过使用 getBoundingClientRect() 函数获取。

这样,动画的流程就是:

  1. 起点:圆的半径为 0,圆心位于开关处。
  2. 终点:圆的半径逐渐增大,直到覆盖整个屏幕,圆心仍然在开关处。

通过 getBoundingClientRect() 函数,我们可以获取开关元素相对于视口的位置,然后使用这些坐标来设置 clip-path 的初始和最终状态,从而实现生长动画。

import { useState, useEffect, useRef } from 'react';

export default function App() {
  const [isDarkMode, setIsDarkMode] = useState(false);
  const ref = useRef(null);

  const toggleDarkMode = (isDarkMode) => {
    if (!ref.current) return;

    document.startViewTransition(() => {
      flushSync(() => {
        setIsDarkMode(isDarkMode);
      });
    });

    const { top, left } = ref.current.getBoundingClientRect();
    const x = left;
    const y = top;

    document.documentElement.animate(
      {
        clipPath: [
          `circle(0px at ${x}px ${y}px)`,
          `circle(300px at ${x}px ${y}px)`,
        ],
      },
      {
        duration: 500,
        easing: 'ease-in-out',
        pseudoElement: '::view-transition-new(root)',
      }
    );
  };

  // useEffect(() => { ...

  return (
    <div className="h-screen w-screen flex items-center justify-center bg-white dark:bg-gray-900">
      <Switch.Root checked={isDarkMode} onCheckedChange={toggleDarkMode}>
        <Switch.Thumb ref={ref}>
          {isDarkMode ? <IconMoon /> : <IconSun />}
        </Switch.Thumb>
      </Switch.Root>
    </div>
  );
}

请注意,上述代码中的动画仅应用于 ::view-transition-new(root),因为我们只希望在页面的“新”状态上应用 clip-path

我们还需要关闭默认的淡入淡出动画和应用的混合模式。这可以直接在 CSS 中完成:

::view-transition-old(root),
::view-transition-new(root) {
  animation: none;
  mix-blend-mode: normal;
}

由于我们使用了编程动画,因此还需要进行另一个更改。startViewTransition() 函数返回一个对象,该对象有一个 .ready 属性。这个属性是一个 Promise,当浏览器可以执行过渡并将 ::view-transition-* 伪元素附加到 DOM 上时,该 Promise 会被解析。如果这些元素没有附加到 DOM 上,我们的 animate() 调用将会过早运行,导致动画无法执行。因此,我们需要等待 .ready 属性解析后再运行动画。

import { useState, useEffect, useRef } from 'react';
import { flushSync } from 'react-dom';
import * as Switch from './Switch';
import { IconMoon, IconSun } from './Icons';

export default function App() {
  const [isDarkMode, setIsDarkMode] = useState(false);
  const ref = useRef(null);

  const toggleDarkMode = async (isDarkMode) => {
    if (!ref.current) return;

    await document.startViewTransition(() => {
      flushSync(() => {
        setIsDarkMode(isDarkMode);
      });
    }).ready;

    const { top, left } = ref.current.getBoundingClientRect();
    const x = left;
    const y = top;

    document.documentElement.animate(
      {
        clipPath: [
          `circle(0px at ${x}px ${y}px)`,
          `circle(200px at ${x}px ${y}px)`,
        ],
      },
      {
        duration: 500,
        easing: 'ease-in-out',
        pseudoElement: '::view-transition-new(root)',
      }
    );
  };

  useEffect(() => {
    if (isDarkMode) {
      document.documentElement.classList.add('dark');
    } else {
      document.documentElement.classList.remove('dark');
    }
  }, [isDarkMode]);

  return (
    <div className="h-screen w-screen flex items-center justify-center bg-white dark:bg-gray-950">
      <Switch.Root checked={isDarkMode} onCheckedChange={toggleDarkMode}>
        <Switch.Thumb ref={ref}>
          {isDarkMode ? <IconMoon /> : <IconSun />}
        </Switch.Thumb>
      </Switch.Root>
    </div>
  );
}
// styles.css
::view-transition-old(root),
::view-transition-new(root) {
  animation: none;
  mix-blend-mode: normal;
}

动画已经在运行了!虽然仍有一些瑕疵(字面上说),但我想暂停一下,感谢你如果阅读并理解了上述所有内容的话!❤️

目前的问题是,应该覆盖整个屏幕的圆形蒙版的半径不足以覆盖整个屏幕。这个半径是硬编码的值,可能对不同的屏幕尺寸不够有效。我们需要根据用户的屏幕尺寸准确计算这个半径。这涉及到一些数学和几何知识,但如果你尝试计算一个能够覆盖屏幕的圆的半径,它实际上是由开关在屏幕上形成的较大垂直和水平偏移所构成的直角三角形的斜边。

要计算这个半径,我们可以使用以下公式:

  1. 获取开关元素的屏幕偏移量:getBoundingClientRect()
  2. 计算水平和垂直的最大偏移量。
  3. 计算半径:radius = Math.sqrt(horizontalOffset^2 + verticalOffset^2)

这可以确保圆形蒙版足够大,能够覆盖整个屏幕。

这段数学可以通过以下代码实现:

import { useState, useEffect, useRef } from 'react';
import { flushSync } from 'react-dom';
import * as Switch from './Switch';
import { IconMoon, IconSun } from './Icons';

export default function App() {
  const [isDarkMode, setIsDarkMode] = useState(false);
  const ref = useRef(null);

  const toggleDarkMode = async (isDarkMode) => {
    if (!ref.current) return;

    await document.startViewTransition(() => {
      flushSync(() => {
        setIsDarkMode(isDarkMode);
      });
    }).ready;

    const { top, left } = ref.current.getBoundingClientRect();
    const x = left;
    const y = top;
    const right = window.innerWidth - left;
    const bottom = window.innerHeight - top;
    // Calculates the radius of circle that can cover the screen
    const maxRadius = Math.hypot(
      Math.max(left, right),
      Math.max(top, bottom),
    );

    document.documentElement.animate(
      {
        clipPath: [
          `circle(0px at ${x}px ${y}px)`,
          `circle(${maxRadius}px at ${x}px ${y}px)`,
        ],
      },
      {
        duration: 500,
        easing: 'ease-in-out',
        pseudoElement: '::view-transition-new(root)',
      }
    );
  };

  useEffect(() => {
    if (isDarkMode) {
      document.documentElement.classList.add('dark');
    } else {
      document.documentElement.classList.remove('dark');
    }
  }, [isDarkMode]);

  return (
    <div className="h-screen w-screen flex items-center justify-center bg-white dark:bg-gray-950">
      <Switch.Root checked={isDarkMode} onCheckedChange={toggleDarkMode}>
        <Switch.Thumb ref={ref}>
          {isDarkMode ? <IconMoon /> : <IconSun />}
        </Switch.Thumb>
      </Switch.Root>
    </div>
  );
}
// styles.css 一样同上

动画按预期工作了!由于我们使用了数学计算这个半径,它会在开关位于屏幕上的任何位置时都有效,使组件对用户非常灵活 🙌

添加最后的修饰

在这个交互中还有一些细微的改进可以做:

  1. 圆形蒙版的中心位置: 目前,圆形蒙版从开关的左上角开始生长,因为我们使用了 getBoundingClientRect() 返回的 topleft 偏移量。为了使圆的中心位于开关的中心,我们可以通过在 topleft 偏移量上分别加上开关的 height / 2width / 2 来计算新的偏移量。
  2. 视图过渡 API 的兼容性: 在撰写本文时,视图过渡 API 仍处于实验阶段,并且未在所有主流浏览器中得到支持(可以在这里查看支持情况)。为了防止在这些浏览器中交互中断,我们可以添加一个检查,如果视图过渡 API 不受支持,则调用 setIsDarkMode() 并提前返回。
  3. 尊重减少运动的设置: 一些用户可能在操作系统中启用了减少运动的选项。为了尊重此设置,我们还可以通过添加媒体查询检查来禁用这个动画,当用户启用了减少运动选项时提前返回。

最终代码

import { useState, useEffect, useRef } from 'react';
import { flushSync } from 'react-dom';
import * as Switch from './Switch';
import { IconMoon, IconSun } from './Icons';

export default function App() {
  const [isDarkMode, setIsDarkMode] = useState(false);
  const ref = useRef(null);

  const toggleDarkMode = async (isDarkMode) => {
    /**
     * Return early if View Transition API is not supported
     * or user prefers reduced motion
     */
    if (
        !ref.current ||
        !document.startViewTransition ||
        window.matchMedia('(prefers-reduced-motion: reduce)').matches
    ) {
      setIsDarkMode(isDarkMode);
      return;
    }

    await document.startViewTransition(() => {
      flushSync(() => {
        setIsDarkMode(isDarkMode);
      });
    }).ready;

    const { top, left, width, height } = ref.current.getBoundingClientRect();
    const x = left + width / 2;
    const y = top + height / 2;
    const right = window.innerWidth - left;
    const bottom = window.innerHeight - top;
    const maxRadius = Math.hypot(
      Math.max(left, right),
      Math.max(top, bottom),
    );

    document.documentElement.animate(
      {
        clipPath: [
          `circle(0px at ${x}px ${y}px)`,
          `circle(${maxRadius}px at ${x}px ${y}px)`,
        ],
      },
      {
        duration: 500,
        easing: 'ease-in-out',
        pseudoElement: '::view-transition-new(root)',
      }
    );
  };

  useEffect(() => {
    if (isDarkMode) {
      document.documentElement.classList.add('dark');
    } else {
      document.documentElement.classList.remove('dark');
    }
  }, [isDarkMode]);

  return (
    <div className="h-screen w-screen flex items-center justify-center bg-white dark:bg-gray-950">
      <Switch.Root checked={isDarkMode} onCheckedChange={toggleDarkMode}>
        <Switch.Thumb ref={ref}>
          {isDarkMode ? <IconMoon /> : <IconSun />}
        </Switch.Thumb>
      </Switch.Root>
    </div>
  );
}
//styles.css 同上

这个最终版本的代码做了以下改进:

  • 计算了开关的中心位置,以使圆形蒙版从开关的中心开始生长。
  • 添加了对视图过渡 API 支持的检查,确保在不支持的浏览器中不会出现问题。
  • 添加了对减少运动选项的检查,以便在用户启用了减少运动时跳过动画。

我非常高兴终于实现了这个在 Telegram 应用中使用了无数次的交互。视图过渡 API 解锁了许多 Web 动画的可能性。尽管这个 API 相当简单,但你可以用它创建的效果几乎是无限的!我很期待看到其他 Web 开发者利用这个 API 创建的各种动画和交互,以及它如何用于打造更令人愉悦的 Web 体验。

References


此文自动发布于:github issues