Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 7 additions & 12 deletions .github/workflows/lint-and-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: actions/setup-node@v4
- uses: actions/setup-node@v5
with:
node-version: lts/*
cache: 'yarn'
Expand All @@ -33,30 +33,25 @@ jobs:
build:
name: Build affected projects
needs: lint
runs-on: macos-latest
runs-on: macos-26

steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: actions/setup-node@v4
- uses: actions/setup-node@v5
with:
node-version: lts/*
cache: 'yarn'
- uses: nrwl/nx-set-shas@v4
with:
main-branch-name: 'master'
- name: Xcode Select Version
# see available xcode versions in https://github.com/actions/runner-images/blob/main/images/macos/macos-14-arm64-Readme.md#xcode
# see available xcode versions in https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md#xcode
uses: mobiledevops/xcode-select-version-action@v1
with:
xcode-select-version: 16.4
- name: Install visionOS platform
run: |
defaults write com.apple.dt.Xcode AllowUnsupportedVisionOSHost -bool YES
defaults write com.apple.CoreSimulator AllowUnsupportedVisionOSHost -bool YES
xcodebuild -downloadPlatform visionOS
xcode-select-version: 26.6
- name: yarn install
run: yarn --immutable
- name: Build.all affected
Expand Down
91 changes: 82 additions & 9 deletions packages/nativescript-view-shot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,72 @@ Create and render a view completely detached:
```ts
renderDetached() {
const myView = new GridLayout();
const renderedViewData = loadViewInBackground(myView);
// measure and layout the detached view with the desired width/height
measureAndLayout(renderedViewData.hostView, Screen.mainScreen.widthDIPs);
const result = renderToImageSource(renderedViewData.hostView);
disposeBackgroundView(renderedViewData);
// loads the view in the background, measures, lays out, renders and disposes it
const result = renderViewToImage(myView, { width: Screen.mainScreen.widthDIPs });
// work with result
}
```

`renderViewToImage` is shorthand for:
```ts
const renderedViewData = loadViewInBackground(myView);
measureAndLayout(renderedViewData.hostView, Screen.mainScreen.widthDIPs);
const result = renderToImageSource(renderedViewData.hostView);
disposeBackgroundView(renderedViewData);
```

#### Render options

Every render function accepts `RenderOptions`:

```ts
renderToImageSource(view, {
scale: 1, // pixels per DIP/point, defaults to the screen scale
opaque: true, // the view paints every pixel, skip the alpha channel
range: 'standard', // iOS only: 'standard' (default) | 'extended' | 'automatic'
bitmapConfig: 'ARGB_8888', // Android only: 'ARGB_8888' (default) | 'RGB_565'
});
```

To get an image of an exact pixel size (thumbnails, uploads, external displays), size the view in DIPs equal to the target pixels and pass `scale: 1`. The default scale renders `screenScale²` times as many pixels.

Render functions return `null` when the view has no native view or has a zero width or height (usually a missing `measureAndLayout` call).

#### Releasing images

The pixel memory of a rendered image lives in native memory that the JS garbage collector doesn't account for, so discarded images can pile up until an unrelated GC runs. When rendering often, release images as soon as you are done with them:

```ts
// release explicitly with core's Utils.releaseNativeObject
const image = renderViewToImage(view, { width: 100, height: 100 });
sendToDevice(image);
Utils.releaseNativeObject(image.ios ?? image.android); // image must not be used afterwards

// or scope the image to a callback; it's released when the callback returns or its promise settles
withRenderedImage(view, (image) => sendToDevice(image));
```

Don't dispose an image that is still in use from JS, such as one bound to an `Image` view.

#### Repeated renders

For views rendered many times (live previews, external displays), load the view once and mutate it between renders instead of rebuilding it every time:

```ts
const myView = buildView();
const renderedViewData = loadViewInBackground(myView);

function renderFrame(text: string) {
myLabel.text = text;
// background views are not attached to a window, so layout must be run manually after every change
measureAndLayout(renderedViewData.hostView, 1200, 100);
withRenderedImage(renderedViewData.hostView, (image) => sendToDevice(image), { scale: 1 });
}

// when done
disposeBackgroundView(renderedViewData);
```

#### API

```ts
Expand All @@ -71,16 +128,31 @@ export interface BackgroundViews {
// (enables CSS inheritance)
logicalContainer?: View;
}
export interface RenderOptions {
scale?: number;
opaque?: boolean;
range?: 'standard' | 'extended' | 'automatic'; // iOS only
bitmapConfig?: 'ARGB_8888' | 'RGB_565'; // Android only
}
export interface RenderViewOptions extends RenderOptions {
width?: number;
height?: number;
host?: View;
}
export function measureAndLayout(hostView: View, width?: number, height?: number): void;

export class LogicalViewContainer extends LayoutBase {}

export function renderToImageSource(hostView: View): ImageSource;
export function renderToImageSource(hostView: View, options?: RenderOptions): ImageSource | null;

export function loadViewInBackground(view: View, host?: View): BackgroundViews;

export function disposeBackgroundView(backgroundViews: BackgroundViews): void;

export function renderViewToImage(view: View, options?: RenderViewOptions): ImageSource | null;

export function withRenderedImage<T>(view: View, callback: (image: ImageSource | null) => T, options?: RenderOptions): T;

```

### Angular
Expand Down Expand Up @@ -140,7 +212,8 @@ export class NativescriptViewShotComponent {
import { Injector, TemplateRef, Type, ViewContainerRef } from '@angular/core';
import { NgViewRef } from '@nativescript/angular';
import { ImageSource, View, ViewBase } from '@nativescript/core';
export interface DrawableOptions<T = unknown> {
import { RenderOptions } from '@valor/nativescript-view-shot';
export interface DrawableOptions<T = unknown> extends RenderOptions {
/**
* target width of the view and image, in dip. If not specified, the measured width of the view will be used.
*/
Expand Down Expand Up @@ -172,8 +245,8 @@ export interface DrawableOptions<T = unknown> {
logicalHost?: ViewBase | ViewContainerRef;
}
export declare class ViewShotService {
captureInBackground<T>(type: Type<T> | TemplateRef<T>, options?: DrawableOptions<T>): Promise<ImageSource>;
captureRenderedView(view: View): ImageSource;
captureInBackground<T>(type: Type<T> | TemplateRef<T>, options?: DrawableOptions<T>): Promise<ImageSource | null>;
captureRenderedView(view: View, options?: RenderOptions): ImageSource | null;
}

