在electron中实现一个桌面悬浮球
2025年1月17日 20:47
概要
在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()
}
}
这样就实现了整个交互的过程,详细讲解可以看这个视频。