Electron 实现带有底部滑出动画的通知弹窗

4,579 阅读3分钟

 先贴出最终的效果图:

看完效果,直接贴代码:

import { screen } from 'electron';
import ChildWindow from '../win/childWin';
import { NOTIFIER } from '../../config';
import { getOptions } from '../../utils/dialog';
import { easeInQuad, easeOutQuad } from '../common';

export type PopupNotify = {  
    title: string;
    content: string;
};
// NOTIFIER 其实就是这个
export const NOTIFIER = {
    width: 290,
    height: 200,
    url: '/notifier',
};
const offset = 8;
export default class Notifier {
    isShown: boolean;
    winInstance?: ChildWindow;
    screenHeight: number = 0;
    private timer: any = null;

    constructor(args: PopupNotify) {
        this.isShown = false;
        this.createNotifier(args);
    }

    createNotifier(args: PopupNotify) {
        let display = screen.getPrimaryDisplay().workAreaSize;
        let WIDTH = display.width;
        let HEIGHT = display.height; // 减去任务栏的高度    
        this.screenHeight = HEIGHT;    
        const winInstance = (this.winInstance = new ChildWindow({      
            width: NOTIFIER.width,
            height: NOTIFIER.height,      
            x: WIDTH - NOTIFIER.width - offset,      
            y: HEIGHT,      
            frame: false,      
            resizable: false,      
            movable: false,      
            alwaysOnTop: true,      
            opacity: 0,      
            webPreferences: {        
                devTools: false,      
            },    
        }));    

        winInstance.bind({      
            readyToShow: () => {        
                this.show();      
            },      
            focus: this.focus,      
            blur: this.blur,      
            closed: this.closed,    
        });    

        const options = getOptions(NOTIFIER.url, {      
            ...args,
            min: false,
        });    

        winInstance.loadFile(options, 'window');    
        return winInstance;  
    }  

    show() {    
        this.winInstance && this.winInstance.show();    
        this.animate(this.winClose);  
    }  

    /** 执行从底部划出动画 渐变y和opcity */  
    animate = (callback: Function, open: boolean = true) => {    
        let currentTime = 0;    
        this.timer = setInterval(() => {      
            currentTime += 10;      
            if (currentTime > 200) {        
                open && (this.isShown = true);        
                clearInterval(this.timer);        
                /** 开始执行销毁当前弹窗的方法 */        
                callback();      
            } else {        
                this.setBounds(Math.floor(
                    open ? 
                        easeOutQuad(currentTime, this.screenHeight, NOTIFIER.height + offset, 200, !open)
                        : easeInQuad(currentTime, this.screenHeight - NOTIFIER.height - offset, NOTIFIER.height + offset, 200)));
                this.setOpacity(Number(open ? easeInQuad(currentTime, 0, 1, 200, open).toFixed(2) : easeOutQuad(currentTime, 1, 0, 200, open).toFixed(2)));
            }    
        }, 10);  
    };  

    setBounds = (y: number) => {    
        try {      
            this.winInstance && this.winInstance.win && !this.winInstance.win.isDestroyed() && this.winInstance.win.setBounds({ y });    
        } catch (error) {      
            console.log(error);    
        }  
    };  

    setOpacity = (opacity: number) => {    
        try {      
            this.winInstance && this.winInstance.win && !this.winInstance.win.isDestroyed() && this.winInstance.win.setOpacity(opacity);    
        } catch (error) {      
            console.log(error);    
        }  
    };

    focus = () => {    
        /** 获取焦点之后 取消倒计时关闭当前窗口 但是由于窗口打开是默认获取焦点的 focus不会重复触发     
        *  所以这时候isShown为false 不会clearTimeout 除非先失去焦点 再获取焦点才会clearTimeout */    
        if (this.isShown && this.timer) {      
            clearTimeout(this.timer);      
            this.timer = null;    
        }  
    };  

    blur = () => {    
        /** 开始倒计时关闭当前窗口 */    
        this.timer === null && this.winClose();  
    };  

    /** 窗口关闭触发的事件 */  
    closed = () => {    
        this.timer && clearTimeout(this.timer);  
    };  

    /** 倒计时2s之后关闭当前窗口 */  
    winClose = () => {    
        this.timer = setTimeout(() => {      
            this.animate(() => {        
                this.winInstance && this.winInstance.win && !this.winInstance.win.isDestroyed() && this.winInstance.win.close();               }, false);    
        }, 3000);  
    };
}

// common.ts 中的缓动函数的方法

/** linear */
export function linear(
    currentTime: number, 
    startValue: number,
    changeValue: number,  
    duration: number,  
    increase: boolean = true
) {  
    return increase ? (changeValue * currentTime) / duration + startValue 
        : startValue - (changeValue * currentTime) / duration;
}

/** ease-in */
export function easeInQuad(
    currentTime: number,  
    startValue: number,  
    changeValue: number,  
    duration: number,  
    increase: boolean = true
) {  
    currentTime /= duration;  
    return increase ? changeValue * currentTime * currentTime + startValue 
        : startValue - changeValue * currentTime * currentTime;
}

