有趣的黑暗模式切换效果

Jan 1, 2023Front End

bilibili-transition

以上动图是 Bilibili Mac 客户端的黑暗模式切换效果,颜色在点击处进行分散和聚拢,整体界面的过渡十分平滑,没有分割感。目前很多产品的切换效果只是整体的变化,或许为了表现更自然一点,会加上时间的过渡。但这种新颖有趣的黑暗模式切换效果总体还是非常少见,我打算复刻一下。或许别的应用也有类似的效果,但在这里我以 Bilibili Mac 客户端为目标效果。

本篇文章将用前端技术(Electron)实现效果,不考虑 Bilibili 选用的技术栈(虽然我推测 Bilibili 也用了 Electron)。

如何实现这种效果

下图显示了切换效果某一刻的样子,我们从这一帧可以提取很多信息。

bilibili-shot

我们将 UI 分为不同的元素和块,在 WEB 技术中等同于 HTML 的各个 Element。图中颜色变化不区分元素,即不是单个元素的变化。我们不能简单的设置每个元素的颜色,于是我们考虑这么实现:

在黑暗模式按钮点击时,将切换前的窗口视图进行截图,并覆盖在窗口最上层以遮挡实际内容,然后将实际的窗口内容立刻切换到另一个效果,此时窗口看起来还是切换前的效果,接着我们将截图形状不断进行裁剪,慢慢露出遮挡下的内容,直到实际窗口完整显示出来。

代码的具体实现

以下实现基于 Electron,并且我假设你了解并熟悉 Electron,关于具体 Electron 细节并不会有太多说明。以下代码中,main.js 指主进程代码,preload.js 为预加载脚本,renderer.js 渲染进程 HTML 页面的 JS 代码。

我们使用 Electron 按照官网示例新建一个窗口,主线程代码是这样的(你完全可以从官网拿到基本相同的代码):

// main.js
const { app, BrowserWindow } = require('electron')
const path = require('path')

let mainWindow;

function createWindow () {
  mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    titleBarStyle: 'hidden',
  })

  mainWindow.loadFile('index.html')
}

app.whenReady().then(() => {
  createWindow()

  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) createWindow()
  })
})

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit()
})
// main.js
const { app, BrowserWindow } = require('electron')
const path = require('path')

let mainWindow;

function createWindow () {
  mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    titleBarStyle: 'hidden',
  })

  mainWindow.loadFile('index.html')
}

app.whenReady().then(() => {
  createWindow()

  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) createWindow()
  })
})

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit()
})

同时窗口加载 HTML 页面,我们给页面加点内容,完成整体的布局和样式(包括黑暗模式样式),将模式切换按钮放置在左下角:

page

Electron 的窗口对象向外提供了一个 capturePage 接口,利用该接口可以获取窗口的截图。该接口调用运行在主进程中,我们通过预加载脚本将截图方法提供给渲染进程使用。

// preload.js
const { contextBridge, ipcRenderer } = require('electron')

// 向渲染进程 window 对象注入 APP 对象,该对象下有 capturePage 方法
contextBridge.exposeInMainWorld('APP', {
  capturePage: () => ipcRenderer.invoke('ACTION:CAPTURE_PAGE')
})


// main.js
// 修改创建窗口方法
function createWindow () {
  mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    // 添加 webPreferences 属性,将预加载脚本注入程序中
    webPreferences: {
      preload: path.join(__dirname, 'preload.js')
    },
    titleBarStyle: 'hidden',
  })

  mainWindow.loadFile('index.html')
}

// 监听 ACTION:CAPTURE_PAGE 事件,截图后转为 base64 向渲染进程传递
ipcMain.handle('ACTION:CAPTURE_PAGE', () => {
  return mainWindow.webContents.capturePage()
    .then(page => page.toDataURL())
})
// preload.js
const { contextBridge, ipcRenderer } = require('electron')

// 向渲染进程 window 对象注入 APP 对象,该对象下有 capturePage 方法
contextBridge.exposeInMainWorld('APP', {
  capturePage: () => ipcRenderer.invoke('ACTION:CAPTURE_PAGE')
})


// main.js
// 修改创建窗口方法
function createWindow () {
  mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    // 添加 webPreferences 属性,将预加载脚本注入程序中
    webPreferences: {
      preload: path.join(__dirname, 'preload.js')
    },
    titleBarStyle: 'hidden',
  })

  mainWindow.loadFile('index.html')
}

// 监听 ACTION:CAPTURE_PAGE 事件,截图后转为 base64 向渲染进程传递
ipcMain.handle('ACTION:CAPTURE_PAGE', () => {
  return mainWindow.webContents.capturePage()
    .then(page => page.toDataURL())
})

