Electron 桌面应用侧边悬浮窗口设计与实现

本文将详细介绍如何使用 Electron 实现一个智能侧边悬浮窗口,包含窗口初始化、拖拽移动、丰富的操作功能以及平滑的动画效果。

1. 窗口初始化

首先让我们创建一个基本的悬浮窗口,它需要具备透明、无边框、始终置顶等特性:

javascript 复制代码
class SideFloatWin {
  constructor(options) {
    this.initConfig(options);
    this.initWin();
    this.openWin();
    this.registerEvent();
  }

  initConfig(options) {
    const { initUrl, env, mainWin } = options;
    this.initUrl = initUrl;
    this.env = env;
    this.mainWin = mainWin;
    this.canOpenSideFloatWin = true;
  }

  initWin() {
    const site = this.winSiteInfo(24, 180);
    
    this.win = new BrowserWindow({
      title: '悬浮窗口',
      x: site.x,
      y: site.y,
      width: 60,
      height: 60,
      show: false,
      resizable: false,
      frame: false,
      transparent: true,
      backgroundColor: '#00000000',
      alwaysOnTop: true,
      webPreferences: {
        preload: path.join(__dirname, "preload.js"),
        contextIsolation: true,
      }
    });

    // 加载页面内容
    if(this.env === "development") {
      this.win.loadURL(this.initUrl + "#/side");
    } else {
      this.win.loadFile(this.initUrl, { hash: 'side' });
    }
    
    this.win.hide();
    this.win.setMenu(null);
    this.win.setSkipTaskbar(true);
    
    // macOS 多工作空间支持
    if (process.platform === 'darwin') {
      this.win.setVisibleOnAllWorkspaces(true);
    }
  }
}

这段代码创建了一个宽高均为 60px 的圆形悬浮窗口,具有以下特性:

  • 无边框透明设计
  • 始终置于其他窗口之上
  • 不在任务栏显示
  • 支持多工作空间显示

2. 窗口移动实现

实现窗口拖拽功能需要自定义移动逻辑,因为无边框窗口无法通过系统默认方式拖动:

javascript 复制代码
// 自定义窗口移动类
class CustomWindowMove {
  constructor() {
    this.isOpen = false;
    this.win = null;
    this.winStartPosition = { x: 0, y: 0, width: 0, height: 0 };
    this.startPosition = { x: 0, y: 0 };
  }

  init(win) {
    this.win = win;
  }

  start() {
    this.isOpen = true;
    // 保存窗口初始位置和大小
    const winPosition = this.win.getPosition();
    const winSize = this.win.getSize();
    this.winStartPosition.x = winPosition[0];
    this.winStartPosition.y = winPosition[1];
    this.winStartPosition.width = winSize[0];
    this.winStartPosition.height = winSize[1];

    // 保存鼠标初始位置
    const mouseStartPosition = screen.getCursorScreenPoint();
    this.startPosition.x = mouseStartPosition.x;
    this.startPosition.y = mouseStartPosition.y;

    // 开始移动
    this.move();
  }

  move() {
    if (!this.isOpen || this.win.isDestroyed() || !this.win.isFocused()) {
      this.end();
      return;
    }

    // 计算新位置
    const cursorPosition = screen.getCursorScreenPoint();
    const x = this.winStartPosition.x + cursorPosition.x - this.startPosition.x;
    const y = this.winStartPosition.y + cursorPosition.y - this.startPosition.y;

    // 更新窗口位置
    this.win.setBounds({
      x: x,
      y: y,
      width: this.winStartPosition.width,
      height: this.winStartPosition.height,
    });

    // 循环调用实现平滑移动
    setTimeout(() => {
      this.move();
    }, 20);
  }

  end() {
    this.isOpen = false;
  }
}

// 在主类中集成拖拽功能
handleWinMove() {
  this.floatMoveEvent = new CustomWindowMove();
  this.floatMoveEvent.init(this.win);
}

