阅读视图

发现新文章,点击刷新页面。

在electron中实现一个桌面悬浮球

 概要

在electron + vue3 搭建的应用中实现了一个桌面悬浮球/mini窗口的功能,支持任意拖拽、丝滑的菜单折叠展开动画效果。在实现过程中需要关注的一些点:

1、管理悬浮球窗口创建以及配置:需要一个透明的窗口来承载视图。

2、解决electron拖拽和点击事件冲突(核心):因为使用 -webkit-app-region: drag 样式的方式会导致拖拽和点击事件冲突,所以需要通过渲染进程和主进程的通信来解决窗口位置的更新。

3、初始化组件位置,计算窗口拖动位置:这里需要一些拖拽状态的判断、还有更新位置信息。

4、折叠展开动画和事件处理

最终效果

代码细节实现

首先需要窗口electron窗口来承载vue页面,在窗口管理模块中配置需要的参数,主要是frame、transport、skipTaskbar,然后注入preload中的进程交互事件,来实现渲染进程和主进程的通信。

windowList.set(WINDOW_ROUTE_NAME.MINI_WINDOW, {
  options() {
    return {
      width: 190,
      height: 170,
      frame: false,
      show: true,
      skipTaskbar: true,
      transparent: true,
      resizable: false,
      alwaysOnTop: true,
      webPreferences: {
        preload,
        nodeIntegration: true,
        contextIsolation: true,
      }
    }
  },
  callback(window: any) {
    loadUrl(window, WINDOW_URLS.MINI_WINDOW)
    // 初始化悬浮球位置
    const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize
    window.setPosition(screenWidth - window.getSize()[0] -100, screenHeight - window.getSize()[1] - 100)
  }
})

同时还要注册监听事件,来接受渲染进程的唤起动作

  ipcMainService.on("app:show:mini-window", (event, {
    name,
  }) => {
    const miniWindow = windowManager.createWindow(WINDOW_ROUTE_NAME.MINI_WINDOW)
    miniWindow.show()
  })

在页面中触发窗口唤起的动作,发送事件到主进程

const showMiniWindow = (value: boolean) => {
  ipcRenderService.send('app:show:mini-window', value)
}

在vue模板中添加基本的dom结构,注册事件handleMouseDown、handleMouseEnter、handleMouseLeave来实现位置计算、进程通信、折叠展开动画。

<template>
  <div class="mini-window"
       :class="{ 'expanded': isExpanded }"
       @mousedown="handleMouseDown"
       @mouseenter="handleMouseEnter"
       @mouseleave="handleMouseLeave">
    <!-- 折叠状态 -->
    <div class="mini-content">
      <span class="mini-bg"></span>
    </div>
    
    <!-- 展开状态 -->
    <div class="expanded-content" @click.stop>
      <div class="actions">
        <div class="action-item" @click="handleAction('restore')">
          <el-icon><FullScreen /></el-icon>
          <span>还原</span>
        </div>
        <div class="action-item" @click="handleAction('settings')">
          <el-icon><Setting /></el-icon>
          <span>设置</span>
        </div>
        <div class="action-item" @click="handleAction('dashboard')">
          <el-icon><House /></el-icon>
          <span>仪表盘</span>
        </div>
      </div>
    </div>
  </div>
</template>

下面是需要用到的样式

.mini-window {
  position: relative;
  margin-left: 125px;
  margin-top: 109px;
  width: 50px;
  height: 50px;
  border-radius: 25px;
  background: #fff;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
  transition: all 0.3s ease;
  overflow: hidden;
  user-select: none;
  
  &.expanded {
    width: 160px;
    height: 150px;
    border-radius: 12px;
    transform: translate(-110px, -100px);
    
    .mini-content {
      opacity: 0;
      pointer-events: none;
    }
    
    .expanded-content {
      opacity: 1;
      pointer-events: auto;
    }
  }
  
  .mini-content {
    position: absolute;
    bottom: 1px;
    right: 5px;
    opacity: 1;
    transition: opacity 0.3s;
    .mini-bg {
      cursor: pointer;
      display: inline-block;
      background: var(--app-color-gradient-blue);
      width: 40px;
      height: 40px;
      border-radius: 20px;
    }
  }
  
  .expanded-content {
    position: absolute;
    bottom: 0;
    right: 0;
    width: 160px;
    height: 150px;
    opacity: 0;
    padding: 9px 12px;
    pointer-events: none;
    transition: opacity 0.3s;
    
    .actions {
      display: flex;
      flex-direction: column-reverse;
      gap: 8px;
      
      .action-item {
        display: flex;
        align-items: center;
        gap: 12px;
        padding: 10px 12px;
        border-radius: 8px;
        cursor: pointer;
        transition: all 0.2s ease;
        color: var(--ep-color-primary);
        
        .el-icon {
          font-size: 18px;
        }
        
        span {
          font-size: 14px;
        }
        
        &:hover {
          background-color: var(--menu-active-bg-color);
          transform: scale(1.06);
          outline: 1px solid var(--ep-color-primary);
        }
      }
    }
  }
}

鼠标按下事件用来获取窗口初始位置,通过ipcRenderService.invoke和主进程通信获取位置信息,然后注册鼠标移动和鼠标抬起事件。

// 处理鼠标按下事件
const handleMouseDown = (e: MouseEvent) => {
  if (isExpanded.value) return // 展开状态不允许拖动
  
  isDragging = false
  initialMouseX = e.screenX // 使用screenX/screenY获取相对于屏幕的坐标
  initialMouseY = e.screenY
  mouseDownTime = Date.now()
  // 获取窗口初始位置
  ipcRenderService.invoke('app:window:get-position').then(([x, y]: [number, number]) => {
    windowInitialX = x
    windowInitialY = y
    
    document.addEventListener('mousemove', handleMouseMove)
    document.addEventListener('mouseup', handleMouseUp)
  })
}

 鼠标移动时,判断阈值并计算新的位置,然后和主进程通信设置当前的坐标位置。

// 处理鼠标移动事件
const handleMouseMove = (e: MouseEvent) => {
  const deltaX = e.screenX - initialMouseX
  const deltaY = e.screenY - initialMouseY
  
  // 判断是否达到拖动阈值
  if (!isDragging && (Math.abs(deltaX) > 5 || Math.abs(deltaY) > 5)) {
    isDragging = true
  }

  if (isDragging) {
    // 计算新位置
    const newX = windowInitialX + deltaX
    const newY = windowInitialY + deltaY
    
    // 发送新位置到主进程
    ipcRenderService.send('app:window:set-position', { x: newX, y: newY })
  }
}

鼠标抬起时需要移除前面注册的鼠标移动和鼠标抬起事件

const handleMouseUp = () => {
  document.removeEventListener('mousemove', handleMouseMove)
  document.removeEventListener('mouseup', handleMouseUp)
  
  // 如果不是拖拽且点击时间小于200ms,则触发展开/收起
  if (!isDragging && (Date.now() - mouseDownTime < 200)) {
    toggleExpand()
  }
}

这样就实现了整个交互的过程,详细讲解可以看这个视频。

electron 实现一个丝滑的桌面悬浮球/mini窗口_哔哩哔哩_bilibili

❌