🔥what?你的网站切换主题效果太生硬?来看看 ElementPlus 官网怎么做的

3,214 阅读9分钟

前言

哈喽大家好!我是 嘟老板

现在越来越多的网站都有切换主题功能,既能凸显网站多样的风格,也能满足不同审美的用户,极大的提升了用户体验,但是大部分都是直接从一种主题切换到另一种主题,切换效果略显生硬,最近看了 ElementPlus 官网切换主题的过渡动画,顿时醍醐灌顶,原来还可以这样做!

阅读本文您将收获:

  1. 了解并实现切换主题过渡动画全过程。
  2. 了解相关知识点,如 startViewTransitionanimate 等。

效果预览

Nov-29-2024 17-34-00.gif

实现过程

整个实现过程大致可以分为三步:主题切换动画过渡圆形扩散动画

主题切换

切换主题通常是通过修改 html 标签的 class 来实现。当主题为 light 时,html 标签的 class 设为 light置空,当主题为 dark 时,html 标签的 class 设为 dark

以一个简单的 html 为例:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    :root {
      --bg-color: #fff;
      background-color: var(--bg-color);
    }
   .dark {
      --bg-color: #000;
    }
  </style>
</head>
<body>
  <button id="themeToggler">切换主题</button>
</body>
<script>
  const themeToggler = document.getElementById('themeToggler');

  themeToggler.addEventListener('click', () => {
    document.documentElement.classList.toggle('dark')
  })
</script>
</html>

