likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 1

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

🧠 - 简介

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

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

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

👁️ - 分析

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

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

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

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

🫀 - 拆解

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

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

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

💪 - 落实

动画效果讲解

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

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

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

// 主题切换动画
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 主题源码不难看出,主色和功能色其实都是一组由浅到深的渐变色,所以切换主色并不只是切换一个色值,而是切换一组色值。

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

/**
 * 创建两种颜色之间的混合色。
 * @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;
}

然后进行变量替换

/**
 * 改变网页中特定元素的主题颜色。
 * @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));
}

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

🛀 - 总结

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

<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: 15227974559@163.com
   * @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. 开发提效之一键生成模块(页面)