至此,主进程的代码已全部完成,剩下的我们来实现渲染端的效果。给 HTML 添加 Canvas 标签,并设定一些基本的样式:

<!-- index.html -->
<style>
#canvas {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  /* 首先是不显示的 */
  display: none;
  width: 100%;
  height: 100%;
  z-index: 9999;
}
</style>

<canvas id="canvas" />
<!-- index.html -->
<style>
#canvas {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  /* 首先是不显示的 */
  display: none;
  width: 100%;
  height: 100%;
  z-index: 9999;
}
</style>

<canvas id="canvas" />

HTML 侧代码也完成了,剩下的就是如何实现整体效果了。我们给按钮元素添加点击事件来处理模式切换。

// renderer.js
const btn = document.querySelector('#switcher') // 模式切换按钮

let isDarkMode = false

const toggleTheme = () => {
  if (isDarkMode) {
    document.querySelector('html').classList.remove('dark')
    isDarkMode = false
    return
  }
  document.querySelector('html').classList.add('dark')
  isDarkMode = true
}

btn.addEventListener('click', ({ clientX, clientY }) => {
  toggleTheme()
})
// renderer.js
const btn = document.querySelector('#switcher') // 模式切换按钮

let isDarkMode = false

const toggleTheme = () => {
  if (isDarkMode) {
    document.querySelector('html').classList.remove('dark')
    isDarkMode = false
    return
  }
  document.querySelector('html').classList.add('dark')
  isDarkMode = true
}

btn.addEventListener('click', ({ clientX, clientY }) => {
  toggleTheme()
})

以上代码仅仅实现了不同模式的切换,还没有任何动画效果。为了实现动画效果,我们应该在切换模式前(调用 toggleTheme 方法前)完成一些工作:获取窗口截图,并绘制在 Canvas 中,然后悬浮在页面最顶层。

// renderer.js
const btn = document.querySelector('#switcher') // 模式切换按钮

btn.addEventListener('click', () => {
  window.APP.capturePage()
    .then(loadImage)
    .then(img => {
      // 先调用 spread 方法
      spread(img)
      toggleTheme()
    })
})

const canvas = document.querySelector('#canvas')
const ctx = canvas.getContext('2d')

const spread = (img) => {
  const w = window.innerWidth
  const h = window.innerHeight
  canvas.width = w
  canvas.height = h
  canvas.style.display = 'block'
  ctx.drawImage(img, 0, 0, img.width, img.height, 0, 0, w, h)
}

const loadImage = src => {
  const img = new Image()
  return new Promise((resolve, reject) => {
    img.onload = () => resolve(img)
    img.onerror = reject
    img.src = src
  })
}
// renderer.js
const btn = document.querySelector('#switcher') // 模式切换按钮

btn.addEventListener('click', () => {
  window.APP.capturePage()
    .then(loadImage)
    .then(img => {
      // 先调用 spread 方法
      spread(img)
      toggleTheme()
    })
})

const canvas = document.querySelector('#canvas')
const ctx = canvas.getContext('2d')

const spread = (img) => {
  const w = window.innerWidth
  const h = window.innerHeight
  canvas.width = w
  canvas.height = h
  canvas.style.display = 'block'
  ctx.drawImage(img, 0, 0, img.width, img.height, 0, 0, w, h)
}

const loadImage = src => {
  const img = new Image()
  return new Promise((resolve, reject) => {
    img.onload = () => resolve(img)
    img.onerror = reject
    img.src = src
  })
}

此时只是单纯的把截图绘制出来覆盖在页面上,即使页面已经切换模式,页面看起来与切换前没有变化,接着我们只剩下不断裁剪 Canvas 的工作了。

为了实现 Canvas 的裁剪,我们需要用到绘制上下文的 clip 方法和 非零与奇偶环绕规则clip 方法可以将 Canvas 裁剪出一个特定区域,后续任何绘制操作都不会影响区域以外的地方;关于 非零与奇偶环绕规则 可以参考这篇文章,这里不过多赘述。

我们期望以鼠标点击处为圆心裁剪一个圆形,并将图像绘制在圆形当中,通过不断裁剪以达到 Canvas 分散或聚集的效果,因此可以这么实现:

