diff --git a/examples/public/db_list_test.riv b/examples/public/db_list_test.riv new file mode 100644 index 0000000..fb26b72 Binary files /dev/null and b/examples/public/db_list_test.riv differ diff --git a/examples/src/components/DataBindingTests.stories.tsx b/examples/src/components/DataBindingTests.stories.tsx index 136c293..6971bdb 100644 --- a/examples/src/components/DataBindingTests.stories.tsx +++ b/examples/src/components/DataBindingTests.stories.tsx @@ -2,7 +2,7 @@ import React, { useEffect } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import { within, expect, waitFor, userEvent } from '@storybook/test'; -import { StringPropertyTest, NumberPropertyTest, BooleanPropertyTest, ColorPropertyTest, EnumPropertyTest, NestedViewModelTest, TriggerPropertyTest, PersonForm, PersonInstances, ImagePropertyTest } from './DataBindingTests'; +import { StringPropertyTest, NumberPropertyTest, BooleanPropertyTest, ColorPropertyTest, EnumPropertyTest, NestedViewModelTest, TriggerPropertyTest, PersonForm, PersonInstances, ImagePropertyTest, TodoListTest } from './DataBindingTests'; const meta: Meta = { title: 'Tests/DataBinding', @@ -385,4 +385,100 @@ export const ImagePropertyStory: StoryObj = { expect(canvas.getByTestId('current-image-url')).toBeTruthy(); }, { timeout: 5000 }); } +}; + + +export const TodoListStory: StoryObj = { + name: 'Todo List Property', + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Wait for the Rive file to load + await waitFor(() => { + expect(canvas.getByTestId('list-length')).toBeTruthy(); + }, { timeout: 3000 }); + + const initialLengthText = canvas.getByTestId('list-length').textContent; + const initialCount = parseInt(initialLengthText?.match(/Items: (\d+)/)?.[1] || '0'); + + // Test 1: addInstance - Add item to end + const addButton = canvas.getByTestId('add-item-button'); + await userEvent.click(addButton); + + await waitFor(() => { + expect(canvas.getByTestId('list-length').textContent).toContain(`Items: ${initialCount + 1}`); + }); + + // Test 2: addInstanceAt - Add item at specific index (if we have items) + if (initialCount > 0) { + const addAtButton = canvas.getByTestId('add-item-at-button'); + await userEvent.click(addAtButton); + + await waitFor(() => { + expect(canvas.getByTestId('list-length').textContent).toContain(`Items: ${initialCount + 2}`); + }); + } + + // Test 3: getInstanceAt - Interact with specific items + const currentCount = initialCount + (initialCount > 0 ? 2 : 1); + if (currentCount > 0) { + await waitFor(() => { + expect(canvas.getByTestId('todo-item-0')).toBeTruthy(); + }); + + // Edit the first item + const todoText = canvas.getByTestId('todo-text-0'); + await userEvent.clear(todoText); + + // Wait for the input to be cleared to avoid issues with autocomplete + await waitFor(() => { + expect((todoText as HTMLInputElement).value).toBe(''); + }, { timeout: 2000 }); + + await userEvent.click(todoText); + await userEvent.paste('Test Item'); + + await waitFor(() => { + expect(canvas.getByTestId('todo-text-value-0').textContent).toContain('Test Item'); + }, { timeout: 3000 }); + + } + + // Test 4: swap - Swap first two items + if (currentCount >= 2) { + const firstText = canvas.getByTestId('todo-text-0').value; + const secondText = canvas.getByTestId('todo-text-1').value; + + const swapButton = canvas.getByTestId('swap-button'); + await userEvent.click(swapButton); + + await waitFor(() => { + expect(canvas.getByTestId('todo-text-0')).toHaveValue(secondText); + expect(canvas.getByTestId('todo-text-1')).toHaveValue(firstText); + }, { timeout: 3000 }); + } + + // Test 5: removeInstance - Remove by instance reference + if (currentCount > 0) { + const removeInstanceButton = canvas.getByTestId('remove-instance-button'); + await userEvent.click(removeInstanceButton); + + await waitFor(() => { + expect(canvas.getByTestId('list-length').textContent).toContain(`Items: ${currentCount - 1}`); + }, { timeout: 3000 }); + } + + // Test 6: removeInstanceAt - Remove by index + const afterRemoveInstance = currentCount > 0 ? currentCount - 1 : 0; + if (afterRemoveInstance > 0) { + const removeIndexButton = canvas.getByTestId('remove-index-button'); + await userEvent.click(removeIndexButton); + + await waitFor(() => { + expect(canvas.getByTestId('list-length').textContent).toContain(`Items: ${afterRemoveInstance - 1}`); + }, { timeout: 3000 }); + } + + } }; \ No newline at end of file diff --git a/examples/src/components/DataBindingTests.tsx b/examples/src/components/DataBindingTests.tsx index 8817f9b..a4e589c 100644 --- a/examples/src/components/DataBindingTests.tsx +++ b/examples/src/components/DataBindingTests.tsx @@ -11,7 +11,9 @@ import Rive, { useViewModelInstanceColor, useViewModelInstanceTrigger, useViewModelInstanceImage, - decodeImage + decodeImage, + ViewModelInstance, + useViewModelInstanceList } from '@rive-app/react-webgl2'; @@ -610,4 +612,178 @@ export const ImagePropertyTest = ({ src }: { src: string }) => { )} ); +}; + +// List Property Test + +const TodoItemComponent = ({ + index, + todoItem +}: { + index: number; + todoItem: ViewModelInstance | null; +}) => { + const { value: text, setValue: setText } = useViewModelInstanceString('text', todoItem); + const { value: isDone, setValue: setIsDone } = useViewModelInstanceBoolean('isDone', todoItem); + + if (!todoItem) { + return
Item not found
; + } + + return ( +
+ setIsDone(e.target.checked)} + /> + setText(e.target.value)} + style={{ flex: 1 }} + /> +
+ Text: {text} +
+
+ Done: {isDone ? 'true' : 'false'} +
+
+ ); +}; + +export const TodoListTest = ({ src }: { src: string }) => { + const { rive, RiveComponent } = useRive({ + src, + autoplay: true, + artboard: "Artboard", + autoBind: false, + stateMachines: "State Machine 1", + }); + + const viewModel = useViewModel(rive, { name: 'TodoList' }); + const viewModelInstance = useViewModelInstance(viewModel, { rive }); + + const { + length, + addInstance, + addInstanceAt, + removeInstance, + removeInstanceAt, + getInstanceAt, + swap + } = useViewModelInstanceList('items', viewModelInstance); + + const handleAddItem = () => { + const todoItemViewModel = rive?.viewModelByName?.('TodoItem'); + if (todoItemViewModel) { + const newTodoItem = todoItemViewModel.instance?.(); + if (newTodoItem) { + addInstance(newTodoItem); + } + } + }; + + const handleAddItemAt = () => { + const todoItemViewModel = rive?.viewModelByName?.('TodoItem'); + if (todoItemViewModel && length > 0) { + const newTodoItem = todoItemViewModel.instance?.(); + if (newTodoItem) { + addInstanceAt(newTodoItem, 1); + } + } + }; + + const handleRemoveFirstInstance = () => { + const firstInstance = getInstanceAt(0); + if (firstInstance) { + removeInstance(firstInstance); + } + }; + + const handleRemoveFirstByIndex = () => { + if (length > 0) { + removeInstanceAt(0); + } + }; + + const handleSwapItems = () => { + if (length >= 2) { + swap(0, 1); + } + }; + + return ( +
+ + {rive === null ? ( +
Loading…
+ ) : ( +
+
Items: {length}
+ +
+ + + + + + + + + +
+ +
+ {Array.from({ length }, (_, index) => ( + + ))} +
+
+ )} +
+ ); }; \ No newline at end of file diff --git a/src/hooks/useViewModelInstanceList.ts b/src/hooks/useViewModelInstanceList.ts index 21da86b..2a9d838 100644 --- a/src/hooks/useViewModelInstanceList.ts +++ b/src/hooks/useViewModelInstanceList.ts @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, useState } from 'react'; import { ViewModelInstance, ViewModelInstanceList } from '@rive-app/canvas'; import { UseViewModelInstanceListResult } from '../types'; import { useViewModelInstanceProperty } from './useViewModelInstanceProperty'; @@ -15,13 +15,22 @@ export default function useViewModelInstanceList( viewModelInstance?: ViewModelInstance | null ): UseViewModelInstanceListResult { + // We track revision to trigger re-renders on list manipulation (e.g. addInstance, removeInstance, etc). + // This is mostly important for things like the swap function which wouldn't trigger a re-render otherwise. + // It also accounts for changes that happen within the Rive file itself rather than through the hook. + const [_revision, setRevision] = useState(0); + const result = useViewModelInstanceProperty>( path, viewModelInstance, { getProperty: useCallback((vm, p) => vm.list(p), []), getValue: useCallback((prop) => prop.length, []), - defaultValue: 0, + defaultValue: null, + onPropertyEvent: () => { + // This fires when the list changes in Rive + setRevision(prev => prev + 1); + }, buildPropertyOperations: useCallback((safePropertyAccess) => ({ addInstance: (instance: ViewModelInstance) => { safePropertyAccess(prop => prop.addInstance(instance)); diff --git a/src/index.ts b/src/index.ts index 19f1ccb..a9951e5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import useViewModelInstanceColor from './hooks/useViewModelInstanceColor'; import useViewModelInstanceEnum from './hooks/useViewModelInstanceEnum'; import useViewModelInstanceTrigger from './hooks/useViewModelInstanceTrigger'; import useViewModelInstanceImage from './hooks/useViewModelInstanceImage'; +import useViewModelInstanceList from './hooks/useViewModelInstanceList'; import useResizeCanvas from './hooks/useResizeCanvas'; import useRiveFile from './hooks/useRiveFile'; @@ -28,6 +29,7 @@ export { useViewModelInstanceEnum, useViewModelInstanceTrigger, useViewModelInstanceImage, + useViewModelInstanceList, RiveProps, }; export {