```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"@valor/nativescript-view-shot": ["../../../dist/packages/nativescript-view-shot"]
},
"outDir": "../../../dist/out-tsc",
"declarationDir": "../../../dist/out-tsc"
"declarationDir": "../../../dist/out-tsc",
"strict": true
},
"files": ["index.ts"]
}
3 changes: 2 additions & 1 deletion packages/nativescript-view-shot/angular/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"rootDirs": [".", "../.."]
"rootDirs": [".", "../.."],
"strict": true
}
}
81 changes: 43 additions & 38 deletions packages/nativescript-view-shot/angular/view-shot.service.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { ComponentRef, inject, Injectable, Injector, TemplateRef, Type, ViewContainerRef } from '@angular/core';

import { generateNativeScriptView, isDetachedElement, isInvisibleNode, NgView, NgViewRef } from '@nativescript/angular';
import { ContentView, ImageSource, LayoutBase, ProxyViewContainer, View, ViewBase } from '@nativescript/core';
import { disposeBackgroundView, loadViewInBackground, measureAndLayout, renderToImageSource } from '@valor/nativescript-view-shot';
import { ContentView, ImageSource, View, ViewBase } from '@nativescript/core';
import { disposeBackgroundView, loadViewInBackground, measureAndLayout, RenderOptions, renderToImageSource } from '@valor/nativescript-view-shot';

export interface DrawableOptions<T = unknown> {
export interface DrawableOptions<T = unknown> extends RenderOptions {
/**
* target width of the view and image, in dip. If not specified, the measured width of the view will be used.
*/
Expand Down Expand Up @@ -41,55 +41,60 @@ export interface DrawableOptions<T = unknown> {
})
export class ViewShotService {
private myInjector = inject(Injector);
async captureInBackground<T>(type: Type<T> | TemplateRef<T>, { width, height, injector, delay, logicalHost }: DrawableOptions<T> = {}): Promise<ImageSource> {
async captureInBackground<T>(type: Type<T> | TemplateRef<T>, { width, height, injector, delay, logicalHost, ...renderOptions }: DrawableOptions<T> = {}): Promise<ImageSource | null> {
let host: NgView = logicalHost instanceof ViewContainerRef ? logicalHost.element.nativeElement : logicalHost;
while ((isInvisibleNode(host) || isDetachedElement(host)) && (host = host.parentNode)); // iterate over parent nodes until we find a visible one
const ngView = generateNativeScriptView(type, {
injector: injector || (logicalHost instanceof ViewContainerRef ? logicalHost.injector : this.myInjector),
keepNativeViewAttached: true,
});
if (ngView.ref instanceof ComponentRef) {
ngView.ref.changeDetectorRef.detectChanges();
} else {
ngView.ref.detectChanges();
}
if (ngView.view.parent) {
if (ngView.view.parent instanceof ContentView) {
ngView.view.parent.content = null;
try {
if (ngView.ref instanceof ComponentRef) {
ngView.ref.changeDetectorRef.detectChanges();
} else {
ngView.view.parent._removeView(ngView.view);
ngView.ref.detectChanges();
}
}
const drawableViews = loadViewInBackground(ngView.view, host);
const { hostView } = drawableViews;
if (ngView.view.parent) {
if (ngView.view.parent instanceof ContentView) {
ngView.view.parent.content = null as unknown as View;
} else {
ngView.view.parent._removeView(ngView.view);
}
}
const drawableViews = loadViewInBackground(ngView.view, host);
try {
const { hostView } = drawableViews;

measureAndLayout(hostView, width, height);
measureAndLayout(hostView, width, height);

if (typeof delay === 'function' || (typeof delay === 'number' && delay >= 0)) {
if (typeof delay === 'number') {
await new Promise<void>((resolve) =>
setTimeout(() => {
resolve();
}, delay)
);
} else {
await delay(ngView);
if (ngView.ref instanceof ComponentRef) {
ngView.ref.changeDetectorRef.detectChanges();
} else {
ngView.ref.detectChanges();
if (typeof delay === 'function' || (typeof delay === 'number' && delay >= 0)) {
if (typeof delay === 'number') {
await new Promise<void>((resolve) =>
setTimeout(() => {
resolve();
}, delay),
);
} else {
await delay(ngView);
if (ngView.ref instanceof ComponentRef) {
ngView.ref.changeDetectorRef.detectChanges();
} else {
ngView.ref.detectChanges();
}
}
measureAndLayout(hostView, width, height);
}

return renderToImageSource(hostView, renderOptions);
} finally {
disposeBackgroundView(drawableViews);
}
measureAndLayout(hostView, width, height);
} finally {
ngView.ref.destroy();
}

const result = renderToImageSource(hostView);
disposeBackgroundView(drawableViews);
ngView.ref.destroy();
return result;
}

captureRenderedView(view: View) {
return renderToImageSource(view);
captureRenderedView(view: View, options?: RenderOptions) {
return renderToImageSource(view, options);
}
}
41 changes: 39 additions & 2 deletions packages/nativescript-view-shot/common.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isAndroid, LayoutBase, Utils, View } from '@nativescript/core';
import { LayoutBase, Utils, View } from '@nativescript/core';

export interface BackgroundViews {
// parent of the view that will be rendered to ensure margins are respected
Expand All @@ -10,6 +10,42 @@ export interface BackgroundViews {
logicalContainer?: View;
}

export interface RenderOptions {
/**
* Pixels per DIP/point in the resulting image. Defaults to the main screen scale.
* For a pixel-exact image, size the view in DIPs equal to the target pixels and pass `scale: 1`.
*/
scale?: number;
/**
* The view paints every pixel, so the image can skip its alpha channel.
*/
opaque?: boolean;
/**
* iOS only. Color range of the backing buffer. `'automatic'` picks an extended-range
* (64 bpp) buffer on wide-color screens, doubling memory. Defaults to `'standard'`.
*/
range?: 'standard' | 'extended' | 'automatic';
/**
* Android only. `'RGB_565'` halves memory but drops alpha and reduces color depth. Defaults to `'ARGB_8888'`.
*/
bitmapConfig?: 'ARGB_8888' | 'RGB_565';
}

export interface RenderViewOptions extends RenderOptions {
/**
* target width of the view and image, in dip. If not specified, the measured width of the view will be used.
*/
width?: number;
/**
* target height of the view and image, in dip. If not specified, the measured height of the view will be used.
*/
height?: number;
/**
* logical parent of the view, used for CSS inheritance. See `loadViewInBackground`.
*/
host?: View;
}

export function measureAndLayout(hostView: View, width?: number, height?: number) {
const dpWidth = width ? Utils.layout.toDevicePixels(width) : 0;
const dpHeight = height ? Utils.layout.toDevicePixels(height) : 0;
Expand Down Expand Up @@ -48,7 +84,8 @@ export class LogicalViewContainer extends LayoutBase {
return false;
}

public createNativeView() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public createNativeView(): any {
return undefined;
}

Expand Down
Loading
Loading