// 处理拖拽事件
moveFloatWin(info) {
  switch (info) {
    case 'homeDragWindowStart':
      this.floatMoveEvent.start();
      break;
    case 'homeDragWindowEnd':
      this.floatMoveEvent.end();
      break;
  }
}

3. 窗口操作功能实现

为悬浮窗口添加丰富的交互功能,包括右键菜单、单击和双击操作:

javascript 复制代码
// 注册IPC事件处理
registerEvent() {
  ipcMain.on("side-float-operation", (event, info) => {
    this.floatOperation(info);
  });
}

// 操作处理
floatOperation(info) {
  switch (info.type) {
    case "move":
      this.moveFloatWin(info.value);
      break;
    case "rightClick":
      this.createMenu();
      break;
    case "dblClick":
      this.openMainWindow();
      break;
    case "mouseout":
      this.showWinHalf();
      break;
    case "mouseover":
      this.showWinAll();
      break;
    default:
      break;
  }
}

// 创建右键菜单
createMenu() {
  const menuList = [
    { 
      label: '打开主窗口', 
      click: () => this.mainWin.show() 
    },
    { 
      label: '开机启动', 
      type: 'checkbox', 
      checked: app.getLoginItemSettings().openAtLogin, 
      click: () => {
        app.setLoginItemSettings({ 
          openAtLogin: !app.getLoginItemSettings().openAtLogin 
        });
      }
    },
    { 
      label: '悬浮窗功能', 
      type: 'checkbox', 
      checked: this.canOpenSideFloatWin, 
      click: () => {
        this.canOpenSideFloatWin = !this.canOpenSideFloatWin;
        if(this.canOpenSideFloatWin) {
          this.openWin();
        } else {
          this.closeWin();
        }
      }
    },
    { 
      label: '功能1', 
      type: 'checkbox', 
      checked: true, 
      click: () => {
        // 功能1切换逻辑
      }
    },
    { 
      label: '功能2', 
      type: 'checkbox', 
      checked: true, 
      click: () => {
        // 功能2切换逻辑
      }
    },
    { 
      label: '退出', 
      click: () => app.exit()
    }
  ];
  
  const contextMenu = Menu.buildFromTemplate(menuList);
  const mousePosition = screen.getCursorScreenPoint();
  contextMenu.popup(this.win, mousePosition.x, mousePosition.y);
}

// 打开主窗口
openMainWindow() {
  if(this.mainWin) {
    this.mainWin.show();
  }
}

在前端Vue组件中,我们需要相应实现鼠标事件的触发:

vue 复制代码
<template>
  <div class="side-win" 
       @mouseleave="mouseout" 
       @mouseenter="mouseover"
       @mousedown.stop="moveWindow($event)" 
       @mouseup.stop="stopMove($event)">
    <div class="side-content" 
         @contextmenu.prevent="rightClick" 
         @click.stop="clickSide" 
         @dblclick.stop="dblClickSide">
      <div class="side-icon"></div>
    </div>
  </div>
</template>

<script setup>
const rightClick = () => {
  window.electronAPI.sidefloatOperation({ type: "rightClick" });
}

let clickTimer = null;
const clickSide = () => {
  clearTimeout(clickTimer);
  clickTimer = setTimeout(() => {
    // 单击操作
  }, 200);
}

const dblClickSide = () => {
  clearTimeout(clickTimer);
  window.electronAPI.sidefloatOperation({ type: "dblClick" });
}

const mouseDowm = ref(false);
const moveWindow = (e) => {
  mouseDowm.value = true;
  window.electronAPI.sidefloatOperation({
    type: "move",
    value: "homeDragWindowStart"
  });
}

const stopMove = (e) => {
  mouseDowm.value = false;
  window.electronAPI.sidefloatOperation({
    type: "move",
    value: "homeDragWindowEnd"
  });
}

const mouseout = () => {
  if(mouseDowm.value) return;
  window.electronAPI.sidefloatOperation({ type: "mouseout" });
}

