Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: add useStateMachineInputs hook #310

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
62 changes: 62 additions & 0 deletions src/hooks/useStateMachineInputs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { EventType, StateMachineInput, Rive } from '@rive-app/canvas';
import { useEffect, useState } from 'react';

/**
* Custom hook for fetching multiple stateMachine inputs from a rive file.
* Particularly useful for fetching multiple inputs from a variable number of input names.
*
* @param rive - Rive instance
* @param stateMachineName - Name of the state machine
* @param inputNames - Name and initial value of the inputs
* @returns StateMachineInput[]
*/
export default function useStateMachineInputs(
rive: Rive | null,
stateMachineName?: string,
inputNames?: {
name: string;
initialValue?: number | boolean;
}[]
) {
const [inputs, setInputs] = useState<StateMachineInput[]>([]);

useEffect(() => {
const syncInputs = () => {
if (!rive || !stateMachineName || !inputNames) return;

const riveInputs = rive.stateMachineInputs(stateMachineName);
if (!riveInputs) return;

// To optimize lookup time from O(n) to O(1) in the following loop
const riveInputLookup = new Map<string, StateMachineInput>(
riveInputs.map(input => [input.name, input])
);

setInputs(() => {
// Iterate over inputNames instead of riveInputs to preserve array order
return inputNames
.filter(inputName => riveInputLookup.has(inputName.name))
.map(inputName => {
const riveInput = riveInputLookup.get(inputName.name)!;

if (inputName.initialValue !== undefined) {
riveInput.value = inputName.initialValue;
}

return riveInput;
});
});
};

syncInputs();
if (rive) {
rive.on(EventType.Load, syncInputs);

return () => {
rive.off(EventType.Load, syncInputs);
};
}
}, [rive]);

return inputs;
}
18 changes: 16 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,22 @@ import useRive from './hooks/useRive';
import useStateMachineInput from './hooks/useStateMachineInput';
import useResizeCanvas from './hooks/useResizeCanvas';
import useRiveFile from './hooks/useRiveFile';
import useStateMachineInputs from './hooks/useStateMachineInputs';

export default Rive;
export { useRive, useStateMachineInput, useResizeCanvas, useRiveFile , RiveProps };
export { RiveState, UseRiveParameters, UseRiveFileParameters, UseRiveOptions } from './types';
export {
useRive,
useStateMachineInput,
useStateMachineInputs,
useResizeCanvas,
useRiveFile,
RiveProps,
};
export {
RiveState,
UseRiveParameters,
UseRiveFileParameters,
UseRiveOptions,
} from './types';

export * from '@rive-app/canvas';
161 changes: 161 additions & 0 deletions test/useStateMachineInputs.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { mocked } from 'jest-mock';
import { renderHook } from '@testing-library/react-hooks';

import useStateMachineInputs from '../src/hooks/useStateMachineInputs';
import { Rive, StateMachineInput } from '@rive-app/canvas';

jest.mock('@rive-app/canvas', () => ({
Rive: jest.fn().mockImplementation(() => ({
on: jest.fn(),
off: jest.fn(),
stop: jest.fn(),
stateMachineInputs: jest.fn(),
})),
Layout: jest.fn(),
Fit: {
Cover: 'cover',
},
Alignment: {
Center: 'center',
},
EventType: {
Load: 'load',
},
StateMachineInputType: {
Number: 1,
Boolean: 2,
Trigger: 3,
},
}));

function getRiveMock({
smiInputs,
}: {
smiInputs?: null | StateMachineInput[];
} = {}) {
const riveMock = new Rive({
canvas: undefined as unknown as HTMLCanvasElement,
});
if (smiInputs) {
riveMock.stateMachineInputs = jest.fn().mockReturnValue(smiInputs);
}

return riveMock;
}

describe('useStateMachineInputs', () => {
it('returns empty array if there is null rive object passed', () => {
const { result } = renderHook(() => useStateMachineInputs(null));
expect(result.current).toEqual([]);
});

it('returns empty array if there is no state machine name', () => {
const riveMock = getRiveMock();
mocked(Rive).mockImplementation(() => riveMock);

const { result } = renderHook(() =>
useStateMachineInputs(riveMock, '', [{ name: 'testInput' }])
);
expect(result.current).toEqual([]);
});

it('returns empty array if there are no input names provided', () => {
const riveMock = getRiveMock();
mocked(Rive).mockImplementation(() => riveMock);

const { result } = renderHook(() =>
useStateMachineInputs(riveMock, 'smName', [])
);
expect(result.current).toEqual([]);
});

it('returns empty array if there are no inputs for the state machine', () => {
const riveMock = getRiveMock({ smiInputs: [] });
mocked(Rive).mockImplementation(() => riveMock);

const { result } = renderHook(() =>
useStateMachineInputs(riveMock, 'smName', [{ name: 'testInput' }])
);
expect(result.current).toEqual([]);
});

it('returns only the inputs that exist in the state machine', () => {
const smInputs = [
{ name: 'input1' } as StateMachineInput,
{ name: 'input2' } as StateMachineInput,
];
const riveMock = getRiveMock({ smiInputs: smInputs });
mocked(Rive).mockImplementation(() => riveMock);

const { result } = renderHook(() =>
useStateMachineInputs(riveMock, 'smName', [
{ name: 'input1' },
{ name: 'nonexistent' },
{ name: 'input2' },
])
);
expect(result.current).toEqual([smInputs[0], smInputs[1]]);
});

it('sets initial values on the inputs when provided', () => {
const smInputs = [
{ name: 'boolInput', value: false } as StateMachineInput,
{ name: 'numInput', value: 0 } as StateMachineInput,
];
const riveMock = getRiveMock({ smiInputs: smInputs });
mocked(Rive).mockImplementation(() => riveMock);

const { result } = renderHook(() =>
useStateMachineInputs(riveMock, 'smName', [
{ name: 'boolInput', initialValue: true },
{ name: 'numInput', initialValue: 42 },
])
);

expect(result.current[0].value).toBe(true);
expect(result.current[1].value).toBe(42);
});

it('does not set initial values if not provided', () => {
const smInputs = [
{ name: 'boolInput', value: false } as StateMachineInput,
{ name: 'numInput', value: 0 } as StateMachineInput,
];
const riveMock = getRiveMock({ smiInputs: smInputs });
mocked(Rive).mockImplementation(() => riveMock);

const { result } = renderHook(() =>
useStateMachineInputs(riveMock, 'smName', [
{ name: 'boolInput' },
{ name: 'numInput' },
])
);

expect(result.current[0].value).toBe(false);
expect(result.current[1].value).toBe(0);
});

it('preserves the order of inputs as specified in inputNames', () => {
const smInputs = [
{ name: 'input1' } as StateMachineInput,
{ name: 'input2' } as StateMachineInput,
{ name: 'input3' } as StateMachineInput,
];
const riveMock = getRiveMock({ smiInputs: smInputs });
mocked(Rive).mockImplementation(() => riveMock);

const { result } = renderHook(() =>
useStateMachineInputs(riveMock, 'smName', [
{ name: 'input3' },
{ name: 'input1' },
{ name: 'input2' },
])
);

expect(result.current.map((input) => input.name)).toEqual([
'input3',
'input1',
'input2',
]);
});
});