How to animate native Electron window

tags: electron nodejs n-api js nswindow macos objective-c++

This post demonstrates how to animate Electron’s native application window using native macOS API.

TL;DR

Call native system animation API on the native window handle provided by Electron. The complete source code can be found on GitHub, it is also published as NPM package:

npm install electron-window-rotator

Idea

Electron provides sufficient but minimal API to the native window where it renders content. For more sophisticated UI, there is win.getNativeWindowHandle() API that returns a Buffer. That Buffer is a platform-specific handle of the window: NSView* for macOS, Window(unsigned long) on Linux, and HWND for Windows.

The idea is to use this native handle conveniently provided by Electron to manipulate the native window.

Code

All animation code in the post was written for macOS. Linux and Windows require their own system-specific animation implementations.

High level implementation steps:

  • take a webview screenshot using Electron API (captures window content)
  • take a native window screenshot using macOS API (captures window frame)
  • combine Electron’s and native screenshots
  • create a separate native window big enough to play rotation animation in it
  • replace the original window with its screenshot and play animation
  • show the original window back.

high level implementation diagram

Taking a webview screenshot using Electron API

I haven’t found a working solution to do this in the native code. The API I used returns a native window screenshot including all native elements, but Electron’s renderer view was missed.

In Electron, webContents has capture API for taking webview screenshots, and it can be accessed from BrowserWindow itself:

const screenshot = await win.webContents.capturePage();
screenshot.toPNG() // to convert it to PNG

Native Nodejs extension

All following steps require creating a native Nodejs module to access macOS native API. That can be done using Nodejs N-API.

A simple native extension consists of:

  • rotator.mm /rotator.h – Objective-C++ files with all business logic
  • binding.gyp – build config for node-gyp that specifies entry point and files to include into Objective-C++ build
  • NativeExtension.cc – extension entry point, defines the object that gets exported from the native to JS side
  • index.js – extension index file that exports native part and provides external API to JS.

There is bindings module to “glue” Objective-C++ and JS code together:

const NativeExtension = require('bindings')('NativeExtension');

When all files are there, the extension can be built with:

node-gyp rebuild --debug

External NPM module API for Rotator.rotate() function will look like:

enum Direction {
  Left = 0,
  Right,
}

declare function rotate(
    electronWindow: Electron$BrowserWindow,
    duration?: number,
    direction?: Direction
): Promise<void>

Internal native extension API for NativeExtension.rotate() function will be:

declare function rotate(
    electronNativeWindowHandle: Buffer, // pointer to NSView on macOS
    screenshot: Buffer,                 // raw Electron screenshot data
    duration: number,                   // default: 1000ms
    direction: Direction                // default: 0 - left
): void;

To parse arguments on the Objective-C++ side, napi_get_buffer_info can be used for window handler and raw Electron screenshot data, napi_get_value_int32 to parse duration and direction argument values. See full arguments parsing code here.

Taking a native window screenshot

After passing window handle from JS code to native code as a Buffer (using win.getNativeWindowHandle()), that handle needs to be cast to *NSView:

// `windowBuffer` - a Buffer that contains pointer to the window view,
// on macOS Electron returns it from `win.getNativeWindowHandle()` call:
NSView *mainWindowView = *static_cast<NSView **>(windowBuffer);

Now we have the main window view. The window itself can be found here:

NSWindow *window = mainWindowView.window;

NSWindow has an extensive API. To take its screenshot, we need:

  • get NSWindow’s superview that includes a window frame
  • create an NSBitmapImageRep representation for that superview
  • create an NSImage and add the representation into it.

All these steps in code:

NSView *windowView = [window.contentView superview];
NSBitmapImageRep *windowScreenshotRep =
    [windowView bitmapImageRepForCachingDisplayInRect:windowView.bounds];
[windowView cacheDisplayInRect:windowView.bounds toBitmapImageRep:windowScreenshotRep];
NSSize windowScreenshotSize = NSMakeSize(CGImageGetWidth([windowScreenshotRep CGImage]),
                                         CGImageGetHeight([windowScreenshotRep CGImage]));