const mouseover = () => {
  if(mouseDowm.value) return;
  window.electronAPI.sidefloatOperation({ type: "mouseover" });
}
</script>

4. 窗口动画效果

为提升用户体验,我们为悬浮窗口添加平滑的动画效果:

javascript 复制代码
// 显示完整窗口
showWinAll() {
  this.moveWindowWithAnimation(this.win, this.winSiteInfo(59, 180), 200);
}

// 显示一半窗口(鼠标移出时)
showWinHalf() {
  this.moveWindowWithAnimation(this.win, this.winSiteInfo(24, 180), 200);
}

// 窗口移动动画实现
moveWindowWithAnimation(window, {x, y, displayFrequency}, duration) {
  const startX = window.getPosition()[0];
  const startY = window.getPosition()[1];
  const startTime = Date.now();
  
  function animate() {
    const currentTime = Date.now();
    const elapsedTime = currentTime - startTime;
    const progress = Math.min(elapsedTime / duration, 1);
    
    // 计算当前窗口位置(使用缓动函数)
    const currentX = startX + (x - startX) * progress;
    const currentY = startY;
    
    // 设置窗口位置
    window.setPosition(Math.round(currentX), Math.round(currentY));
    
    // 如果动画未完成,继续下一帧
    if (progress < 1) {
      setTimeout(animate, 1000 / displayFrequency / 2);
    }
  }
  
  // 启动动画
  animate();
}

// 计算窗口位置信息
winSiteInfo(offsetX, offsetY) {
  const siteInfo = screen.getAllDisplays().reduce((p, c) => {
    const {x, y, width, height} = c.bounds;
    if(x + width - offsetX > p.x){
      p.x = x + width - offsetX;
      p.y = y + height - offsetY;
      p.displayFrequency = c.displayFrequency;
    }
    return p;
  }, {x: 0, y: 0, displayFrequency: 60});
  
  return siteInfo;
}

最后,我们需要为悬浮窗口添加合适的样式,使其外观更加美观:

html 复制代码
<style scoped>
.side-win{
  width: 60px;
  height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
  cursor: pointer;
  background: transparent;
}

.side-content{
  display: flex;
  justify-content: center;
  align-items: center;
  width: 48px;
  height: 48px;
  background: #FFFFFF;
  box-shadow: 0px 3px 6px 1px rgba(0,0,0,0.16);
  border-radius: 24px;
  transition: all 0.3s ease;
}

.side-content:hover {
  transform: scale(1.1);
  box-shadow: 0px 5px 12px 2px rgba(0,0,0,0.2);
}

.side-icon{
  background-image: url("@/assets/icon.png");
  background-size: 100% 100%;
  width: 38px;
  height: 38px;
}
</style>
相关推荐
uhakadotcom1 小时前
在python中,使用conda,使用poetry,使用uv,使用pip,四种从效果和好处的角度看,有哪些区别?
前端·javascript·面试
玲小珑2 小时前
LangChain.js 完全开发手册(九)LangGraph 状态图与工作流编排
前端·langchain·ai编程
鹏多多2 小时前
深入解析vue的keep-alive缓存机制
前端·javascript·vue.js
JarvanMo2 小时前
用 `alice` 来检查 Flutter 中的 HTTP 调用
前端
小图图2 小时前
Claude Code 黑箱揭秘
前端·后端
吃饺子不吃馅2 小时前
为什么SnapDOM 比 html2canvas截图要快?
前端·javascript·面试
这里有鱼汤2 小时前
miniQMT下载历史行情数据太慢怎么办?一招提速10倍!
前端·python
用户21411832636023 小时前
dify案例分享-免费玩转 AI 绘图!Dify 整合 Qwen-Image,文生图 图生图一步到位
前端
IT_陈寒3 小时前
Redis 性能翻倍的 7 个冷门技巧,第 5 个大多数人都不知道!
前端·人工智能·后端