YGG-CLI-9-主题切换的设计与开发页面

一个文笔一般,想到哪是哪的唯心论前端小白。

🧠 - 简介

主题切换的方案,网上有好多种,而我要实现这一种:

其实是模仿了 element-plus 官网的主题切换效果,它那里只是实现了亮色和暗色的切换,我则是在 element-plus 明亮切换的基础上,增加了 主色 的切换。

👁️ - 分析

主题切换站在产品的角度其实分为几个档:

  1. 明暗切换:我的世界里只有 0 和 1,非黑即白,其实主要是修改的反而是中性色的色值,例如:背景色,文本颜色,边框颜色等。
  2. 主色切换:主色切换和明暗切换,则是刚好相反,是在切换主色,也是我这在这个模板项目中使用的方案。
  3. 全站定制主题色:把所有的颜色提取出来进行归类,都使用变量控制,然后将这些变量进行设计定制。工作量很大。

另外总结一下一个网站上常用的几类颜色:

  • 主色:网站的主要颜色,例如:京东的红色,支付宝的蓝色,美团的黄色等等。
  • 辅助色:辅助色主要是和主色交相辉映的,为了衬托主色。
  • 点缀色(功能色):在element-plus 中的具体体现就是:警告、错误、成功这些提示颜色。
  • 中性色:背景、边框、文本颜色。

🫀 - 拆解

通过效果图可以看出来,这个主题切换分为如下几个部分:

  1. 明亮和暗黑切换的时候会出现一个动画效果,暗黑向亮白转换时,圆形由小到大,亮白向暗黑转换则相反。
  2. 都是明亮或者都是暗黑时,则不出现动画。
  3. 亮白和暗黑后面的颜色一样时,主色没有变化。
  4. 当下次进入系统时,要记录上次的主色,并进行主色切换。

所以开发起来就很顺利了,主要是实现这个动画效果,然后实现主色的切换。

💪 - 落实

动画效果讲解

动画切换是不是一下子想不起来怎么做?我也是查了好多资料才查到的。

它主要是用到了一个不怎么常用的api:document.startViewTransition,看过兼容性才知道,兼容性并没有想象中的那么优秀,所以还要做一下兼容,如果浏览器不支持,就不进行动画了。

所以就出现了下面这段逻辑:

ts 复制代码
// 主题切换动画
const animationTheme = (x: Ref<number>, y: Ref<number>, isToggle: boolean, color: string) => {
    // 开始一次视图过渡:
    const transition = document.startViewTransition(() => {
      changePrimaryColor(colorMap[color])
      isToggle && toggleDark()
    });

    transition.ready.then(() => {
      //计算按钮到最远点的距离用作裁剪圆形的半径
      const endRadius = Math.hypot(
        Math.max(x.value, innerWidth - x.value),
        Math.max(y.value, innerHeight - y.value)
      );
      const clipPath = [
        `circle(0px at ${x.value}px ${y.value}px)`,
        `circle(${endRadius}px at ${x.value}px ${y.value}px)`,
      ];
      //开始动画
      document.documentElement.animate(
        {
          clipPath: isDark.value ? [...clipPath].reverse() : clipPath,
        },
        {
          duration: 400,
          easing: "ease-in",
          pseudoElement: isDark.value
            ? "::view-transition-old(root)"
            : "::view-transition-new(root)",
        }
      );
    });
  }

// 操作主题切换
const handleChangeTheme = useThrottleFn((v: string) => {

    if (v === localStorage.getItem('theme')) {
      return
    }
    const [mode, color] = v.split('-')
    const isToggle = (!isDark.value && mode === 'dark') || (isDark.value && mode === 'light')

    theme.value = v
    localStorage.setItem('theme', v)

    //在不支持的浏览器里不做动画
    if (document.startViewTransition) {
      if (isToggle) {
        animationTheme(x, y, isToggle, color)
      } else {
        changePrimaryColor(colorMap[color])
      }
    } else {
      changePrimaryColor(colorMap[color])
      isToggle && toggleDark()
    }
  }, 400)

上面还是用了节流函数进行包裹操作方法,避免动画进行到一半就被打断的尴尬局面 ~ ~ ~

startViewTransition[MDN] 地址,可以自己学习一下,我也正在学习这些不怎么常用的API。

主色生成器

通过看 element-plus 主题源码不难看出,主色和功能色其实都是一组由浅到深的渐变色,所以切换主色并不只是切换一个色值,而是切换一组色值。

所以就实现了一个色阶生成器:

ts 复制代码
/**
 * 创建两种颜色之间的混合色。
 * @param color1 第一种颜色值。
 * @param color2 第二种颜色值。
 * @param ratio 混合比例(0 到 1 之间)。
 * @returns 返回混合后的颜色值。
 */
function mix(color1: string, color2: string, ratio: number): string {
  // 解析第一种颜色的 RGB 值
  const r1 = parseInt(color1.substring(1, 3), 16);
  const g1 = parseInt(color1.substring(3, 5), 16);
  const b1 = parseInt(color1.substring(5, 7), 16);

  // 解析第二种颜色的 RGB 值
  const r2 = parseInt(color2.substring(1, 3), 16);
  const g2 = parseInt(color2.substring(3, 5), 16);
  const b2 = parseInt(color2.substring(5, 7), 16);

  // 计算混合后的 RGB 值
  const mixedR = Math.round(r1 * (1 - ratio) + r2 * ratio);
  const mixedG = Math.round(g1 * (1 - ratio) + g2 * ratio);
  const mixedB = Math.round(b1 * (1 - ratio) + b2 * ratio);

  // 构建混合后的颜色值
  const mixedColor = `#${mixedR.toString(16)}${mixedG.toString(16)}${mixedB.toString(16)}`;

  return mixedColor;
}

然后进行变量替换

ts 复制代码
/**
 * 改变网页中特定元素的主题颜色。
 * @param e 选定的颜色值。
 */
export function changePrimaryColor(e: string): void {
  const pre = "--el-color-primary";
  const mixWhite = "#ffffff";
  const mixBlack = "#000000";
  const el = document.documentElement;

  // 设置主要颜色
  el.style.setProperty(pre, e);

  for (let i = 1; i < 10; i += 1) {
    // 设置不同明度的颜色值
    el.style.setProperty(`${pre}-light-${i}`, mix(e, mixWhite, i * 0.1));
  }

  // 设置较深的颜色值
  el.style.setProperty("--el-color-primary-dark", mix(e, mixBlack, 0.1));
}

这样就实现了主色的切换。

🛀 - 总结

上面四个核心方法就是这个主题切换的核心功能了,最后为了凑字数,粘上这个抽屉的源码:

vue 复制代码
<template>
  <Drawer
    ref="themeDrawer"
    :show-cancel="false"
    :confirm-text="'关闭'"
    @confirm="handleConfirm"
  >
    <div>
      <ul>
        <li
          v-for="item of themeList "
          :key="item.value"
          :class="{ 'theme-current': theme == item.value }"
          class="theme-item"
          @click="handleChangeTheme(item.value)"
        >
          <p>
            <span
              v-for="i in 4 "
              :key="i"
              :style="`background: ${['red', 'yellow', 'blue', 'green'][i - 1]}; height: 24px; width: 36px; display: inline-block;`"
            />
          </p>
          <p> {{ item.label }}</p>
          <span
            v-if="theme === item.value"
            class="current-jiaobiao"
          ><i class="iconfont icon-jiaobiao" /></span>
        </li>
      </ul>
    </div>
  </Drawer>
</template>

