From e9364cacc2e221b22a1772690daaf18a85271f12 Mon Sep 17 00:00:00 2001 From: HayesGordon Date: Thu, 31 Oct 2024 18:09:14 +0000 Subject: [PATCH] feat: add responsive layout properties and other fixes This PR: - Fixes hit testing when providing a `layoutScaleFactor` - Exposes `artboardWidth`, `artboardHeight`, and `devicePixelRatio` on the `Rive` object. This is needed for more advanced user control, and is also needed in Rive-React to complete the layout implementation. - Adds a bunch of tests for the above, and expected responsive behaviour under various conditions. Diffs= 86b7531b20 feat: add responsive layout properties and other fixes (#8451) Co-authored-by: Gordon --- .rive_head | 2 +- js/src/rive.ts | 110 ++++++++++++- js/src/utils/registerTouchInteractions.ts | 3 + js/test/artboard.test.ts | 28 ++++ js/test/layout.test.ts | 27 +++ js/test/resonsive_layout.test.ts | 192 ++++++++++++++++++++++ 6 files changed, 358 insertions(+), 4 deletions(-) create mode 100644 js/test/resonsive_layout.test.ts diff --git a/.rive_head b/.rive_head index 8424cc48..5deb7d90 100644 --- a/.rive_head +++ b/.rive_head @@ -1 +1 @@ -5297cf0cc25ef5b45d438dddaefb2ac52cb8c4a1 +86b7531b209507a61cc8aa5fac2ea9bf89668d2d diff --git a/js/src/rive.ts b/js/src/rive.ts index 759f4994..d1c97aea 100644 --- a/js/src/rive.ts +++ b/js/src/rive.ts @@ -1510,6 +1510,13 @@ export class Rive { // Keep a local value of the set volume to update it asynchronously private _volume = 1; + // Keep a local value of the set width to update it asynchronously + private _artboardWidth: number | undefined = undefined; + + // Keep a local value of the set height to update it asynchronously + private _artboardHeight: number | undefined = undefined; + + // Keep a local value of the device pixel ratio used in rendering and canvas/artboard resizing private _devicePixelRatioUsed = 1; // Whether the canvas element's size is 0 @@ -1700,6 +1707,7 @@ export class Rive { fit: this._layout.runtimeFit(this.runtime), alignment: this._layout.runtimeAlignment(this.runtime), isTouchScrollEnabled: touchScrollEnabledOption, + layoutScaleFactor: this._layout.layoutScaleFactor, }); } } @@ -1730,6 +1738,16 @@ export class Rive { } } + private initArtboardSize() { + if (!this.artboard) return; + + // Use preset values if they are not undefined + this._artboardWidth = this.artboard.width = + this._artboardWidth || this.artboard.width; + this._artboardHeight = this.artboard.height = + this._artboardHeight || this.artboard.height; + } + // Initializes runtime with Rive data and preps for playing private async initData( artboardName: string, @@ -1756,6 +1774,9 @@ export class Rive { autoplay, ); + // Initialize the artboard size + this.initArtboardSize(); + // Check for audio this.initializeAudio(); @@ -2280,13 +2301,18 @@ export class Rive { * Accounts for devicePixelRatio as a multiplier to render the size of the canvas drawing surface. * Uses the size of the backing canvas to set new width/height attributes. Need to re-render * and resize the layout to match the new drawing surface afterwards. - * Useful function for consumers to include in a window resize listener + * Useful function for consumers to include in a window resize listener. + * + * This method will set the {@link devicePixelRatioUsed} property. + * + * Optionally, you can provide a {@link customDevicePixelRatio} to provide a + * custom value. */ public resizeDrawingSurfaceToCanvas(customDevicePixelRatio?: number) { if (this.canvas instanceof HTMLCanvasElement && !!window) { const { width, height } = this.canvas.getBoundingClientRect(); const dpr = customDevicePixelRatio || window.devicePixelRatio || 1; - this._devicePixelRatioUsed = dpr; + this.devicePixelRatioUsed = dpr; this.canvas.width = dpr * width; this.canvas.height = dpr * height; this.startRendering(); @@ -2761,7 +2787,7 @@ export class Rive { } /** - * getter and setter for the volume of the artboard + * Getter / Setter for the volume of the artboard */ public get volume(): number { if (this.artboard && this.artboard.volume !== this._volume) { @@ -2776,6 +2802,84 @@ export class Rive { this.artboard.volume = value * audioManager.systemVolume; } } + + /** + * The width of the artboard. + * + * This will return undefined if the artboard is not loaded yet and a custom + * width has not been set. + * + * Do not set this value manually when using {@link resizeDrawingSurfaceToCanvas} + * with a {@link Layout.fit} of {@link Fit.Layout}, as the artboard width is + * automatically set. + */ + public get artboardWidth(): number | undefined { + if (this.artboard) { + return this.artboard.width; + } + return this._artboardWidth; + } + + public set artboardWidth(value: number) { + this._artboardWidth = value; + if (this.artboard) { + this.artboard.width = value; + } + } + + /** + * The height of the artboard. + * + * This will return undefined if the artboard is not loaded yet and a custom + * height has not been set. + * + * Do not set this value manually when using {@link resizeDrawingSurfaceToCanvas} + * with a {@link Layout.fit} of {@link Fit.Layout}, as the artboard height is + * automatically set. + */ + public get artboardHeight(): number | undefined { + if (this.artboard) { + return this.artboard.height; + } + return this._artboardHeight; + } + + public set artboardHeight(value: number) { + this._artboardHeight = value; + + if (this.artboard) { + this.artboard.height = value; + } + } + + /** + * Reset the artboard size to its original values. + */ + public resetArtboardSize() { + if (this.artboard) { + this.artboard.resetArtboardSize(); + this._artboardWidth = this.artboard.width; + this._artboardHeight = this.artboard.height; + } else { + // If the artboard isn't loaded, we need to reset the custom width and height + this._artboardWidth = undefined; + this._artboardHeight = undefined; + } + } + + /** + * The device pixel ratio used in rendering and canvas/artboard resizing. + * + * This value will be overidden by the device pixel ratio used in + * {@link resizeDrawingSurfaceToCanvas}. If you use that method, do not set this value. + */ + public get devicePixelRatioUsed(): number { + return this._devicePixelRatioUsed; + } + + public set devicePixelRatioUsed(value: number) { + this._devicePixelRatioUsed = value; + } } /** diff --git a/js/src/utils/registerTouchInteractions.ts b/js/src/utils/registerTouchInteractions.ts index 74c1b1d6..3987e363 100644 --- a/js/src/utils/registerTouchInteractions.ts +++ b/js/src/utils/registerTouchInteractions.ts @@ -9,6 +9,7 @@ export interface TouchInteractionsParams { fit: rc.Fit; alignment: rc.Alignment; isTouchScrollEnabled?: boolean; + layoutScaleFactor?: number; } interface ClientCoordinates { @@ -69,6 +70,7 @@ export const registerTouchInteractions = ({ fit, alignment, isTouchScrollEnabled = false, + layoutScaleFactor = 1.0, }: TouchInteractionsParams) => { if ( !canvas || @@ -142,6 +144,7 @@ export const registerTouchInteractions = ({ maxY: boundingRect.height, }, artboard.bounds, + layoutScaleFactor, ); const invertedMatrix = new rive.Mat2D(); forwardMatrix.invert(invertedMatrix); diff --git a/js/test/artboard.test.ts b/js/test/artboard.test.ts index da1d3a23..a8a8dc2e 100644 --- a/js/test/artboard.test.ts +++ b/js/test/artboard.test.ts @@ -79,4 +79,32 @@ test("Artboard bounds can be retrieved from a loaded Rive file", (done) => { }); }); +test("Artboard width and height can be get/set from a loaded Rive file", (done) => { + const canvas = document.createElement("canvas"); + const r = new rive.Rive({ + canvas: canvas, + artboard: "MyArtboard", + buffer: stateMachineFileBuffer, + onLoad: () => { + const initialWidth = r.artboardWidth; + const initialHeight = r.artboardHeight; + + expect(initialWidth).toBe(500); + expect(initialHeight).toBe(500); + + r.artboardWidth = 1000; + r.artboardHeight = 1000; + + expect(r.artboardWidth).toBe(1000); + expect(r.artboardHeight).toBe(1000); + + r.resetArtboardSize(); + + expect(r.artboardWidth).toBe(initialWidth); + expect(r.artboardHeight).toBe(initialHeight); + done(); + }, + }); +}); + // #endregion diff --git a/js/test/layout.test.ts b/js/test/layout.test.ts index 83e6ae0f..8973bb90 100644 --- a/js/test/layout.test.ts +++ b/js/test/layout.test.ts @@ -98,4 +98,31 @@ test("Layouts can be copied with overridden values", (): void => { expect(layout.maxY).toBe(40); }); +test("New Layout with Fit.Layout works as expected", (): void => { + let layout = new rive.Layout({ + fit: rive.Fit.Layout, + layoutScaleFactor: 2, + }); + + expect(layout.fit).toBe(rive.Fit.Layout); + expect(layout.layoutScaleFactor).toBe(2); +}); + +test("layoutScaleFactor can be copied with overridden values", (): void => { + let layout = new rive.Layout({ + fit: rive.Fit.Layout, + layoutScaleFactor: 2, + }); + + expect(layout.fit).toBe(rive.Fit.Layout); + expect(layout.layoutScaleFactor).toBe(2); + + layout = layout.copyWith({ + layoutScaleFactor: 3, + }); + + expect(layout.fit).toBe(rive.Fit.Layout); + expect(layout.layoutScaleFactor).toBe(3); +}); + // #endregion diff --git a/js/test/resonsive_layout.test.ts b/js/test/resonsive_layout.test.ts new file mode 100644 index 00000000..31aa8eef --- /dev/null +++ b/js/test/resonsive_layout.test.ts @@ -0,0 +1,192 @@ +// Note: This uses the canvas-advanced-single module, which has WASM embedded in JS +// which means there is no loading an external WASM file for tests +import * as rive from "../src/rive"; +import { stateMachineFileBuffer } from "./assets/bytes"; + +// #region responsive layout + +const canvasInitialWidth = 400; +const canvasInitialHeight = 200; +const artboardInitialWidth = 500; +const artboardInitialHeight = 500; + +let mockGetBoundingClientRect: jest.SpyInstance; + +beforeEach(() => { + mockGetBoundingClientRect = jest.spyOn( + HTMLElement.prototype, + 'getBoundingClientRect' + ).mockImplementation(() => ({ + width: canvasInitialWidth, + height: canvasInitialHeight, + top: 0, + left: 0, + right: canvasInitialWidth, + bottom: canvasInitialHeight, + x: 0, + y: 0, + })); +}); + +afterEach(() => { + mockGetBoundingClientRect.mockRestore(); +}); + +test("Layout of type Fit.Layout adjusts artboard and canvas size", (done) => { + const canvas = document.createElement("canvas"); + canvas.width = canvasInitialWidth; + canvas.height = canvasInitialHeight; + document.body.appendChild(canvas); + + const r = new rive.Rive({ + canvas: canvas, + artboard: "MyArtboard", + buffer: stateMachineFileBuffer, + layout: new rive.Layout({ + fit: rive.Fit.Layout, + }), + onLoad: () => { + + // Validate initial sizes + expect(r.artboardWidth).toBe(artboardInitialWidth); + expect(r.artboardHeight).toBe(artboardInitialHeight); + expect(canvas.width).toBe(canvasInitialWidth); + expect(canvas.height).toBe(canvasInitialHeight); + + // Set new artboard size should change artboard size + // but not canvas size + r.artboardWidth = artboardInitialWidth * 2; + r.artboardHeight = artboardInitialHeight * 3; + expect(r.artboardWidth).toBe(artboardInitialWidth * 2); + expect(r.artboardHeight).toBe(artboardInitialHeight * 3); + expect(canvas.width).toBe(canvasInitialWidth); + expect(canvas.height).toBe(canvasInitialHeight); + + // Reset artboard size to initial values + r.resetArtboardSize(); + expect(r.artboardWidth).toBe(artboardInitialWidth); + expect(r.artboardHeight).toBe(artboardInitialHeight); + expect(canvas.width).toBe(canvasInitialWidth); + expect(canvas.height).toBe(canvasInitialHeight); + + var devicePixelRatio = 1; + // Resize canvas to match device pixel ratio + // This should not change the canvas size with a dpr of 1 + // This should set the artboard size to the canvas size if + // layout type is Fit.Layout + r.resizeDrawingSurfaceToCanvas(devicePixelRatio); + expect(canvas.width).toBe(canvasInitialWidth); + expect(canvas.height).toBe(canvasInitialHeight); + expect(r.artboardWidth).toBe(canvas.width); + expect(r.artboardHeight).toBe(canvas.height); + + devicePixelRatio = 2; + // Resize canvas to match device pixel ratio + // This should multiply the canvas size by the dpr + // This should set the artboard size to the original + // `getBoundingClientRect` size, or half the canvas size if + // layout type is Fit.Layout + r.resizeDrawingSurfaceToCanvas(devicePixelRatio); + expect(canvas.width).toBe(canvasInitialWidth * devicePixelRatio); + expect(canvas.height).toBe(canvasInitialHeight * devicePixelRatio); + expect(r.artboardWidth).toBe(canvasInitialWidth); + expect(r.artboardWidth).toBe(canvas.width / devicePixelRatio); + expect(r.artboardHeight).toBe(canvasInitialHeight); + expect(r.artboardHeight).toBe(canvas.height / devicePixelRatio); + + + // Change layout scale factor to 2 + var layoutScaleFactor = 2; + r.layout = new rive.Layout({ + fit: rive.Fit.Layout, + layoutScaleFactor: 2, + }); + + // Resize canvas to match device pixel ratio + // This should multiply the canvas size by the dpr + // This should set the artboard size to the original + // `getBoundingClientRect` size (or half the canvas size) + // divided by the layout scale factor + r.resizeDrawingSurfaceToCanvas(layoutScaleFactor); + expect(canvas.width).toBe(canvasInitialWidth * devicePixelRatio); + expect(canvas.height).toBe(canvasInitialHeight * devicePixelRatio); + expect(r.artboardWidth).toBe(canvasInitialWidth / layoutScaleFactor); + expect(r.artboardHeight).toBe(canvasInitialHeight / layoutScaleFactor); + + // Change layout type to default, anything but (Fit.Layout) + // This should not change the artboard size when the canvas is resized + // in resizeDrawingSurfaceToCanvas regardless of layoutScaleFactor or devicePixelRatio + layoutScaleFactor = 3; + devicePixelRatio = 2; + r.resetArtboardSize(); + r.layout = new rive.Layout({ + // fit: rive.Fit.Contain, // Do not set to Fit.Layout, use default or any other value + layoutScaleFactor: layoutScaleFactor, + }); + r.resizeDrawingSurfaceToCanvas(devicePixelRatio); + expect(canvas.width).toBe(canvasInitialWidth * devicePixelRatio); + expect(canvas.height).toBe(canvasInitialHeight * devicePixelRatio); + expect(r.artboardWidth).toBe(artboardInitialWidth); + expect(r.artboardHeight).toBe(artboardInitialHeight); + + done(); + }, + }); +}); + +test("Artboard size can be set before onLoad", (done) => { + const canvas = document.createElement("canvas"); + canvas.width = canvasInitialWidth; + canvas.height = canvasInitialHeight; + document.body.appendChild(canvas); + + const r = new rive.Rive({ + canvas: canvas, + artboard: "MyArtboard", + buffer: stateMachineFileBuffer, + layout: new rive.Layout({ + fit: rive.Fit.Layout, + }), + onLoad: () => { + // Artboard width and height should be set to the values set before onLoad + expect(r.artboardWidth).toBe(100); + expect(r.artboardHeight).toBe(200); + + done(); + }, + + }); + // Artboard width should be undefined before onLoad + expect(r.artboardWidth).toBe(undefined); + // Artboard height should be undefined before onLoad + expect(r.artboardHeight).toBe(undefined); + + // Set artboard width and height before onLoad + r.artboardWidth = 100; + r.artboardHeight = 200; + expect(r.artboardWidth).toBe(100); + expect(r.artboardHeight).toBe(200); + +}); + +test("devicePixelRatioUsed can be get/set", (done) => { + const canvas = document.createElement("canvas"); + const r = new rive.Rive({ + canvas: canvas, + artboard: "MyArtboard", + buffer: stateMachineFileBuffer, + onLoad: () => { + expect(r.devicePixelRatioUsed).toBe(1); + r.resizeDrawingSurfaceToCanvas(2); + expect(r.devicePixelRatioUsed).toBe(2); + r.devicePixelRatioUsed = 3; + expect(r.devicePixelRatioUsed).toBe(3); + r.devicePixelRatioUsed = 4; + r.resizeDrawingSurfaceToCanvas(5); // This should override the devicePixelRatioUsed above + expect(r.devicePixelRatioUsed).toBe(5); + done(); + }, + }); +}); + +// #endregion