diff --git a/change/@fluentui-react-virtualizer-40268eff-578b-4dfe-8ddc-046946884b0e.json b/change/@fluentui-react-virtualizer-40268eff-578b-4dfe-8ddc-046946884b0e.json new file mode 100644 index 00000000000000..e92acdc547684c --- /dev/null +++ b/change/@fluentui-react-virtualizer-40268eff-578b-4dfe-8ddc-046946884b0e.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "feat: Add default auto-measuring on dynamic virtualizezr if no sizing function provided", + "packageName": "@fluentui/react-virtualizer", + "email": "mifraser@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-virtualizer/etc/react-virtualizer.api.md b/packages/react-components/react-virtualizer/etc/react-virtualizer.api.md index 5c4eb9a35bf6f1..a15e9cf8b277ce 100644 --- a/packages/react-components/react-virtualizer/etc/react-virtualizer.api.md +++ b/packages/react-components/react-virtualizer/etc/react-virtualizer.api.md @@ -171,7 +171,7 @@ export const virtualizerScrollViewDynamicClassNames: SlotClassNames> & Partial> & { itemSize: number; - getItemSize: (index: number) => number; + getItemSize?: (index: number) => number; numItems: number; children: VirtualizerChildRenderFunction; imperativeRef?: RefObject; diff --git a/packages/react-components/react-virtualizer/src/components/Virtualizer/Virtualizer.types.ts b/packages/react-components/react-virtualizer/src/components/Virtualizer/Virtualizer.types.ts index ea08e3ef275895..ae788572597186 100644 --- a/packages/react-components/react-virtualizer/src/components/Virtualizer/Virtualizer.types.ts +++ b/packages/react-components/react-virtualizer/src/components/Virtualizer/Virtualizer.types.ts @@ -57,6 +57,14 @@ export type VirtualizerConfigState = { * Minimum 1px. */ bufferSize: number; + /** + * Ref for access to internal size knowledge, can be used to measure updates + */ + childSizes: React.MutableRefObject; + /** + * Ref for access to internal progressive size knowledge, can be used to measure updates + */ + childProgressiveSizes: React.MutableRefObject; }; export type VirtualizerState = ComponentState & VirtualizerConfigState; diff --git a/packages/react-components/react-virtualizer/src/components/Virtualizer/useVirtualizer.ts b/packages/react-components/react-virtualizer/src/components/Virtualizer/useVirtualizer.ts index 3843defbbbfc19..ee53ed231a1498 100644 --- a/packages/react-components/react-virtualizer/src/components/Virtualizer/useVirtualizer.ts +++ b/packages/react-components/react-virtualizer/src/components/Virtualizer/useVirtualizer.ts @@ -5,7 +5,6 @@ import { useEffect, useRef, useCallback, useReducer, useImperativeHandle, useSta import { useIntersectionObserver } from '../../hooks/useIntersectionObserver'; import { flushSync } from 'react-dom'; import { useVirtualizerContextState_unstable } from '../../Utilities'; -import { renderVirtualizerChildPlaceholder } from './renderVirtualizer'; import { slot } from '@fluentui/react-utilities'; export function useVirtualizer_unstable(props: VirtualizerProps): VirtualizerState { @@ -332,7 +331,7 @@ export function useVirtualizer_unstable(props: VirtualizerProps): VirtualizerSta const _actualIndex = Math.max(newIndex, 0); const end = Math.min(_actualIndex + virtualizerLength, numItems); for (let i = _actualIndex; i < end; i++) { - childArray.current[i - _actualIndex] = renderVirtualizerChildPlaceholder(renderChild(i, isScrolling), i); + childArray.current[i - _actualIndex] = renderChild(i, isScrolling); } }, [isScrolling, numItems, renderChild, virtualizerLength], @@ -521,5 +520,7 @@ export function useVirtualizer_unstable(props: VirtualizerProps): VirtualizerSta axis, bufferSize, reversed, + childSizes, + childProgressiveSizes, }; } diff --git a/packages/react-components/react-virtualizer/src/components/VirtualizerScrollViewDynamic/VirtualizerScrollViewDynamic.types.ts b/packages/react-components/react-virtualizer/src/components/VirtualizerScrollViewDynamic/VirtualizerScrollViewDynamic.types.ts index b0665ce1420f86..c25792830ec849 100644 --- a/packages/react-components/react-virtualizer/src/components/VirtualizerScrollViewDynamic/VirtualizerScrollViewDynamic.types.ts +++ b/packages/react-components/react-virtualizer/src/components/VirtualizerScrollViewDynamic/VirtualizerScrollViewDynamic.types.ts @@ -22,8 +22,9 @@ export type VirtualizerScrollViewDynamicProps = ComponentProps number; + getItemSize?: (index: number) => number; /** * The total number of items to be virtualized. */ diff --git a/packages/react-components/react-virtualizer/src/components/VirtualizerScrollViewDynamic/useVirtualizerScrollViewDynamic.ts b/packages/react-components/react-virtualizer/src/components/VirtualizerScrollViewDynamic/useVirtualizerScrollViewDynamic.tsx similarity index 58% rename from packages/react-components/react-virtualizer/src/components/VirtualizerScrollViewDynamic/useVirtualizerScrollViewDynamic.ts rename to packages/react-components/react-virtualizer/src/components/VirtualizerScrollViewDynamic/useVirtualizerScrollViewDynamic.tsx index fe42d78c7492b5..4fe07a4f92d3eb 100644 --- a/packages/react-components/react-virtualizer/src/components/VirtualizerScrollViewDynamic/useVirtualizerScrollViewDynamic.ts +++ b/packages/react-components/react-virtualizer/src/components/VirtualizerScrollViewDynamic/useVirtualizerScrollViewDynamic.tsx @@ -9,6 +9,8 @@ import { useDynamicVirtualizerMeasure } from '../../Hooks'; import { useVirtualizerContextState_unstable, scrollToItemDynamic } from '../../Utilities'; import type { VirtualizerDataRef } from '../Virtualizer/Virtualizer.types'; import { useImperativeHandle } from 'react'; +import { useMeasureList } from '../../hooks/useMeasureList'; +import type { IndexedResizeCallbackElement } from '../../hooks/useMeasureList'; export function useVirtualizerScrollViewDynamic_unstable( props: VirtualizerScrollViewDynamicProps, @@ -16,10 +18,26 @@ export function useVirtualizerScrollViewDynamic_unstable( const contextState = useVirtualizerContextState_unstable(props.virtualizerContext); const { imperativeRef, axis = 'vertical', reversed, imperativeVirtualizerRef } = props; + let sizeTrackingArray = React.useRef(new Array(props.numItems).fill(props.itemSize)); + + const getChildSizeAuto = React.useCallback( + (index: number) => { + if (sizeTrackingArray.current.length <= index || sizeTrackingArray.current[index] <= 0) { + // Default size for initial state or untracked + return props.itemSize; + } + /* Required to be defined prior to our measure function + * we use a sizing array ref that we will update post-render + */ + return sizeTrackingArray.current[index]; + }, + [sizeTrackingArray, props.itemSize], + ); + const { virtualizerLength, bufferItems, bufferSize, scrollRef } = useDynamicVirtualizerMeasure({ defaultItemSize: props.itemSize, direction: props.axis ?? 'vertical', - getItemSize: props.getItemSize, + getItemSize: props.getItemSize ?? getChildSizeAuto, currentIndex: contextState?.contextIndex ?? 0, numItems: props.numItems, }); @@ -74,6 +92,7 @@ export function useVirtualizerScrollViewDynamic_unstable( const virtualizerState = useVirtualizer_unstable({ ...props, + getItemSize: props.getItemSize ?? getChildSizeAuto, virtualizerLength, bufferItems, bufferSize, @@ -83,6 +102,56 @@ export function useVirtualizerScrollViewDynamic_unstable( onRenderedFlaggedIndex: handleRenderedIndex, }); + const measureObject = useMeasureList( + virtualizerState.virtualizerStartIndex, + virtualizerLength, + props.numItems, + props.itemSize, + ); + + if (axis === 'horizontal') { + sizeTrackingArray = measureObject.widthArray; + } else { + sizeTrackingArray = measureObject.heightArray; + } + + if (!props.getItemSize) { + // Auto-measuring is required + React.Children.map(virtualizerState.virtualizedChildren, (child, index) => { + if (React.isValidElement(child)) { + virtualizerState.virtualizedChildren[index] = ( + { + // If a ref exists in props, call it + if (typeof child.props.ref === 'function') { + child.props.ref(element); + } else if (child.props.ref) { + child.props.ref.current = element; + } + + if (child.hasOwnProperty('ref')) { + // We must access this from the child directly, not props (forward ref). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const localRef = (child as any)?.ref; + + if (typeof localRef === 'function') { + localRef(element); + } else if (localRef) { + localRef.current = element; + } + } + + // Call the auto-measure ref attachment. + measureObject.createIndexedRef(index)(element); + }} + /> + ); + } + }); + } + return { ...virtualizerState, components: { diff --git a/packages/react-components/react-virtualizer/src/hooks/useMeasureList.ts b/packages/react-components/react-virtualizer/src/hooks/useMeasureList.ts new file mode 100644 index 00000000000000..6f3361c0c183e3 --- /dev/null +++ b/packages/react-components/react-virtualizer/src/hooks/useMeasureList.ts @@ -0,0 +1,112 @@ +import * as React from 'react'; +import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; + +export interface IndexedResizeCallbackElement { + handleResize: () => void; +} +/** + * Provides a way of automating size in the virtualizer + * Returns + * `width` - element width ref (0 by default), + * `height` - element height ref (0 by default), + * `measureElementRef` - a ref function to be passed as `ref` to the element you want to measure + */ +export function useMeasureList< + TElement extends HTMLElement & IndexedResizeCallbackElement = HTMLElement & IndexedResizeCallbackElement, +>(currentIndex: number, refLength: number, totalLength: number, defaultItemSize: number) { + const widthArray = React.useRef(new Array(totalLength).fill(defaultItemSize)); + const heightArray = React.useRef(new Array(totalLength).fill(defaultItemSize)); + + const refArray = React.useRef>([]); + const { targetDocument } = useFluent(); + + // the handler for resize observer + const handleIndexUpdate = React.useCallback( + (index: number) => { + const boundClientRect = refArray.current[index]?.getBoundingClientRect(); + const containerWidth = boundClientRect?.width; + widthArray.current[currentIndex + index] = containerWidth || defaultItemSize; + + const containerHeight = boundClientRect?.height; + heightArray.current[currentIndex + index] = containerHeight || defaultItemSize; + }, + [currentIndex, defaultItemSize], + ); + + const handleElementResizeCallback = (entries: ResizeObserverEntry[]) => { + for (const entry of entries) { + const target = entry.target as TElement; + // Call the elements own resize handler (indexed) + target.handleResize(); + } + }; + + React.useEffect(() => { + widthArray.current = new Array(totalLength).fill(defaultItemSize); + heightArray.current = new Array(totalLength).fill(defaultItemSize); + }, [defaultItemSize, totalLength]); + + // Keep the reference of ResizeObserver as a ref, as it should live through renders + const resizeObserver = React.useRef(createResizeObserverFromDocument(targetDocument, handleElementResizeCallback)); + + /* createIndexedRef provides a dynamic function to create an undefined number of refs at render time + * these refs then provide an indexed callback via attaching 'handleResize' to the element itself + * this function is then called on resize by handleElementResize and relies on indexing + * to track continuous sizes throughout renders while releasing all virtualized element refs each render cycle. + */ + const createIndexedRef = React.useCallback( + (index: number) => { + const measureElementRef = (el: TElement) => { + if (!targetDocument || !resizeObserver.current) { + return; + } + + if (el) { + el.handleResize = () => { + handleIndexUpdate(index); + }; + } + + // cleanup previous container + if (refArray.current[index] !== undefined && refArray.current[index] !== null) { + resizeObserver.current.unobserve(refArray.current[index]!); + } + + refArray.current[index] = undefined; + if (el) { + refArray.current[index] = el; + resizeObserver.current.observe(el); + handleIndexUpdate(index); + } + }; + + return measureElementRef; + }, + [handleIndexUpdate, resizeObserver, targetDocument], + ); + + React.useEffect(() => { + const _resizeObserver = resizeObserver; + return () => _resizeObserver.current?.disconnect(); + }, [resizeObserver]); + + return { widthArray, heightArray, createIndexedRef, refArray }; +} + +/** + * FIXME - TS 3.8/3.9 don't have ResizeObserver types by default, move this to a shared utility once we bump the minbar + * A utility method that creates a ResizeObserver from a target document + * @param targetDocument - document to use to create the ResizeObserver + * @param callback - https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/ResizeObserver#callback + * @returns a ResizeObserver instance or null if the global does not exist on the document + */ +export function createResizeObserverFromDocument( + targetDocument: Document | null | undefined, + callback: ResizeObserverCallback, +) { + if (!targetDocument?.defaultView?.ResizeObserver) { + return null; + } + + return new targetDocument.defaultView.ResizeObserver(callback); +} diff --git a/packages/react-components/react-virtualizer/stories/VirtualizerScrollViewDynamic/AutoMeasure.stories.tsx b/packages/react-components/react-virtualizer/stories/VirtualizerScrollViewDynamic/AutoMeasure.stories.tsx new file mode 100644 index 00000000000000..9df460624fbcc0 --- /dev/null +++ b/packages/react-components/react-virtualizer/stories/VirtualizerScrollViewDynamic/AutoMeasure.stories.tsx @@ -0,0 +1,51 @@ +import * as React from 'react'; +import { VirtualizerScrollViewDynamic } from '@fluentui/react-components/unstable'; +import { makeStyles } from '@fluentui/react-components'; +import { useEffect } from 'react'; + +const useStyles = makeStyles({ + child: { + lineHeight: '42px', + width: '100%', + minHeight: '42px', + }, +}); + +export const AutoMeasure = () => { + const styles = useStyles(); + const childLength = 1000; + const minHeight = 42; + const maxHeightIncrease = 150; + // Array size ref stores a list of random num for div sizing and callbacks + const arraySize = React.useRef(new Array(childLength).fill(minHeight)); + + useEffect(() => { + // Set random heights on init (to be measured) + for (let i = 0; i < childLength; i++) { + arraySize.current[i] = Math.floor(Math.random() * maxHeightIncrease + minHeight); + } + }, []); + + return ( + + {(index: number) => { + const backgroundColor = index % 2 ? '#FFFFFF' : '#ABABAB'; + return ( +
{`Node-${index} - size: ${arraySize.current[index]}`}
+ ); + }} +
+ ); +}; diff --git a/packages/react-components/react-virtualizer/stories/VirtualizerScrollViewDynamic/index.stories.ts b/packages/react-components/react-virtualizer/stories/VirtualizerScrollViewDynamic/index.stories.ts index cf5ac78e1cf052..ab3478b8dc378b 100644 --- a/packages/react-components/react-virtualizer/stories/VirtualizerScrollViewDynamic/index.stories.ts +++ b/packages/react-components/react-virtualizer/stories/VirtualizerScrollViewDynamic/index.stories.ts @@ -1,6 +1,7 @@ import { VirtualizerScrollViewDynamic } from '../../src/VirtualizerScrollViewDynamic'; import descriptionMd from './VirtualizerScrollViewDynamicDescription.md'; +export { AutoMeasure } from './AutoMeasure.stories'; export { Default } from './Default.stories'; export { ScrollTo } from './ScrollTo.stories'; export { ScrollLoading } from './ScrollLoading.stories';