<script setup lang="ts">
  /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
   * @Name: CommonTheme
   * @Author: Zhang Ziyi
   * @Email: [email protected]
   * @Date: 2024-03-19 14:28
   * @Introduce: --
   * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
  import { onMounted, ref, Ref } from 'vue'
  import {
    useLocalStorage, useDark, useMouseInElement, useThrottleFn
  } from '@vueuse/core'
  import { useToggle } from '@vueuse/shared'
  import { changePrimaryColor } from '@/utils/themeColorGenerate'

  // Refs
  const themeDrawer = ref()

  // 数据
  const theme = ref<string>('')
  theme.value = useLocalStorage('theme', 'light-blue').value
  const themeList = ref([
    { label: '暗黑-红', value: 'dark-red' },
    { label: '亮白-红', value: 'light-red' },
    { label: '暗黑-金', value: 'dark-yellow' },
    { label: '亮白-金', value: 'light-yellow' },
    { label: '暗黑-蓝', value: 'dark-blue' },
    { label: '亮白-蓝', value: 'light-blue' },
    { label: '暗黑-绿', value: 'dark-green' },
    { label: '亮白-绿', value: 'light-green' },
  ])

  const colorMap: { [k: string]: string } = {
    red: '#FF0000',
    yellow: '#FFD700',
    blue: '#0000FF',
    green: '#00FF00'
  }

  onMounted(() => {
    console.log('🎡 > 主题色初始化完成,当前主题色为:', theme.value, '。');
    initTheme()
  })

  const initTheme = () => {
    const [mode, color] = theme.value.split('-');
    (!isDark.value && mode === 'dark') && toggleDark()
    changePrimaryColor(colorMap[color])
  }

  // 切换主题相关方法
  const { x, y } = useMouseInElement()
  const isDark = useDark()
  const toggleDark = useToggle(isDark)

  // 主题切换动画
  const animationTheme = (x: Ref<number>, y: Ref<number>, isToggle: boolean, color: string) => {
    // 开始一次视图过渡:
    const transition = document.startViewTransition(() => {
      changePrimaryColor(colorMap[color])
      isToggle && toggleDark()
    });

    transition.ready.then(() => {
      //计算按钮到最远点的距离用作裁剪圆形的半径
      const endRadius = Math.hypot(
        Math.max(x.value, innerWidth - x.value),
        Math.max(y.value, innerHeight - y.value)
      );
      const clipPath = [
        `circle(0px at ${x.value}px ${y.value}px)`,
        `circle(${endRadius}px at ${x.value}px ${y.value}px)`,
      ];
      //开始动画
      document.documentElement.animate(
        {
          clipPath: isDark.value ? [...clipPath].reverse() : clipPath,
        },
        {
          duration: 400,
          easing: "ease-in",
          pseudoElement: isDark.value
            ? "::view-transition-old(root)"
            : "::view-transition-new(root)",
        }
      );
    });
  }

  const handleChangeTheme = useThrottleFn((v: string) => {

    if (v === localStorage.getItem('theme')) {
      return
    }
    const [mode, color] = v.split('-')
    const isToggle = (!isDark.value && mode === 'dark') || (isDark.value && mode === 'light')

    theme.value = v
    localStorage.setItem('theme', v)

    //在不支持的浏览器里不做动画
    if (document.startViewTransition) {
      if (isToggle) {
        animationTheme(x, y, isToggle, color)
      } else {
        changePrimaryColor(colorMap[color])
      }
    } else {
      changePrimaryColor(colorMap[color])
      isToggle && toggleDark()
    }
  }, 400)

  const handleCancel = (cb: () => void) => cb()
  const handleConfirm = (cb: () => void) => cb()

  // 对外暴露方法
  const open = () => {
    themeDrawer.value.open('主题切换')
  }
  defineExpose({
    open
  })
</script>

<style lang="scss" scoped>
.theme-item {
  cursor: pointer;
  position: relative;
  margin: 8px 0;
  border: 1px solid var(--el-border-color-light);

  .current-jiaobiao {
    position: absolute;
    bottom: 0;
    right: 0;
    display: block;
    color: var(--el-color-primary);
  }

  &.theme-current {
    background: var(--el-color-primary-light-5);
    border-color: var(--el-color-primary-light-5);
  }

  padding: 10px;

  &:hover {
    background: var(--el-color-primary-light-8);
  }
}
</style>

后续会把一些方法提出去,减轻这个组件的体积,方便方便维护。

系列文章:

  1. 脚手架开发
  2. 模板项目初始化
  3. 模板项目开发规范与设计思路
  4. layout设计与开发
  5. login 设计与开发
  6. CURD页面的设计与开发
  7. 监控页面的设计与开发
  8. 富文本编辑器的使用与页面设开发设计
  9. 主题切换的设计与开发并页面
  10. 水印切换的设计与开发
  11. 全屏与取消全屏
  12. 开发提效之一键生成模块(页面)
相关推荐
Mr...Gan几秒前
TypeScript
开发语言·javascript·typescript
lilye6626 分钟前
精益数据分析(98/126):电商转化率优化与网站性能的底层逻辑
前端·数据挖掘·数据分析
MZWeiei41 分钟前
MVVM 模式,以及 Angular、React、Vue 和 jQuery 的区别与关系
vue.js·react.js·angular.js
前端 贾公子1 小时前
《Vuejs设计与实现》第 8 章(挂载与更新)
开发语言·前端·javascript
述雾学java1 小时前
Spring Boot + Vue 前后端分离项目解决跨域问题详解
vue.js·spring boot·后端
开始编程吧1 小时前
【HarmonyOS5】鸿蒙×React Native:跨端电商应用的「双引擎」驱动实践
前端
m0_746177191 小时前
小白进阶shell学习-----脚本实战案例
前端·chrome·学习
半碗水1 小时前
缝缝补补
前端·javascript
用户2519162427111 小时前
ES6之类的其他书写方式
javascript·ecmascript 6
空城机1 小时前
从零打造前沿Web聊天室:消息系统
前端·vue.js