NSImage *windowScreenshot = [[NSImage alloc] initWithSize:windowScreenshotSize];
[windowScreenshot addRepresentation:windowScreenshotRep];

Now we have NSImage that can be put in an NSLayer of any other window.

Combining Electron’s and native screenshots

First, we need to convert raw Electron screenshot PNG data into NSImage, that as well can be done using NSBitmapImageRep:

NSData *data = [NSData dataWithBytes:electronScreenshotBuffer
                              length:electronScreenshotBufferLength];
NSBitmapImageRep *electronScreenshotRep = [NSBitmapImageRep imageRepWithData:data];
NSSize electronScreenshotSize = NSMakeSize(CGImageGetWidth([electronScreenshotRep CGImage]),
                                           CGImageGetHeight([electronScreenshotRep CGImage]));
NSImage *electronScreenshot = [[NSImage alloc] initWithSize:electronScreenshotSize];
[electronScreenshot addRepresentation:electronScreenshotRep];

To combine two NSImages, we can render the Electron’s screenshot into the native window screenshot. The start point for render is the bottom-left corner, so there is no need for offset.

[windowScreenshot lockFocus];
CGRect electronScreenshotRect = CGRectMake(0, 0, electronScreenshotSize.width,
                                                 electronScreenshotSize.height);
[electronScreenshot drawInRect:electronScreenshotRect];
[windowScreenshot unlockFocus];

Creating a new native window to play rotation animation

Once the full screenshot is ready, let’s create an NSLayer to render the screenshot and apply styles like rounded corners:

CALayer *imageLayer = [CALayer layer];
[imageLayer setContents:image];
[imageLayer setCornerRadius:10.0f];
[imageLayer setMasksToBounds:YES];

To play rotation animation, let’s create a new transparent frameless native window that is big enough to render rotated original window screenshot and its shadow:

NSRect animationWindowContentRect = NSMakeRect(/*...*/);
NSWindow *animationWindow =
    [[NSWindow alloc] initWithContentRect:animationWindowContentRect
                                styleMask:NSWindowStyleMaskBorderless
                                  backing:NSBackingStoreBuffered
                                    defer:NO];
[animationWindow setOpaque:NO];
[animationWindow setHasShadow:NO];
[animationWindow setBackgroundColor:[NSColor clearColor]];
[animationWindow.contentView setWantsLayer:YES];

Put the original window screenshot into the animation window and add shadows which were cut by rounded corners:

CALayer *animationWindowLayer = [animationWindow.contentView layer];
[animationWindowLayer addSublayer:imageLayer];
[animationWindowLayer setShadowRadius:20.0f];
[animationWindowLayer setShadowOpacity:0.7f];
[animationWindowLayer setShadowOffset:CGSizeMake(0, -20)];

Replacing the original window with its screenshot and playing animation

First, we need to hide the original window and show the animation window with the screenshot:

[window setAlphaValue:0.0];
[animationWindow setAlphaValue:1.0];

The macOS animation API provides a basic transform.rotation animation to rotate an NSLayer.

[CATransaction begin];
CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:@"transform.rotation"];
[animation setFromValue:[NSNumber numberWithDouble:0.0]];
CGFloat endAngle = (direction == DIRECTION_LEFT ? 1 : -1) * 2.0 * M_PI;
[animation setToValue:[NSNumber numberWithDouble:endAngle]];
[animation setDuration:duration / 1000.0];
[CATransaction setCompletionBlock:^{
    [window setAlphaValue:1.0];
    [animationWindow close];
}];
[imageLayer addAnimation:animation forKey:@"rotation"];
[CATransaction commit];

There is setCompletionBlock callback that gets called after the animation completes, where we close the animation window and bring the original window back.

Result and possible improvements

Some improvements can be made here, such as:

  • return a Promise from Rotator.rotate() that resolves only after the animation completes
  • replace img.toPNG() with img.getNativeHandle() API to pass just an NSImage pointer to the native code instead of PNG data
  • OR take the whole application screenshot from the native code and get rid of the need to pass Electron’s screenshot around.

The complete source code can be found on GitHub, it is also published as NPM package.