/** ease-out */
export function easeOutQuad(  
    currentTime: number,  
    startValue: number,  
    changeValue: number,  
    duration: number,  
    increase: boolean = true
): number {  
    currentTime /= duration;  
    return increase ? -changeValue * currentTime * (currentTime - 2) + startValue 
        : startValue - -changeValue * currentTime * (currentTime - 2);
}

关于速度曲线可以查看这篇文章:缓动函数

// ChildWindow 其实就是对于new BrowserWindow 过程中的一个封装
import { BrowserWindow, BrowserWindowConstructorOptions, app } from 'electron';
import { LoadFileOption } from 'app/utils/dialog';
const merge = require('lodash/merge');
const path = require('path');
const os = require('os');
export interface WindowListener {  
    readyToShow?: () => void;  
    shown?: () => void;  
    finish?: () => void;
    closed?: () => void;  
    focus?: () => void;  
    blur?: () => void;
}

export default class ChildWindow {  
    win: BrowserWindow | IAnyObject;  
    winOptions: BrowserWindowConstructorOptions;  
    constructor(args: BrowserWindowConstructorOptions) {    
        this.win = {};    
        this.winOptions = args;    
        this.createMainWindow();  
    }  

    createMainWindow = () => {    
        this.win = new BrowserWindow(      
            merge(        
                {          
                    center: true,          
                    // frame: false,          
                    show: false,          
                    resizable: false,          
                    transparent: false,          
                    webPreferences: {            
                        nodeIntegration: true,          
                    },        
                },        
                this.winOptions      
            )    
        );    

        if (os.platform() === 'win32') {      
            /** windows 下禁用右键 */      
            this.win.hookWindowMessage(278, (e: Event) => {        
                this.win.setEnabled(false);        
                setTimeout(() => this.win.setEnabled(true), 100);        
                return;      
            });    
        }  
    };  

    bind = (cb?: WindowListener) => {    
        if (!cb) return;    
        cb.readyToShow &&      
            this.win.once('ready-to-show', () => {        
                cb.readyToShow!();      
            });    

        cb.shown &&      
            this.win.once('show', () => {        
                cb.shown!();      
            });    

        cb.focus &&      
            this.win.on('focus', () => {        
                cb.focus!();      
            });    

        cb.blur &&      
            this.win.on('blur', () => {        
                 cb.blur!();      
            });    

        cb.closed &&      
            this.win.once('closed', () => {        
                cb.closed!();      
            });    

        cb.finish &&      
            this.win.webContents.once('did-finish-load', () => {        
                cb.finish!();      
            });  
    };  

    loadURL = (url: string) => {    
        const userAgent = this.win.webContents.getUserAgent();    
        this.win.loadURL(url, { userAgent });  
    };  

    loadFile = (options: LoadFileOption, temp: string = 'index') => {    
        this.win.loadFile(      
            app.isPackaged        
                ? path.resolve(__dirname, `pages/${temp}.html`)        
                    : path.resolve(__dirname, `../../pages/${temp}.html`),      
            {        
                ...options,      
            }    
         );  
      };  

    getWebContents = () => {    
        return this.win ? this.win.webContents : null;  
    };  

    getUrl = () => {    
        const webContents = this.win.webContents;    
        if (!webContents || webContents.isDestroyed() || webContents.isCrashed())      
            return '';    
        return webContents.getURL();  
    };  

    show = () => {    
        this.win.show();  
      };  

    focus = () => {    
        this.win.focus();  
    };  

    isVisible = () => {    
        return this.win.isVisible();  
    };  

    isDestroyed = () => {    
        return this.win.isDestroyed();  
    };  

    isMinimized = () => {    
        return this.win.isMinimized();  
    };  

    restore = () => {    
        this.win.restore();  
    };  

    mini = () => {    
        this.win.minimize();  
    };  

    hide = () => {    
        this.win.hide();  
    };  

    max = () => {    
        this.win.maximize();  
    };  

    reload = () => {    
        this.win.reload();  
    };  

    close = () => {    
        this.win.close();  
    };
}

实现的原理其实很简单,主要是利用了 electron browserWindow 的 setBounds 和 setOpacity 方法,然后通过 screen.getPrimaryDisplay().workAreaSize 获取到屏幕的宽高,默认先将窗口放在屏幕右下角,并向下偏移自身的高度,当弹窗触发readyToShow事件的时候,就开始执行过渡动画。

最后

实现的源码其实就在我的这个项目里面:electron_client
项目中本地数据的持久化使用的是 sqlite,然后使用 sequelize 数据库模型库。

自己也写了一个react ssr的脚手架工具:js-react-ssr-cli  

已经实现项目 DOM、路由、store 和 css 同构,开发环境的热更新功能

支持选择 js 和 typescript,支持选择 MobX 和 Redux

默认的 css 预处理器为:stylus

欢迎使用哦。