// renderer.js
const spread = (img, { x, y, reverse }) => {
  // x, y 为点击位置
  // reverse 控制发散还是收缩
  return new Promise(resolve => {
    const w = window.innerWidth
    const h = window.innerHeight
    canvas.width = w
    canvas.height = h
    canvas.style.display = 'block'
    // 这里我们通过 getMaxRadius 获取最大的圆形半径
    const radius = Math.ceil(getMaxRadius(x, y))
    const now = performance.now()
    const DURATION = 400
    // 使用 requestAnimationFrame 接口去操作Canvas,以提高流畅度
    const raf = () => {
      const percentage = (performance.now() - now) / DURATION
      // 此时应该画多大的圆
      const r = radius * (reverse ? 1 - percentage : percentage)
      ctx.clearRect(0, 0, w, h)
      ctx.save()
      ctx.beginPath()
      ctx.arc(x, y, r >= 0 ? r : 0, 0, 2 * Math.PI, !reverse)
      if (!reverse) {
        ctx.rect(0, 0, w, h)
      }
      ctx.closePath()
      ctx.clip() // 裁剪出特定区域
      ctx.drawImage(img, 0, 0, img.width, img.height, 0, 0, w, h)
      ctx.restore()
      if (percentage >= 1) {
        // 此时整个过渡效果已经完成,将 Canvas 隐藏
        canvas.style.display = 'none'
        return resolve()
      }
      requestAnimationFrame(raf)
    }
    requestAnimationFrame(raf)
  })
}

const getMaxRadius = (x, y) => {
  // x, y 为鼠标点击位置,分别计算该位置到 Canvas 四个角的距离,并取最大值
  const { width, height } = canvas
  return Math.max(
    calcLength(0, 0, x, y),
    calcLength(width, 0, x, y),
    calcLength(width, height, x, y),
    calcLength(0, height, x, y),
  )
}

const calcLength = (x0, y0, x1, y1) => {
  return Math.sqrt((y1 - y0) ** 2 + (x1 - x0) ** 2)
}
// renderer.js
const spread = (img, { x, y, reverse }) => {
  // x, y 为点击位置
  // reverse 控制发散还是收缩
  return new Promise(resolve => {
    const w = window.innerWidth
    const h = window.innerHeight
    canvas.width = w
    canvas.height = h
    canvas.style.display = 'block'
    // 这里我们通过 getMaxRadius 获取最大的圆形半径
    const radius = Math.ceil(getMaxRadius(x, y))
    const now = performance.now()
    const DURATION = 400
    // 使用 requestAnimationFrame 接口去操作Canvas,以提高流畅度
    const raf = () => {
      const percentage = (performance.now() - now) / DURATION
      // 此时应该画多大的圆
      const r = radius * (reverse ? 1 - percentage : percentage)
      ctx.clearRect(0, 0, w, h)
      ctx.save()
      ctx.beginPath()
      ctx.arc(x, y, r >= 0 ? r : 0, 0, 2 * Math.PI, !reverse)
      if (!reverse) {
        ctx.rect(0, 0, w, h)
      }
      ctx.closePath()
      ctx.clip() // 裁剪出特定区域
      ctx.drawImage(img, 0, 0, img.width, img.height, 0, 0, w, h)
      ctx.restore()
      if (percentage >= 1) {
        // 此时整个过渡效果已经完成,将 Canvas 隐藏
        canvas.style.display = 'none'
        return resolve()
      }
      requestAnimationFrame(raf)
    }
    requestAnimationFrame(raf)
  })
}

const getMaxRadius = (x, y) => {
  // x, y 为鼠标点击位置,分别计算该位置到 Canvas 四个角的距离,并取最大值
  const { width, height } = canvas
  return Math.max(
    calcLength(0, 0, x, y),
    calcLength(width, 0, x, y),
    calcLength(width, height, x, y),
    calcLength(0, height, x, y),
  )
}

const calcLength = (x0, y0, x1, y1) => {
  return Math.sqrt((y1 - y0) ** 2 + (x1 - x0) ** 2)
}

调用 spread 方法需要做一点改动:

// renderer.js
btn.addEventListener('click', ({ clientX, clientY }) => {
    window.APP.capturePage()
      .then(loadImage)
      .then(img => {
        spread(img, { x: clientX, y: clientY, reverse: isDarkMode })
        toggleTheme()
      })
  })
// renderer.js
btn.addEventListener('click', ({ clientX, clientY }) => {
    window.APP.capturePage()
      .then(loadImage)
      .then(img => {
        spread(img, { x: clientX, y: clientY, reverse: isDarkMode })
        toggleTheme()
      })
  })

最终我们实现了它,效果出乎意料的好,与 Bilibili Mac 端的表现几乎一致。唯一不足的地方是:capturePage 获取的截图质量差强人意(Bilibili Mac 端同样存在这个问题),但在几百毫秒的动画中,用户是难以察觉的,因此这个缺点也就可以忽略不计了。

result

该代码已推送到 Github awesome-light-dark-switching 项目,可以查看源码了解整个实现。