以上实现了一个最简易的切换主题效果,首先通过 css 设置不同 class 对应的背景色变量(--bg-color),若 class 为 dark 时,将 --bg-color 设为黑色(#000),若 class 为时,将 --bg-color 设为白色 #fff

效果如下:

Nov-29-2024 18-15-24.gif

不过以上方式建议只在demo中使用,在实际项目中,还是借助 UI 库的主题切换功能更方便快捷,极大减轻心智负担。

ZiMuAdmin就是借助 ElementPlus 提供的主题 + VueUse 来实现主题切换。

Dec-02-2024 10-10-59.gif

封装主题切换按钮

先封装一个切换主题的按钮组件 - ThemeToggler,组件基于 ElementPlus 的开关组件 El-Switch 封装,具体逻辑如下:

  1. El-Switch 组件双向绑定(v-model) darkMode 变量,darkModetrue 时,表示暗黑主题;为 false 时表示明亮主题。

    <el-switch
      ref="switchRef"
      v-model="darkMode"
      v-bind="attrs"
      :active-action-icon="DarkIcon"
      :inactive-action-icon="LightIcon"
    />
    

    active-action-iconinactive-action-icon 分别表示暗黑主题和明亮主题对应的图标,可在 ElementPlusIcons 中选择,也可自定义 svg。

    提供两个 svg 供参考,复制粘贴到 .vue 文件中即可。

    <template>
      <svg viewBox="0 0 24 24" class="dark-icon">
        <path
          d="M11.01 3.05C6.51 3.54 3 7.36 3 12a9 9 0 0 0 9 9c4.63 0 8.45-3.5 8.95-8c.09-.79-.78-1.42-1.54-.95A5.403 5.403 0 0 1 11.1 7.5c0-1.06.31-2.06.84-2.89c.45-.67-.04-1.63-.93-1.56z"
          fill="currentColor"
        />
      </svg>
    </template>
    
    <template>
      <svg viewBox="0 0 24 24" class="light-icon">
        <path
          d="M6.05 4.14l-.39-.39a.993.993 0 0 0-1.4 0l-.01.01a.984.984 0 0 0 0 1.4l.39.39c.39.39 1.01.39 1.4 0l.01-.01a.984.984 0 0 0 0-1.4zM3.01 10.5H1.99c-.55 0-.99.44-.99.99v.01c0 .55.44.99.99.99H3c.56.01 1-.43 1-.98v-.01c0-.56-.44-1-.99-1zm9-9.95H12c-.56 0-1 .44-1 .99v.96c0 .55.44.99.99.99H12c.56.01 1-.43 1-.98v-.97c0-.55-.44-.99-.99-.99zm7.74 3.21c-.39-.39-1.02-.39-1.41-.01l-.39.39a.984.984 0 0 0 0 1.4l.01.01c.39.39 1.02.39 1.4 0l.39-.39a.984.984 0 0 0 0-1.4zm-1.81 15.1l.39.39a.996.996 0 1 0 1.41-1.41l-.39-.39a.993.993 0 0 0-1.4 0c-.4.4-.4 1.02-.01 1.41zM20 11.49v.01c0 .55.44.99.99.99H22c.55 0 .99-.44.99-.99v-.01c0-.55-.44-.99-.99-.99h-1.01c-.55 0-.99.44-.99.99zM12 5.5c-3.31 0-6 2.69-6 6s2.69 6 6 6s6-2.69 6-6s-2.69-6-6-6zm-.01 16.95H12c.55 0 .99-.44.99-.99v-.96c0-.55-.44-.99-.99-.99h-.01c-.55 0-.99.44-.99.99v.96c0 .55.44.99.99.99zm-7.74-3.21c.39.39 1.02.39 1.41 0l.39-.39a.993.993 0 0 0 0-1.4l-.01-.01a.996.996 0 0 0-1.41 0l-.39.39c-.38.4-.38 1.02.01 1.41z"
          fill="currentColor"
        />
      </svg>
    </template>
    
  2. 借助 VueUseuseDarkuseToggle api 实现主题切换。

    我们将该部分内容单独写入 helper.ts 中维护,后续可持续维护相关逻辑。

    import { useDark, useToggle } from '@vueuse/core'
    
    export const isDark = useDark({
      storageKey: 'zm-theme'
    })
    
    export const toggleDark = useToggle(isDark)
    

    其中 isDark 表示当前是否为暗黑主题,toggleDark 为用于切换 isDark 值的函数,即用于切换主题。

  3. ThemeToggler 组件引入 helper,定义 darkMode 变量,初始值为 isDark 的值,并在 darkMode 变化时,调用 toggleDark 函数切换主题。

    <script setup lang="ts">
    import { isDark, toggleDark } from './helper'
    import DarkIcon from './icons/dark.vue'
    import LightIcon from './icons/light.vue'
    
    defineOptions({
      name: 'ThemeToggler'
    })
    const attrs = useAttrs()
    
    const darkMode = ref(isDark.value)
    watch(
      () => darkMode.value,
      () => {
        toggleDark()
      }
    )
    </script>
    
  4. 如有需要,可通过 svgclass(.dark-icon.light-icon)自定义图标的样式,提供 style 供参考:

    <style lang="scss" scoped>
    :deep(.dark-icon) {
      border-radius: 50%;
      color: #cfd3dc;
      background-color: #141414;
    }
    
    :deep(.light-icon) {
      color: #606266;
    }
    </style>
    

OK,到这一个初步的切换主题按钮就封装完毕了,有问题滴滴我。

视图过渡

切换主题搞定了,开始着手实现动画过渡。

这里我们用到 Document 接口提供的实例方法:startViewTransition,详见下文附录部分

El-Switch 提供了 before-change 属性,是状态更改前的钩子,正好可以用来处理动画逻辑。

处理函数代码如下:

const handleBeforeChange = () => {
  // 若浏览器不支持 View Transitions
  if (!document.startViewTransition) {
    return true
  }

  return new Promise(resolve => {
    document.startViewTransition(() => {
      resolve(true)
    })
  })
}

before-change 中,我们通过 startViewTransition 开启一个新的视图过渡,并在过渡结束后,通过 resolve(true) 来告诉 El-Switch 组件过渡已经完成,以继续更新状态。

效果如下:

Dec-02-2024 11-47-14.gif

现在已经有了过渡效果,但看起来好像没啥区别,别急,我们加上动画就明显了。

圆形扩散动画

实现动画需要借助 Element 接口提供的 animate 实例方法,详见下文附录部分

具体实现分为以下步骤:

  1. 获取动画开始位置,在主题切换按钮的中心点。

    代码如下:

    const switchEl = switchRef.value.$el as HTMLElement
    const rect = switchEl.getBoundingClientRect()
    const x = rect.left + rect.width / 2
    const y = rect.top + rect.height / 2
    

    首先通过 Vueref 语法获取按钮元素,然后通过 getBoundingClientRect 方法获取元素尺寸数据相对于视窗的位置,其中 rect.left 是元素相对于视窗左侧的距离,rect.top 是元素相对于视窗顶部的距离,rect.width 是元素的宽度,rect.height 是元素的高度,通过简单的运算,得出按钮中心点相对于视窗的位置坐标 xy(单位:像素 px)。

  2. 计算圆形动画的半径,确定动画需要扩散的最大范围

    代码如下:

    const radius = Math.hypot(
      Math.max(x, innerWidth - x),
      Math.max(y, innerHeight - y)
    )
    

    我们来看张简图,方便理解:

    image.png

    目前,已知坐标 xy,求 radius,应该很一目了然吧,三角形勾股定理得:x^2 + y^2 = radius^2radius 等于 根号下(x^2 + y^2),恰好 Math 对象提供了 hypot 方法,可以求所有参数平方和的平方根,所以上面代码中的 radius 就是我们需要的半径。

    至于为什么要用 Math.max,是因为切换主题的按钮可能在左边,也可能在右边,按钮离哪边远,动画就向那边扩散的多,取最长的半径,没毛病。

  3. 设置动画

    代码如下:

    transition.ready.then(() => {
      const clipPath = [
        `circle(0px at ${x}px ${y}px)`,
        `circle(${radius}px at ${x}px ${y}px)`
      ]
      // 新视图的根元素动画
      document.documentElement.animate(
        {
          clipPath
        },
        {
          duration: 400,
          easing: 'ease-in',
          // 指定要附加动画的伪元素
          pseudoElement: '::view-transition-new(root)'
        }
      )
    })
    

    主要可以分为两部分:

    • 应用 transition.ready 属性,等待相关伪元素创建完成后,开始过渡动画。
    • 应用 document.documentElement.animate 设置动画,第一个参数为动画关键帧对象,第二个参数是属性配置对象,其中 pseudoElement: '::view-transition-new(root)' 用来设置附加动画的伪元素,::view-transition-new(root) 就是调用 startViewTransition 开始的视图过渡过程中创建的伪元素。

handleBeforeChange 完整代码

const handleBeforeChange = () => {
  // 浏览器不支持 View Transitions 时的回退方案:
  if (!document.startViewTransition) {
    return true
  }

  return new Promise(resolve => {
    const switchEl = switchRef.value.$el as HTMLElement
    const rect = switchEl.getBoundingClientRect()
    const x = rect.left + rect.width / 2
    const y = rect.top + rect.height / 2
    const radius = Math.hypot(
      Math.max(x, innerWidth - x),
      Math.max(y, innerHeight - y)
    )
    const transition = document.startViewTransition(() => {
      resolve(true)
    })
    transition.ready.then(() => {
      const clipPath = [
        `circle(0px at ${x}px ${y}px)`,
        `circle(${radius}px at ${x}px ${y}px)`
      ]
      document.documentElement.animate(
        {
          clipPath
        },
        {
          duration: 400,
          easing: 'ease-in',
          pseudoElement: '::view-transition-new(root)'
        }
      )
    })
  })
}
  1. 关闭视图过渡默认动画,在应用的入口文件(如 main.ts)中,引入包含以下代码的样式文件(如 index.scss)即可:

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

OK,到这从主题切换到过渡动画就算完成了,看下最终效果:

Dec-02-2024 16-30-50.gif

你问我为啥和 ElementPlus 官网的效果不完全一致?因为我不想要收回的效果,哈哈!如果你想,切换到暗黑主题时,更改 clipPath 为相反的顺序,并且将动画伪元素配置 pseudoElement 改为 '::view-transition-old(root)' 即可。

附录

startViewTransition

这是一项实验性技术,用于生产前,请仔细检查浏览器兼容性

startViewTransitionView Transitions API 提供的一个函数方法,可以开始一个新的视图过渡,并返回一个 ViewTransition 对象。

语法

startViewTransition(callback)
参数:
  • callback:回调函数,通常用于在视图过渡过程中更新 DOM,返回一个 Promise,在 API 截取当前页面的屏幕截图后被调用。若 callback 返回 Promise resolve,视图过渡将在下一帧开始;若 callback 返回 Promise reject,视图过渡将终止。

视图过渡过程如下:

  1. 当调用 document.startViewTransition() 时,API 会截取当前页面的屏幕截图。

  2. 调用传给 startViewTransition() 的回调函数。当回调函数成功时,ViewTransition.updateCallbackDone Promise 兑现,允许你响应 DOM 更新。

    例如:

    transition.updateCallback.then(() => {
      // DOM 更新...
    })
    
  3. API 会捕获页面的新状态并实时展示。

  4. API 构造了一个具有以下结构的伪元素树:

    ::view-transition
    └─ ::view-transition-group(root)
      └─ ::view-transition-image-pair(root)
          ├─ ::view-transition-old(root)
          └─ ::view-transition-new(root)
    
    • ::view-transition 是视图过渡叠加层的根元素,它包含所有视图过渡且位于所有其他页面内容的顶部。
    • ::view-transition-old 是旧页面视图的屏幕截图;::view-transition-new 是新页面视图的实时展示。

    当过渡动画即将运行时,ViewTransition.ready Promise 兑现,你可以响应它进行一些操作,如运行自定义的 JavaScript 动画,而不是默认的动画。

    例如:

    transition.ready.then(() => {
      // 自定义动画
    })
    
  5. 旧页面视图的 opacity1 过渡到 0,而新视图从 0 过渡到 1,这就是默认的交叉淡入淡出效果。

  6. 当过渡动画结束时,ViewTransition.finished Promise 兑现,你可以响应它进行一些操作。

    例如:

    transition.finished.then(() => {
      // 过渡动画结束...
    })
    

animate

Element 接口的 animate() 方法用于创建一个新的 Animation,并将它应用于某个元素,然后运行动画,返回一个新建的 Animation 对象实例。

一个元素上可以应用多个动画效果。Element 接口提供 getAnimations() 方法来获得动画效果列表。

语法

animate(keyframes, options)
参数
  • keyframes:关键帧对象数组,或一个关键帧对象(其属性是可迭代值的数组),如:

    // 数组形式
    element.animate(
      [
        {
          // from
          opacity: 0,
          color: "#fff",
        },
        {
          // to
          opacity: 1,
          color: "#000",
        },
      ],
      2000,
    );
    
    // 对象形式
    element.animate(
      {
        opacity: [0, 1], // [ from, to ]
        color: ["#fff", "#000"], // [ from, to ]
      },
      2000,
    );
    
  • options:表示动画持续时间的整数(以毫秒为单位),或包含一个或多个时间属性的对象,对象的属性包含:

    • delay:表示动画延迟时间(以毫秒为单位),整数,默认为 0
    • duration:定义动画持续时间(以毫秒为单位),整数,默认为 0,即不会运行动画。
    • direction:定义动画方向,可选值为 normalreversealternatealternate-reverse,默认 normal
    • easing:d定动画缓动函数,可选值为 easing-function,如 lineareaseease-inease-outease-in-outstep-startstep-end,默认为 linear
    • enddelay:定义动画结束延迟时间的整数(以毫秒为单位),默认为 0
    • fill:定义动画填充模式,可选值为 noneforwardsbackwardsboth,默认为 none
    • iterationstart:定义动画开始的时机,若设为 0.5,则动画将在第一次迭代的一半时开始播放,默认值为 0.0,即动画将从第一次迭代的开始处开始播放。
    • iterations:定义动画迭代次数,默认为 1,也可设为 Infinity,表示动画在元素存在期间一直重复。
    • composite:定义动画合成模式,可选值为 addaccumulatereplace,默认 replace
    • iterationcomposite:定义动画迭代合成模式,可选值为 accumulatereplace,默认 replace
    • pseudoelement:包含伪元素选择器的字符串,如 ::before::after,若存在,则动画效果将应用于目标的选定伪元素,而不是目标本身。

    例如:

    element.animate(
      {
        opacity: [0, 1], // [ from, to ]
        color: ["#fff", "#000"], // [ from, to ]
      },
      {
        duration: 2000,
        easing: 'ease-in-out',
        pseudoelement: '::before'
      },
    );
    

结语

本文重点介绍了切换主题过渡动画的完整实现过程,并简单介绍了相关知识点,如 startViewTransitionanimate 等,旨在帮助同学们加深对于相关技术的应用理解。希望对您有所帮助!相关代码已上传至 GitHub,欢迎 star

如您对文章内容有任何疑问或想深入讨论,欢迎评论区留下您的问题和见解。

技术简而不凡,创新生生不息。我是 嘟老板,咱们下期再会。


往期干货