diff --git a/change/@fluentui-react-components-333c9bda-d493-4ea4-842a-af649fd2001e.json b/change/@fluentui-react-components-333c9bda-d493-4ea4-842a-af649fd2001e.json new file mode 100644 index 00000000000000..9989cacc4816a9 --- /dev/null +++ b/change/@fluentui-react-components-333c9bda-d493-4ea4-842a-af649fd2001e.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "feature: Add dynamically sized virtualizer scroll view", + "packageName": "@fluentui/react-components", + "email": "mifraser@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-virtualizer-e15ce267-f3e2-4c24-a3ca-6d20e617025f.json b/change/@fluentui-react-virtualizer-e15ce267-f3e2-4c24-a3ca-6d20e617025f.json new file mode 100644 index 00000000000000..28f90bea75cb7c --- /dev/null +++ b/change/@fluentui-react-virtualizer-e15ce267-f3e2-4c24-a3ca-6d20e617025f.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "feature: Add dynamically sized virtualizer scroll view", + "packageName": "@fluentui/react-virtualizer", + "email": "mifraser@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-components/etc/react-components.unstable.api.md b/packages/react-components/react-components/etc/react-components.unstable.api.md index 16f7745139fa90..cf82a8b7b8d940 100644 --- a/packages/react-components/react-components/etc/react-components.unstable.api.md +++ b/packages/react-components/react-components/etc/react-components.unstable.api.md @@ -15,6 +15,7 @@ import { CheckboxFieldProps_unstable as CheckboxFieldProps } from '@fluentui/rea import { ComboboxField_unstable as ComboboxField } from '@fluentui/react-combobox'; import { comboboxFieldClassNames } from '@fluentui/react-combobox'; import { ComboboxFieldProps_unstable as ComboboxFieldProps } from '@fluentui/react-combobox'; +import { ContextlessVirtualizerScrollViewDynamic } from '@fluentui/react-virtualizer'; import { Field } from '@fluentui/react-field'; import { fieldClassNames } from '@fluentui/react-field'; import { FieldProps } from '@fluentui/react-field'; @@ -46,6 +47,7 @@ import { RadioGroupField_unstable as RadioGroupField } from '@fluentui/react-rad import { radioGroupFieldClassNames } from '@fluentui/react-radio'; import { RadioGroupFieldProps_unstable as RadioGroupFieldProps } from '@fluentui/react-radio'; import { renderAlert_unstable } from '@fluentui/react-alert'; +import { renderContextlessVirtualizerScrollViewDynamic_unstable } from '@fluentui/react-virtualizer'; import { renderField_unstable } from '@fluentui/react-field'; import { renderInfoButton_unstable } from '@fluentui/react-infobutton'; import { renderInfoLabel_unstable } from '@fluentui/react-infobutton'; @@ -57,6 +59,7 @@ import { renderTreeItemLayout_unstable } from '@fluentui/react-tree'; import { renderTreeItemPersonaLayout_unstable } from '@fluentui/react-tree'; import { renderVirtualizer_unstable } from '@fluentui/react-virtualizer'; import { renderVirtualizerScrollView_unstable } from '@fluentui/react-virtualizer'; +import { renderVirtualizerScrollViewDynamic_unstable } from '@fluentui/react-virtualizer'; import { SelectField_unstable as SelectField } from '@fluentui/react-select'; import { selectFieldClassNames } from '@fluentui/react-select'; import { SelectFieldProps_unstable as SelectFieldProps } from '@fluentui/react-select'; @@ -115,6 +118,7 @@ import { TreeSlots } from '@fluentui/react-tree'; import { TreeState } from '@fluentui/react-tree'; import { useAlert_unstable } from '@fluentui/react-alert'; import { useAlertStyles_unstable } from '@fluentui/react-alert'; +import { useDynamicVirtualizerMeasure } from '@fluentui/react-virtualizer'; import { useField_unstable } from '@fluentui/react-field'; import { useFieldStyles_unstable } from '@fluentui/react-field'; import { useFlatTree_unstable } from '@fluentui/react-tree'; @@ -140,15 +144,25 @@ import { useTreeItemPersonaLayoutStyles_unstable } from '@fluentui/react-tree'; import { useTreeItemStyles_unstable } from '@fluentui/react-tree'; import { useTreeStyles_unstable } from '@fluentui/react-tree'; import { useVirtualizer_unstable } from '@fluentui/react-virtualizer'; +import { useVirtualizerContext } from '@fluentui/react-virtualizer'; import { useVirtualizerScrollView_unstable } from '@fluentui/react-virtualizer'; +import { useVirtualizerScrollViewDynamic_unstable } from '@fluentui/react-virtualizer'; +import { useVirtualizerScrollViewDynamicStyles_unstable } from '@fluentui/react-virtualizer'; import { useVirtualizerScrollViewStyles_unstable } from '@fluentui/react-virtualizer'; import { useVirtualizerStyles_unstable } from '@fluentui/react-virtualizer'; import { Virtualizer } from '@fluentui/react-virtualizer'; import { VirtualizerChildRenderFunction } from '@fluentui/react-virtualizer'; import { virtualizerClassNames } from '@fluentui/react-virtualizer'; +import { VirtualizerContextProps } from '@fluentui/react-virtualizer'; +import { VirtualizerContextProvider } from '@fluentui/react-virtualizer'; import { VirtualizerProps } from '@fluentui/react-virtualizer'; import { VirtualizerScrollView } from '@fluentui/react-virtualizer'; import { virtualizerScrollViewClassNames } from '@fluentui/react-virtualizer'; +import { VirtualizerScrollViewDynamic } from '@fluentui/react-virtualizer'; +import { virtualizerScrollViewDynamicClassNames } from '@fluentui/react-virtualizer'; +import { VirtualizerScrollViewDynamicProps } from '@fluentui/react-virtualizer'; +import { VirtualizerScrollViewDynamicSlots } from '@fluentui/react-virtualizer'; +import { VirtualizerScrollViewDynamicState } from '@fluentui/react-virtualizer'; import { VirtualizerScrollViewProps } from '@fluentui/react-virtualizer'; import { VirtualizerScrollViewSlots } from '@fluentui/react-virtualizer'; import { VirtualizerScrollViewState } from '@fluentui/react-virtualizer'; @@ -177,6 +191,8 @@ export { comboboxFieldClassNames } export { ComboboxFieldProps } +export { ContextlessVirtualizerScrollViewDynamic } + export { Field } export { fieldClassNames } @@ -239,6 +255,8 @@ export { RadioGroupFieldProps } export { renderAlert_unstable } +export { renderContextlessVirtualizerScrollViewDynamic_unstable } + export { renderField_unstable } export { renderInfoButton_unstable } @@ -261,6 +279,8 @@ export { renderVirtualizer_unstable } export { renderVirtualizerScrollView_unstable } +export { renderVirtualizerScrollViewDynamic_unstable } + export { SelectField } export { selectFieldClassNames } @@ -377,6 +397,8 @@ export { useAlert_unstable } export { useAlertStyles_unstable } +export { useDynamicVirtualizerMeasure } + export { useField_unstable } export { useFieldStyles_unstable } @@ -427,8 +449,14 @@ export { useTreeStyles_unstable } export { useVirtualizer_unstable } +export { useVirtualizerContext } + export { useVirtualizerScrollView_unstable } +export { useVirtualizerScrollViewDynamic_unstable } + +export { useVirtualizerScrollViewDynamicStyles_unstable } + export { useVirtualizerScrollViewStyles_unstable } export { useVirtualizerStyles_unstable } @@ -439,12 +467,26 @@ export { VirtualizerChildRenderFunction } export { virtualizerClassNames } +export { VirtualizerContextProps } + +export { VirtualizerContextProvider } + export { VirtualizerProps } export { VirtualizerScrollView } export { virtualizerScrollViewClassNames } +export { VirtualizerScrollViewDynamic } + +export { virtualizerScrollViewDynamicClassNames } + +export { VirtualizerScrollViewDynamicProps } + +export { VirtualizerScrollViewDynamicSlots } + +export { VirtualizerScrollViewDynamicState } + export { VirtualizerScrollViewProps } export { VirtualizerScrollViewSlots } diff --git a/packages/react-components/react-components/src/unstable/index.ts b/packages/react-components/react-components/src/unstable/index.ts index f6728de03d1b2e..f9e619a5e5fb58 100644 --- a/packages/react-components/react-components/src/unstable/index.ts +++ b/packages/react-components/react-components/src/unstable/index.ts @@ -121,11 +121,21 @@ export { useVirtualizerStyles_unstable, useIntersectionObserver, useStaticVirtualizerMeasure, + useDynamicVirtualizerMeasure, + VirtualizerContextProvider, + useVirtualizerContext, VirtualizerScrollView, virtualizerScrollViewClassNames, useVirtualizerScrollView_unstable, renderVirtualizerScrollView_unstable, useVirtualizerScrollViewStyles_unstable, + VirtualizerScrollViewDynamic, + ContextlessVirtualizerScrollViewDynamic, + virtualizerScrollViewDynamicClassNames, + useVirtualizerScrollViewDynamic_unstable, + renderVirtualizerScrollViewDynamic_unstable, + renderContextlessVirtualizerScrollViewDynamic_unstable, + useVirtualizerScrollViewDynamicStyles_unstable, } from '@fluentui/react-virtualizer'; export type { VirtualizerProps, @@ -135,6 +145,10 @@ export type { VirtualizerScrollViewProps, VirtualizerScrollViewState, VirtualizerScrollViewSlots, + VirtualizerContextProps, + VirtualizerScrollViewDynamicProps, + VirtualizerScrollViewDynamicState, + VirtualizerScrollViewDynamicSlots, } from '@fluentui/react-virtualizer'; export { 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 a00c9dec69b41e..813643663925a5 100644 --- a/packages/react-components/react-virtualizer/etc/react-virtualizer.api.md +++ b/packages/react-components/react-virtualizer/etc/react-virtualizer.api.md @@ -14,12 +14,29 @@ import type { SetStateAction } from 'react'; import { Slot } from '@fluentui/react-utilities'; import type { SlotClassNames } from '@fluentui/react-utilities'; +// @public +export const ContextlessVirtualizerScrollViewDynamic: React_2.FC; + +// @public (undocumented) +export const renderContextlessVirtualizerScrollViewDynamic_unstable: (state: VirtualizerScrollViewDynamicState) => JSX.Element; + // @public (undocumented) export const renderVirtualizer_unstable: (state: VirtualizerState) => JSX.Element; // @public (undocumented) export const renderVirtualizerScrollView_unstable: (state: VirtualizerScrollViewState) => JSX.Element; +// @public (undocumented) +export const renderVirtualizerScrollViewDynamic_unstable: (props: VirtualizerScrollViewDynamicProps, context: VirtualizerContextProps) => JSX.Element; + +// @public +export const useDynamicVirtualizerMeasure: (virtualizerProps: VirtualizerMeasureDynamicProps) => { + virtualizerLength: number; + bufferItems: number; + bufferSize: number; + scrollRef: (instance: HTMLElement | HTMLDivElement | null) => void; +}; + // @public export const useIntersectionObserver: (callback: IntersectionObserverCallback, options?: IntersectionObserverInit | undefined) => { setObserverList: Dispatch>; @@ -38,9 +55,18 @@ export const useStaticVirtualizerMeasure: (virtualizerProps: VirtualizerMeasureP // @public (undocumented) export function useVirtualizer_unstable(props: VirtualizerProps): VirtualizerState; +// @public (undocumented) +export const useVirtualizerContext: () => VirtualizerContextProps; + // @public (undocumented) export function useVirtualizerScrollView_unstable(props: VirtualizerScrollViewProps): VirtualizerScrollViewState; +// @public (undocumented) +export function useVirtualizerScrollViewDynamic_unstable(props: VirtualizerScrollViewDynamicProps): VirtualizerScrollViewDynamicState; + +// @public +export const useVirtualizerScrollViewDynamicStyles_unstable: (state: VirtualizerScrollViewDynamicState) => VirtualizerScrollViewDynamicState; + // @public export const useVirtualizerScrollViewStyles_unstable: (state: VirtualizerScrollViewState) => VirtualizerScrollViewState; @@ -56,6 +82,15 @@ export type VirtualizerChildRenderFunction = (index: number) => React_2.ReactNod // @public (undocumented) export const virtualizerClassNames: SlotClassNames; +// @public (undocumented) +export type VirtualizerContextProps = { + contextIndex: number; + setContextIndex: (index: number) => void; +}; + +// @public (undocumented) +export const VirtualizerContextProvider: React_2.Provider; + // @public (undocumented) export type VirtualizerProps = ComponentProps> & VirtualizerConfigProps; @@ -65,6 +100,26 @@ export const VirtualizerScrollView: React_2.FC; // @public (undocumented) export const virtualizerScrollViewClassNames: SlotClassNames; +// @public (undocumented) +export const VirtualizerScrollViewDynamic: React_2.FC; + +// @public (undocumented) +export const virtualizerScrollViewDynamicClassNames: SlotClassNames; + +// @public (undocumented) +export type VirtualizerScrollViewDynamicProps = ComponentProps> & Partial> & { + itemSize: number; + getItemSize: (index: number) => number; + numItems: number; + children: VirtualizerChildRenderFunction; +}; + +// @public (undocumented) +export type VirtualizerScrollViewDynamicSlots = VirtualizerScrollViewSlots; + +// @public (undocumented) +export type VirtualizerScrollViewDynamicState = ComponentState & VirtualizerConfigState; + // @public (undocumented) export type VirtualizerScrollViewProps = ComponentProps> & Partial> & { itemSize: number; diff --git a/packages/react-components/react-virtualizer/src/Utilities.ts b/packages/react-components/react-virtualizer/src/Utilities.ts new file mode 100644 index 00000000000000..91e9ad875428e2 --- /dev/null +++ b/packages/react-components/react-virtualizer/src/Utilities.ts @@ -0,0 +1 @@ +export * from './utilities/index'; diff --git a/packages/react-components/react-virtualizer/src/VirtualizerScrollViewDynamic.ts b/packages/react-components/react-virtualizer/src/VirtualizerScrollViewDynamic.ts new file mode 100644 index 00000000000000..dd94f542e41270 --- /dev/null +++ b/packages/react-components/react-virtualizer/src/VirtualizerScrollViewDynamic.ts @@ -0,0 +1 @@ +export * from './components/VirtualizerScrollViewDynamic/index'; 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 dbdfb52d21c222..3217d39f56d4f7 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 @@ -61,7 +61,7 @@ export type VirtualizerState = ComponentState & VirtualizerCon // Virtualizer render function to procedurally generate children elements as rows or columns via index. // Q: Use generic typing and passing through object data or a simple index system? -export type VirtualizerChildRenderFunction = (index: number) => React.ReactNode; +export type VirtualizerChildRenderFunction = (index: number, isScrolling?: boolean) => React.ReactNode; export type VirtualizerConfigProps = { /** @@ -135,14 +135,26 @@ export type VirtualizerConfigProps = { getItemSize?: (index: number) => number; /** - * Notify users of index changes + * Callback for notifying when a flagged index has been rendered */ - onUpdateIndex?: (index: number, prevIndex: number) => void; + onRenderedFlaggedIndex?: (index: number) => number; + /* + * Callback object to notify when a flagged index has been rendered + */ + flagIndex?: FlaggedIndexCallback; +}; + +export type FlaggedIndexCallback = { /** - * Allow users to intervene in index calculation changes + * Callback for notifying when a flagged index has been rendered + */ + onRenderedFlaggedIndex: (index: number) => number; + + /* + * Callback for notifying when a flagged index has been rendered */ - onCalculateIndex?: (newIndex: number) => number; + flaggedIndex: React.MutableRefObject; }; export type VirtualizerProps = ComponentProps> & VirtualizerConfigProps; diff --git a/packages/react-components/react-virtualizer/src/components/Virtualizer/renderVirtualizer.tsx b/packages/react-components/react-virtualizer/src/components/Virtualizer/renderVirtualizer.tsx index bcc999560a09e4..b1dac3fe1b452c 100644 --- a/packages/react-components/react-virtualizer/src/components/Virtualizer/renderVirtualizer.tsx +++ b/packages/react-components/react-virtualizer/src/components/Virtualizer/renderVirtualizer.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { getSlots } from '@fluentui/react-utilities'; import { VirtualizerSlots, VirtualizerState } from './Virtualizer.types'; +import { ReactNode } from 'react'; export const renderVirtualizer_unstable = (state: VirtualizerState) => { const { slots, slotProps } = getSlots(state); @@ -20,3 +21,11 @@ export const renderVirtualizer_unstable = (state: VirtualizerState) => { ); }; + +export const renderVirtualizerChildPlaceholder = (child: ReactNode, index: number, isScrolling?: boolean) => { + return ( + + {child} + + ); +}; 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 90577e0efc412e..4bd9edc03a2697 100644 --- a/packages/react-components/react-virtualizer/src/components/Virtualizer/useVirtualizer.ts +++ b/packages/react-components/react-virtualizer/src/components/Virtualizer/useVirtualizer.ts @@ -6,6 +6,9 @@ import type { VirtualizerProps, VirtualizerState } from './Virtualizer.types'; import { resolveShorthand } from '@fluentui/react-utilities'; import { flushSync } from 'react-dom'; +import { useVirtualizerContext } from '../../Utilities'; +import { renderVirtualizerChildPlaceholder } from './renderVirtualizer'; + export function useVirtualizer_unstable(props: VirtualizerProps): VirtualizerState { const { itemSize, @@ -18,13 +21,17 @@ export function useVirtualizer_unstable(props: VirtualizerProps): VirtualizerSta scrollViewRef, axis = 'vertical', reversed = false, - onUpdateIndex, - onCalculateIndex, + flagIndex, } = props; + /* The context is optional, it's useful for injecting additional index logic, or performing uniform state updates*/ + const virtualizerContext = useVirtualizerContext(); // Tracks the initial item to start virtualizer at, -1 implies first render cycle const [virtualizerStartIndex, setVirtualizerStartIndex] = useState(-1); + const actualIndex = virtualizerContext ? virtualizerContext.contextIndex : virtualizerStartIndex; + const setActualIndex = virtualizerContext ? virtualizerContext.setContextIndex : setVirtualizerStartIndex; + // Store ref to before padding element const beforeElementRef = useRef(null); @@ -62,31 +69,70 @@ export function useVirtualizer_unstable(props: VirtualizerProps): VirtualizerSta for (let index = 0; index < numItems; index++) { childSizes.current[index] = getItemSize(index); - if (index === 0) { childProgressiveSizes.current[index] = childSizes.current[index]; } else { childProgressiveSizes.current[index] = childProgressiveSizes.current[index - 1] + childSizes.current[index]; } } + + /* + * We keep an up to date context reference for external hooks or user injected changes + */ + if (virtualizerContext.currentChildSizes) { + virtualizerContext.currentChildSizes.current = childSizes.current; + } + if (virtualizerContext.progressiveChildSizes) { + virtualizerContext.progressiveChildSizes.current = childProgressiveSizes.current; + } }; + const [isScrolling, setIsScrolling] = useState(false); + const scrollTimer = useRef | null>(); + const scrollCounter = useRef(0); + + const initializeScrollingTimer = () => { + /* + * This can be considered the 'velocity' required to start scroll + * - Maybe we should let users pass these in. + */ + const INIT_SCROLL_FLAG_REQ = 10; + const INIT_SCROLL_FLAG_DELAY = 100; + + scrollCounter.current++; + if (scrollCounter.current >= INIT_SCROLL_FLAG_REQ) { + setIsScrolling(true); + } + if (scrollTimer.current) { + clearTimeout(scrollTimer.current); + } + scrollTimer.current = setTimeout(() => { + setIsScrolling(false); + scrollCounter.current = 0; + }, INIT_SCROLL_FLAG_DELAY); + }; + + useEffect(() => { + initializeScrollingTimer(); + }, [actualIndex]); + const batchUpdateNewIndex = (index: number) => { // Local updates - onUpdateIndex?.(index, virtualizerStartIndex); updateChildRows(index); updateCurrentItemSizes(index); // State setters - setVirtualizerStartIndex(index); + setActualIndex(index); }; // Observe intersections of virtualized components const { setObserverList } = useIntersectionObserver( (entries: IntersectionObserverEntry[], observer: IntersectionObserver) => { + const currentIndex = actualIndex; + /* Sanity check - do we even need virtualization? */ if (virtualizerLength > numItems) { - if (virtualizerStartIndex !== 0) { + if (currentIndex !== 0) { batchUpdateNewIndex(0); } // No-op @@ -159,21 +205,14 @@ export function useVirtualizer_unstable(props: VirtualizerProps): VirtualizerSta // For now lets use hardcoded size to assess current element to paginate on const startIndex = getIndexFromScrollPosition(measurementPos); - let bufferedIndex = Math.max(startIndex - bufferCount, 0); - - if (onCalculateIndex) { - // User has chance to intervene/customize prior to render - // They may want to normalize this value. - bufferedIndex = onCalculateIndex(bufferedIndex); - } + const bufferedIndex = Math.max(startIndex - bufferCount, 0); // Safety limits const maxIndex = Math.max(numItems - virtualizerLength, 0); const newStartIndex = Math.min(Math.max(bufferedIndex, 0), maxIndex); - if (virtualizerStartIndex !== newStartIndex) { + if (currentIndex !== newStartIndex) { // We flush sync this and perform an immediate state update - // due to virtualizerStartIndex invalidation. flushSync(() => { batchUpdateNewIndex(newStartIndex); }); @@ -189,7 +228,7 @@ export function useVirtualizer_unstable(props: VirtualizerProps): VirtualizerSta const findIndexRecursive = (scrollPos: number, lowIndex: number, highIndex: number): number => { if (lowIndex > highIndex) { // We shouldn't get here - but no-op the index if we do. - return virtualizerStartIndex; + return actualIndex; } const midpoint = Math.floor((lowIndex + highIndex) / 2); const iBefore = Math.max(midpoint - 1, 0); @@ -239,25 +278,35 @@ export function useVirtualizer_unstable(props: VirtualizerProps): VirtualizerSta const calculateTotalSize = () => { if (!getItemSize) { - return itemSize * numItems; + const size = itemSize * numItems; + if (virtualizerContext?.totalSize) { + virtualizerContext.totalSize.current = size; + } + return size; } + // Ensure context has access to totalSize for any reverse calculations + if (virtualizerContext?.totalSize) { + virtualizerContext.totalSize.current = childProgressiveSizes.current[numItems - 1]; + } // Time for custom size calcs return childProgressiveSizes.current[numItems - 1]; }; const calculateBefore = () => { + const currentIndex = Math.min(actualIndex, numItems); + if (!getItemSize) { // The missing items from before virtualization starts height - return virtualizerStartIndex * itemSize; + return currentIndex * itemSize; } - if (virtualizerStartIndex <= 0) { + if (currentIndex <= 0) { return 0; } // Time for custom size calcs - return childProgressiveSizes.current[virtualizerStartIndex - 1]; + return childProgressiveSizes.current[currentIndex - 1]; }; const calculateAfter = () => { @@ -265,7 +314,7 @@ export function useVirtualizer_unstable(props: VirtualizerProps): VirtualizerSta return 0; } - const lastItemIndex = Math.min(virtualizerStartIndex + virtualizerLength, numItems - 1); + const lastItemIndex = Math.min(actualIndex + virtualizerLength, numItems - 1); if (!getItemSize) { // The missing items from after virtualization ends height const remainingItems = numItems - lastItemIndex - 1; @@ -287,14 +336,18 @@ export function useVirtualizer_unstable(props: VirtualizerProps): VirtualizerSta if (childArray.current.length !== numItems) { childArray.current = new Array(virtualizerLength); } - const actualIndex = Math.max(newIndex, 0); - const end = Math.min(actualIndex + virtualizerLength, numItems); - - for (let i = actualIndex; i < end; i++) { - childArray.current[i - actualIndex] = renderChild(i); + 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, + isScrolling, + ); } }, - [numItems, renderChild, virtualizerLength], + [numItems, renderChild, virtualizerLength, isScrolling], ); const setBeforeRef = useCallback( @@ -377,7 +430,7 @@ export function useVirtualizer_unstable(props: VirtualizerProps): VirtualizerSta // Initialization on mount - update array index to 0 (ready state). // Only fire on mount (no deps). useEffect(() => { - if (virtualizerStartIndex < 0) { + if (actualIndex < 0) { batchUpdateNewIndex(0); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -385,13 +438,33 @@ export function useVirtualizer_unstable(props: VirtualizerProps): VirtualizerSta // If the user passes in an updated renderChild function - update current children useEffect(() => { - if (virtualizerStartIndex >= 0) { - updateChildRows(virtualizerStartIndex); + const currentIndex = actualIndex; + if (currentIndex >= 0) { + updateChildRows(currentIndex); forceUpdate(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [renderChild, updateChildRows]); + useEffect(() => { + // Ensure we repopulate if getItemSize callback changes + populateSizeArrays(); + + // We only run this effect on getItemSize change (recalc dynamic sizes) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [getItemSize]); + + useEffect(() => { + if (!flagIndex || flagIndex.flaggedIndex.current === null) { + return; + } + const checkIndex = flagIndex.flaggedIndex.current; + if (actualIndex <= checkIndex && actualIndex + virtualizerLength >= checkIndex) { + flagIndex.flaggedIndex.current = null; + flagIndex?.onRenderedFlaggedIndex(checkIndex); + } + }, [actualIndex, flagIndex, virtualizerLength]); + // Ensure we have run through and updated the whole size list array at least once. initializeSizeArray(); @@ -402,11 +475,11 @@ export function useVirtualizer_unstable(props: VirtualizerProps): VirtualizerSta // Ensure we recalc if virtualizer length changes const maxCompare = Math.min(virtualizerLength, numItems); - if (childArray.current.length !== maxCompare && virtualizerStartIndex + childArray.current.length < numItems) { - updateChildRows(virtualizerStartIndex); + if (childArray.current.length !== maxCompare && actualIndex + childArray.current.length < numItems) { + updateChildRows(actualIndex); } - const isFullyInitialized = hasInitialized.current && virtualizerStartIndex >= 0; + const isFullyInitialized = hasInitialized.current && actualIndex >= 0; return { components: { before: 'div', @@ -444,7 +517,7 @@ export function useVirtualizer_unstable(props: VirtualizerProps): VirtualizerSta beforeBufferHeight: isFullyInitialized ? calculateBefore() : 0, afterBufferHeight: isFullyInitialized ? calculateAfter() : 0, totalVirtualizerHeight: isFullyInitialized ? calculateTotalSize() : virtualizerLength * itemSize, - virtualizerStartIndex, + virtualizerStartIndex: actualIndex, axis, bufferSize, reversed, diff --git a/packages/react-components/react-virtualizer/src/components/VirtualizerScrollView/VirtualizerScrollView.types.ts b/packages/react-components/react-virtualizer/src/components/VirtualizerScrollView/VirtualizerScrollView.types.ts index 198789fef9c81d..1377f473d2bab5 100644 --- a/packages/react-components/react-virtualizer/src/components/VirtualizerScrollView/VirtualizerScrollView.types.ts +++ b/packages/react-components/react-virtualizer/src/components/VirtualizerScrollView/VirtualizerScrollView.types.ts @@ -5,6 +5,7 @@ import { VirtualizerConfigState, VirtualizerChildRenderFunction, } from '../Virtualizer/Virtualizer.types'; +import { VirtualizerScrollCallbacks } from '../../hooks/useImperativeScrolling.types'; export type VirtualizerScrollViewSlots = VirtualizerSlots & { /** @@ -31,6 +32,10 @@ export type VirtualizerScrollViewProps = ComponentProps & VirtualizerConfigState; diff --git a/packages/react-components/react-virtualizer/src/components/VirtualizerScrollView/useVirtualizerScrollView.ts b/packages/react-components/react-virtualizer/src/components/VirtualizerScrollView/useVirtualizerScrollView.ts index 331752a1376f31..2d78b76f1b492f 100644 --- a/packages/react-components/react-virtualizer/src/components/VirtualizerScrollView/useVirtualizerScrollView.ts +++ b/packages/react-components/react-virtualizer/src/components/VirtualizerScrollView/useVirtualizerScrollView.ts @@ -3,6 +3,8 @@ import { resolveShorthand, useMergedRefs } from '@fluentui/react-utilities'; import { useVirtualizer_unstable } from '../Virtualizer/useVirtualizer'; import { VirtualizerScrollViewProps, VirtualizerScrollViewState } from './VirtualizerScrollView.types'; import { useStaticVirtualizerMeasure } from '../../Hooks'; +import { FlaggedIndexCallback } from '../Virtualizer/Virtualizer.types'; +import { _scrollToItemStatic } from '../../hooks/useImperativeScrolling'; export function useVirtualizerScrollView_unstable(props: VirtualizerScrollViewProps): VirtualizerScrollViewState { const { virtualizerLength, bufferItems, bufferSize, scrollRef } = useStaticVirtualizerMeasure({ @@ -10,7 +12,27 @@ export function useVirtualizerScrollView_unstable(props: VirtualizerScrollViewPr direction: props.axis ?? 'vertical', }); - const iScrollRef = useMergedRefs(React.useRef(null), scrollRef); + const iScrollRef = useMergedRefs(React.useRef(null), scrollRef) as React.RefObject; + + const scrollCallbackIndex = React.useRef(null); + + React.useEffect(() => { + const { itemSize, numItems, axis = 'vertical', reversed } = props; + if (props.scrollCallbacks) { + props.scrollCallbacks.scrollToItem.current = (index: number) => { + scrollCallbackIndex.current = index; + _scrollToItemStatic({ + indexRef: scrollCallbackIndex, + itemSize, + totalItems: numItems, + scrollView: iScrollRef, + axis, + reversed, + }); + }; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [iScrollRef, props.scrollCallbacks]); const virtualizerState = useVirtualizer_unstable({ ...props, @@ -18,6 +40,12 @@ export function useVirtualizerScrollView_unstable(props: VirtualizerScrollViewPr bufferItems, bufferSize, scrollViewRef: iScrollRef, + flagIndex: props.scrollCallbacks + ? ({ + flaggedIndex: scrollCallbackIndex, + onRenderedFlaggedIndex: props.scrollCallbacks.didScrollToItem.current, + } as FlaggedIndexCallback) + : undefined, }); return { @@ -29,7 +57,7 @@ export function useVirtualizerScrollView_unstable(props: VirtualizerScrollViewPr container: resolveShorthand(props.container, { required: true, defaultProps: { - ref: iScrollRef as React.RefObject, + ref: iScrollRef, }, }), }; diff --git a/packages/react-components/react-virtualizer/src/components/VirtualizerScrollView/useVirtualizerScrollViewStyles.ts b/packages/react-components/react-virtualizer/src/components/VirtualizerScrollView/useVirtualizerScrollViewStyles.ts index b457f20bcef4c1..023fdc40b5739c 100644 --- a/packages/react-components/react-virtualizer/src/components/VirtualizerScrollView/useVirtualizerScrollViewStyles.ts +++ b/packages/react-components/react-virtualizer/src/components/VirtualizerScrollView/useVirtualizerScrollViewStyles.ts @@ -45,7 +45,7 @@ export const useVirtualizerScrollViewStyles_unstable = ( ): VirtualizerScrollViewState => { const styles = useStyles(); - // For now - just return default style mods + // Default virtualizer styles base useVirtualizerStyles_unstable(state); const containerStyle = @@ -57,6 +57,7 @@ export const useVirtualizerScrollViewStyles_unstable = ( ? styles.verticalReversed : styles.vertical; + // Add container styles state.container.className = mergeClasses( virtualizerScrollViewClassNames.container, styles.base, diff --git a/packages/react-components/react-virtualizer/src/components/VirtualizerScrollViewDynamic/VirtualizerScrollViewDynamic.ts b/packages/react-components/react-virtualizer/src/components/VirtualizerScrollViewDynamic/VirtualizerScrollViewDynamic.ts new file mode 100644 index 00000000000000..64894fda84c078 --- /dev/null +++ b/packages/react-components/react-virtualizer/src/components/VirtualizerScrollViewDynamic/VirtualizerScrollViewDynamic.ts @@ -0,0 +1,35 @@ +import { VirtualizerScrollViewDynamicProps } from './VirtualizerScrollViewDynamic.types'; +import { useVirtualizerScrollViewDynamic_unstable } from './useVirtualizerScrollViewDynamic'; +import { + renderContextlessVirtualizerScrollViewDynamic_unstable, + renderVirtualizerScrollViewDynamic_unstable, +} from './renderVirtualizerScrollViewDynamic'; +import { useVirtualizerScrollViewDynamicStyles_unstable } from './useVirtualizerScrollViewDynamicStyles'; +import * as React from 'react'; +import { useVirtualizerContextState, VirtualizerContextProps } from '../../Utilities'; + +/** + * Virtualizer ScrollView + */ + +export const ContextlessVirtualizerScrollViewDynamic: React.FC = ( + props: VirtualizerScrollViewDynamicProps, + context: React.Context, +) => { + const state = useVirtualizerScrollViewDynamic_unstable(props); + + useVirtualizerScrollViewDynamicStyles_unstable(state); + + return renderContextlessVirtualizerScrollViewDynamic_unstable(state); +}; + +ContextlessVirtualizerScrollViewDynamic.displayName = 'ContextlessVirtualizerScrollViewDynamic'; + +export const VirtualizerScrollViewDynamic: React.FC = ( + props: VirtualizerScrollViewDynamicProps, +) => { + const context = useVirtualizerContextState(); + return renderVirtualizerScrollViewDynamic_unstable(props, context); +}; + +VirtualizerScrollViewDynamic.displayName = 'VirtualizerScrollViewDynamic'; 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 new file mode 100644 index 00000000000000..4305aa6ab79642 --- /dev/null +++ b/packages/react-components/react-virtualizer/src/components/VirtualizerScrollViewDynamic/VirtualizerScrollViewDynamic.types.ts @@ -0,0 +1,44 @@ +import { ComponentProps, ComponentState } from '@fluentui/react-utilities'; +import { + VirtualizerConfigProps, + VirtualizerConfigState, + VirtualizerChildRenderFunction, +} from '../Virtualizer/Virtualizer.types'; + +import { VirtualizerScrollViewSlots } from '../VirtualizerScrollView/VirtualizerScrollView.types'; +import { VirtualizerScrollCallbacks } from '../../hooks/useImperativeScrolling.types'; + +export type VirtualizerScrollViewDynamicSlots = VirtualizerScrollViewSlots; + +export type VirtualizerScrollViewDynamicProps = ComponentProps> & + Partial> & { + /** + * Set as the minimum item size. + * Axis: 'vertical' = Height + * Axis: 'horizontal' = Width + */ + itemSize: number; + /** + * Callback for acquiring size of individual items + * @param index - the index of the requested size's child + */ + getItemSize: (index: number) => number; + /** + * The total number of items to be virtualized. + */ + numItems: number; + /** + * Child render function. + * Iteratively called to return current virtualizer DOM children. + * Will act as a row or column indexer depending on Virtualizer settings. + */ + children: VirtualizerChildRenderFunction; + + /** + * Optional imperative scrolling functions from our useImperativeScrolling hook. + */ + scrollCallbacks?: VirtualizerScrollCallbacks; + }; + +export type VirtualizerScrollViewDynamicState = ComponentState & + VirtualizerConfigState; diff --git a/packages/react-components/react-virtualizer/src/components/VirtualizerScrollViewDynamic/index.ts b/packages/react-components/react-virtualizer/src/components/VirtualizerScrollViewDynamic/index.ts new file mode 100644 index 00000000000000..0c5e4e353fab9e --- /dev/null +++ b/packages/react-components/react-virtualizer/src/components/VirtualizerScrollViewDynamic/index.ts @@ -0,0 +1,5 @@ +export * from './VirtualizerScrollViewDynamic'; +export * from './VirtualizerScrollViewDynamic.types'; +export * from './useVirtualizerScrollViewDynamic'; +export * from './renderVirtualizerScrollViewDynamic'; +export * from './useVirtualizerScrollViewDynamicStyles'; diff --git a/packages/react-components/react-virtualizer/src/components/VirtualizerScrollViewDynamic/renderVirtualizerScrollViewDynamic.tsx b/packages/react-components/react-virtualizer/src/components/VirtualizerScrollViewDynamic/renderVirtualizerScrollViewDynamic.tsx new file mode 100644 index 00000000000000..502362489f5115 --- /dev/null +++ b/packages/react-components/react-virtualizer/src/components/VirtualizerScrollViewDynamic/renderVirtualizerScrollViewDynamic.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; +import { getSlots } from '@fluentui/react-utilities'; +import { + VirtualizerScrollViewDynamicProps, + VirtualizerScrollViewDynamicSlots, + VirtualizerScrollViewDynamicState, +} from './VirtualizerScrollViewDynamic.types'; +import { VirtualizerContextProvider, VirtualizerContextProps } from '../../Utilities'; +import { ContextlessVirtualizerScrollViewDynamic } from './VirtualizerScrollViewDynamic'; +import { renderVirtualizer_unstable } from '../Virtualizer/renderVirtualizer'; + +export const renderContextlessVirtualizerScrollViewDynamic_unstable = (state: VirtualizerScrollViewDynamicState) => { + const { slots, slotProps } = getSlots(state); + + return {renderVirtualizer_unstable(state)}; +}; + +export const renderVirtualizerScrollViewDynamic_unstable = ( + props: VirtualizerScrollViewDynamicProps, + context: VirtualizerContextProps, +) => { + return ( + + + + ); +}; diff --git a/packages/react-components/react-virtualizer/src/components/VirtualizerScrollViewDynamic/useVirtualizerScrollViewDynamic.ts b/packages/react-components/react-virtualizer/src/components/VirtualizerScrollViewDynamic/useVirtualizerScrollViewDynamic.ts new file mode 100644 index 00000000000000..4f496ed9ddaed5 --- /dev/null +++ b/packages/react-components/react-virtualizer/src/components/VirtualizerScrollViewDynamic/useVirtualizerScrollViewDynamic.ts @@ -0,0 +1,73 @@ +import * as React from 'react'; +import { resolveShorthand, useMergedRefs } from '@fluentui/react-utilities'; +import { useVirtualizer_unstable } from '../Virtualizer/useVirtualizer'; +import { + VirtualizerScrollViewDynamicProps, + VirtualizerScrollViewDynamicState, +} from './VirtualizerScrollViewDynamic.types'; +import { useDynamicVirtualizerMeasure } from '../../Hooks'; +import { useVirtualizerContext } from '../../Utilities'; +import { _scrollToItemDynamic } from '../../hooks/useImperativeScrolling'; +import { FlaggedIndexCallback } from '../Virtualizer/Virtualizer.types'; + +export function useVirtualizerScrollViewDynamic_unstable( + props: VirtualizerScrollViewDynamicProps, +): VirtualizerScrollViewDynamicState { + const { contextIndex, currentChildSizes, totalSize } = useVirtualizerContext(); + const { virtualizerLength, bufferItems, bufferSize, scrollRef, sizingArray } = useDynamicVirtualizerMeasure({ + defaultItemSize: props.itemSize, + direction: props.axis ?? 'vertical', + getItemSize: props.getItemSize, + currentIndex: contextIndex, + numItems: props.numItems, + }); + + const iScrollRef = useMergedRefs(React.useRef(null), scrollRef) as React.RefObject; + const scrollCallbackIndex = React.useRef(null); + + React.useEffect(() => { + const { axis = 'vertical', reversed } = props; + if (props.scrollCallbacks) { + props.scrollCallbacks.scrollToItem.current = (index: number) => { + scrollCallbackIndex.current = index; + _scrollToItemDynamic({ + indexRef: scrollCallbackIndex, + itemSizes: currentChildSizes ?? sizingArray, + totalSize: totalSize?.current ?? 0, + scrollView: iScrollRef, + axis, + reversed, + }); + }; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [iScrollRef, props.scrollCallbacks]); + + const virtualizerState = useVirtualizer_unstable({ + ...props, + virtualizerLength, + bufferItems, + bufferSize, + scrollViewRef: iScrollRef, + flagIndex: props.scrollCallbacks + ? ({ + flaggedIndex: scrollCallbackIndex, + onRenderedFlaggedIndex: props.scrollCallbacks.didScrollToItem.current, + } as FlaggedIndexCallback) + : undefined, + }); + + return { + ...virtualizerState, + components: { + ...virtualizerState.components, + container: 'div', + }, + container: resolveShorthand(props.container, { + required: true, + defaultProps: { + ref: iScrollRef, + }, + }), + }; +} diff --git a/packages/react-components/react-virtualizer/src/components/VirtualizerScrollViewDynamic/useVirtualizerScrollViewDynamicStyles.ts b/packages/react-components/react-virtualizer/src/components/VirtualizerScrollViewDynamic/useVirtualizerScrollViewDynamicStyles.ts new file mode 100644 index 00000000000000..bbf6deb5da7edc --- /dev/null +++ b/packages/react-components/react-virtualizer/src/components/VirtualizerScrollViewDynamic/useVirtualizerScrollViewDynamicStyles.ts @@ -0,0 +1,70 @@ +import type { SlotClassNames } from '@fluentui/react-utilities'; +import { + VirtualizerScrollViewDynamicSlots, + VirtualizerScrollViewDynamicState, +} from './VirtualizerScrollViewDynamic.types'; +import { useVirtualizerStyles_unstable, virtualizerClassNames } from '../Virtualizer/useVirtualizerStyles'; +import { makeStyles, mergeClasses } from '@griffel/react'; + +const virtualizerScrollViewDynamicClassName = 'fui-Virtualizer-Scroll-View-Dynamic'; + +export const virtualizerScrollViewDynamicClassNames: SlotClassNames = { + ...virtualizerClassNames, + container: `${virtualizerScrollViewDynamicClassName}__container`, +}; + +const useStyles = makeStyles({ + base: { + display: 'flex', + width: '100%', + height: '100%', + overflowAnchor: 'none', + }, + vertical: { + flexDirection: 'column', + overflowY: 'auto', + }, + horizontal: { + flexDirection: 'row', + overflowX: 'auto', + }, + verticalReversed: { + flexDirection: 'column-reverse', + overflowY: 'auto', + }, + horizontalReversed: { + flexDirection: 'row-reverse', + overflowX: 'auto', + }, +}); + +/** + * Apply styling to the Virtualizer states + */ +export const useVirtualizerScrollViewDynamicStyles_unstable = ( + state: VirtualizerScrollViewDynamicState, +): VirtualizerScrollViewDynamicState => { + const styles = useStyles(); + + // Default virtualizer styles base + useVirtualizerStyles_unstable(state); + + const containerStyle = + state.axis === 'horizontal' + ? state.reversed + ? styles.horizontalReversed + : styles.horizontal + : state.reversed + ? styles.verticalReversed + : styles.vertical; + + // Add container styles + state.container.className = mergeClasses( + virtualizerScrollViewDynamicClassNames.container, + styles.base, + containerStyle, + state.container.className, + ); + + return state; +}; diff --git a/packages/react-components/react-virtualizer/src/hooks/useImperativeScrolling.ts b/packages/react-components/react-virtualizer/src/hooks/useImperativeScrolling.ts new file mode 100644 index 00000000000000..4f2812fbc42a28 --- /dev/null +++ b/packages/react-components/react-virtualizer/src/hooks/useImperativeScrolling.ts @@ -0,0 +1,94 @@ +import { ScrollToItemDynamic, ScrollToItemStatic, VirtualizerScrollCallbacks } from './useImperativeScrolling.types'; +import { useRef } from 'react'; + +export const useImperativeScrolling = (): VirtualizerScrollCallbacks => { + // The virtualizer scroll container will override this function for the user to call + const scrollToItem = useRef<(index: number) => void>(); + // The user will override this function for the virtualizer scroll container to call + const didScrollToItem = useRef<(index: number) => void>(); + + return { + scrollToItem, + didScrollToItem, + }; +}; + +export const _scrollToItemStatic = (params: ScrollToItemStatic) => { + const { indexRef, itemSize, totalItems, scrollView, axis = 'vertical', reversed = false } = params; + + if (indexRef.current === null) { + // null check - abort + return; + } + + if (axis === 'horizontal') { + if (reversed) { + scrollView.current?.scrollTo({ + left: totalItems * itemSize - itemSize * indexRef.current, + behavior: 'auto', + }); + } else { + scrollView.current?.scrollTo({ + left: itemSize * indexRef.current, + behavior: 'auto', + }); + } + } else { + if (reversed) { + scrollView.current?.scrollTo({ + top: totalItems * itemSize - itemSize * indexRef.current, + behavior: 'auto', + }); + } else { + scrollView.current?.scrollTo({ + top: itemSize * indexRef.current, + behavior: 'auto', + }); + } + } +}; + +export const _scrollToItemDynamic = (params: ScrollToItemDynamic) => { + const { indexRef, itemSizes, totalSize, scrollView, axis = 'vertical', reversed = false } = params; + if (!itemSizes.current) { + return; + } + + if (indexRef.current === null || itemSizes.current === null || itemSizes.current.length < indexRef.current) { + // null check - abort + return; + } + + let itemDepth = 0; + for (let i = 0; i < indexRef.current; i++) { + if (i < indexRef.current) { + itemDepth += itemSizes.current[i]; + } + } + + if (axis === 'horizontal') { + if (reversed) { + scrollView.current?.scrollTo({ + left: totalSize - itemDepth, + behavior: 'smooth', + }); + } else { + scrollView.current?.scrollTo({ + left: itemDepth, + behavior: 'smooth', + }); + } + } else { + if (reversed) { + scrollView.current?.scrollTo({ + top: totalSize - itemDepth, + behavior: 'smooth', + }); + } else { + scrollView.current?.scrollTo({ + top: itemDepth, + behavior: 'smooth', + }); + } + } +}; diff --git a/packages/react-components/react-virtualizer/src/hooks/useImperativeScrolling.types.ts b/packages/react-components/react-virtualizer/src/hooks/useImperativeScrolling.types.ts new file mode 100644 index 00000000000000..521e4ca6414adb --- /dev/null +++ b/packages/react-components/react-virtualizer/src/hooks/useImperativeScrolling.types.ts @@ -0,0 +1,24 @@ +import { MutableRefObject, RefObject } from 'react'; + +export type VirtualizerScrollCallbacks = { + scrollToItem: MutableRefObject<((index: number) => void) | undefined>; + didScrollToItem: MutableRefObject<((index: number) => void) | undefined>; +}; + +export type ScrollToItemStatic = { + indexRef: MutableRefObject; + itemSize: number; + totalItems: number; + scrollView: RefObject; + axis?: 'horizontal' | 'vertical'; + reversed?: boolean; +}; + +export type ScrollToItemDynamic = { + indexRef: MutableRefObject; + itemSizes: RefObject; + totalSize: number; + scrollView: RefObject; + axis?: 'horizontal' | 'vertical'; + reversed?: boolean; +}; diff --git a/packages/react-components/react-virtualizer/src/hooks/useVirtualizerMeasure.ts b/packages/react-components/react-virtualizer/src/hooks/useVirtualizerMeasure.ts index d5f92320ecb233..1e77ff8c7c2da4 100644 --- a/packages/react-components/react-virtualizer/src/hooks/useVirtualizerMeasure.ts +++ b/packages/react-components/react-virtualizer/src/hooks/useVirtualizerMeasure.ts @@ -1,6 +1,6 @@ import * as React from 'react'; import { canUseDOM } from '@fluentui/react-utilities'; -import { VirtualizerMeasureProps } from './useVirtualizerMeasure.types'; +import { VirtualizerMeasureDynamicProps, VirtualizerMeasureProps } from './useVirtualizerMeasure.types'; import { debounce } from '../utilities/debounce'; /** @@ -24,7 +24,7 @@ export const useStaticVirtualizerMeasure = ( const { virtualizerLength, bufferItems, bufferSize } = state; - // The ref the user sets on their scrollView - Defaults to document.body to ensure no null on init + // The ref the user sets on their scrollView const container = React.useRef(null); const resizeCallback = () => { @@ -97,3 +97,142 @@ export const useStaticVirtualizerMeasure = ( scrollRef, }; }; + +/** + * React hook that measures virtualized space dynamically to ensure optimized virtualization length. + */ +export const useDynamicVirtualizerMeasure = ( + virtualizerProps: VirtualizerMeasureDynamicProps, +): { + virtualizerLength: number; + bufferItems: number; + bufferSize: number; + scrollRef: (instance: HTMLElement | HTMLDivElement | null) => void; + sizingArray: React.RefObject; +} => { + const { defaultItemSize, direction = 'vertical', numItems, getItemSize, currentIndex } = virtualizerProps; + + const sizeTracker = React.useRef(new Array(numItems).fill(defaultItemSize)); + + if (sizeTracker.current.length !== numItems) { + // numItems changed, morph array - keep previously explored values. + const newItems = numItems - sizeTracker.current.length; + if (newItems > 0) { + sizeTracker.current = [...sizeTracker.current, ...Array(newItems).fill(defaultItemSize)]; + } else if (numItems > 0) { + sizeTracker.current.splice(numItems, newItems * -1); + } + } + + const [state, setState] = React.useState({ + virtualizerLength: 0, + virtualizerBufferItems: 0, + virtualizerBufferSize: 0, + }); + + const { virtualizerLength, virtualizerBufferItems, virtualizerBufferSize } = state; + + const container = React.useRef(null); + + const resizeCallback = () => { + if (!container.current) { + // Error? ignore? + return; + } + + const containerSize = + direction === 'vertical' + ? container.current?.getBoundingClientRect().height + : container.current?.getBoundingClientRect().width; + + let indexSizer = 0; + let length = 0; + + while (indexSizer <= containerSize) { + const iItemSize = getItemSize(currentIndex + length); + sizeTracker.current[currentIndex + length] = iItemSize; + + // Increment + indexSizer += iItemSize; + length++; + } + + /* + * Number of items to append at each end, i.e. 'preload' each side before entering view. + */ + const bufferItems = Math.max(Math.floor(length / 4), 2); + + /* + * This is how far we deviate into the bufferItems to detect a redraw. + */ + const bufferSize = Math.max(Math.floor((length / 8) * defaultItemSize), 1); + + const totalLength = length + bufferItems * 2 + 3; + + setState({ + virtualizerLength: totalLength, + virtualizerBufferSize: bufferSize, + virtualizerBufferItems: bufferItems, + }); + }; + + // the handler for resize observer + const handleResize = debounce(resizeCallback); + + // Keep the reference of ResizeObserver in the state, as it should live through renders + const [resizeObserver] = React.useState(canUseDOM() ? new ResizeObserver(handleResize) : undefined); + + const scrollRef = React.useCallback( + (el: HTMLElement | null) => { + if (container.current !== el) { + if (container.current) { + resizeObserver?.unobserve(container.current); + } + + container.current = el; + if (container.current) { + resizeObserver?.observe(container.current); + } + } + }, + [resizeObserver], + ); + + if (container.current) { + const containerSize = + direction === 'vertical' + ? container.current?.getBoundingClientRect().height * 1.5 + : container.current?.getBoundingClientRect().width * 1.5; + + let couldBeSmaller = false; + let recheckTotal = 0; + for (let i = currentIndex; i < currentIndex + virtualizerLength; i++) { + const newItemSize = getItemSize(i); + sizeTracker.current[i] = newItemSize; + recheckTotal += newItemSize; + + const newLength = i - currentIndex; + + const bufferItems = Math.max(Math.floor(newLength / 4), 2); + const totalNewLength = newLength + bufferItems * 2 + 4; + const compareLengths = totalNewLength < virtualizerLength; + + if (recheckTotal > containerSize && compareLengths && !couldBeSmaller) { + couldBeSmaller = true; + } + } + + // Check if the render has caused us to need a re-calc of virtualizer length + if (recheckTotal < containerSize || couldBeSmaller) { + handleResize(); + } + } + + return { + virtualizerLength, + bufferItems: virtualizerBufferItems, + bufferSize: virtualizerBufferSize, + scrollRef, + sizingArray: sizeTracker, + }; +}; diff --git a/packages/react-components/react-virtualizer/src/hooks/useVirtualizerMeasure.types.ts b/packages/react-components/react-virtualizer/src/hooks/useVirtualizerMeasure.types.ts index dffd7c801c7b89..8e468be400110c 100644 --- a/packages/react-components/react-virtualizer/src/hooks/useVirtualizerMeasure.types.ts +++ b/packages/react-components/react-virtualizer/src/hooks/useVirtualizerMeasure.types.ts @@ -1,4 +1,14 @@ +import { MutableRefObject } from 'react'; + export type VirtualizerMeasureProps = { defaultItemSize: number; direction?: 'vertical' | 'horizontal'; }; + +export type VirtualizerMeasureDynamicProps = { + defaultItemSize: number; + currentIndex: number; + numItems: number; + getItemSize: (index: number) => number; + direction?: 'vertical' | 'horizontal'; +}; diff --git a/packages/react-components/react-virtualizer/src/index.ts b/packages/react-components/react-virtualizer/src/index.ts index eff1e6574b1946..918ca9fdfe066a 100644 --- a/packages/react-components/react-virtualizer/src/index.ts +++ b/packages/react-components/react-virtualizer/src/index.ts @@ -11,7 +11,12 @@ export type { VirtualizerSlots, VirtualizerChildRenderFunction, } from './Virtualizer'; -export { useIntersectionObserver, useStaticVirtualizerMeasure } from './Hooks'; + +export { useIntersectionObserver, useStaticVirtualizerMeasure, useDynamicVirtualizerMeasure } from './Hooks'; + +export { VirtualizerContextProvider, useVirtualizerContext } from './Utilities'; + +export type { VirtualizerContextProps } from './Utilities'; export { VirtualizerScrollView, @@ -26,3 +31,19 @@ export type { VirtualizerScrollViewState, VirtualizerScrollViewSlots, } from './VirtualizerScrollView'; + +export { + VirtualizerScrollViewDynamic, + ContextlessVirtualizerScrollViewDynamic, + virtualizerScrollViewDynamicClassNames, + useVirtualizerScrollViewDynamic_unstable, + renderVirtualizerScrollViewDynamic_unstable, + renderContextlessVirtualizerScrollViewDynamic_unstable, + useVirtualizerScrollViewDynamicStyles_unstable, +} from './VirtualizerScrollViewDynamic'; + +export type { + VirtualizerScrollViewDynamicProps, + VirtualizerScrollViewDynamicState, + VirtualizerScrollViewDynamicSlots, +} from './VirtualizerScrollViewDynamic'; diff --git a/packages/react-components/react-virtualizer/src/utilities/VirtualizerContext/VirtualizerContext.ts b/packages/react-components/react-virtualizer/src/utilities/VirtualizerContext/VirtualizerContext.ts new file mode 100644 index 00000000000000..90b39a6ab4687e --- /dev/null +++ b/packages/react-components/react-virtualizer/src/utilities/VirtualizerContext/VirtualizerContext.ts @@ -0,0 +1,20 @@ +import * as React from 'react'; +import type { VirtualizerContextProps } from './types'; + +const VirtualizerContext = React.createContext( + undefined, +) as React.Context; + +export const VirtualizerContextProvider = VirtualizerContext.Provider; + +export const useVirtualizerContext = () => { + return React.useContext(VirtualizerContext); +}; + +export const useVirtualizerContextState = (): VirtualizerContextProps => { + const [contextIndex, setContextIndex] = React.useState(0); + const currentChildSizes = React.useRef([]); + const progressiveChildSizes = React.useRef([]); + const totalSize = React.useRef(0); + return { contextIndex, setContextIndex, currentChildSizes, totalSize, progressiveChildSizes }; +}; diff --git a/packages/react-components/react-virtualizer/src/utilities/VirtualizerContext/index.ts b/packages/react-components/react-virtualizer/src/utilities/VirtualizerContext/index.ts new file mode 100644 index 00000000000000..3eee78749512cc --- /dev/null +++ b/packages/react-components/react-virtualizer/src/utilities/VirtualizerContext/index.ts @@ -0,0 +1,2 @@ +export * from './VirtualizerContext'; +export * from './types'; diff --git a/packages/react-components/react-virtualizer/src/utilities/VirtualizerContext/types.ts b/packages/react-components/react-virtualizer/src/utilities/VirtualizerContext/types.ts new file mode 100644 index 00000000000000..d726e3119c4653 --- /dev/null +++ b/packages/react-components/react-virtualizer/src/utilities/VirtualizerContext/types.ts @@ -0,0 +1,23 @@ +import { MutableRefObject } from 'react'; + +/** + * {@docCategory Virtualizer} + */ +export type VirtualizerContextProps = { + contextIndex: number; + setContextIndex: (index: number) => void; + totalSize?: MutableRefObject; + + /* + * Virtualizer's current item sizes - external hooks can track/update. + * Users can update sizing at any time to handle dynamic updates. + */ + currentChildSizes?: MutableRefObject; + /* + * Virtualizer's current progressive sizes. + * + * Updating these in-sync instead of waiting for Virtualizer to catch a mismatch + * will ensure accuracy from any external currentChildSizes updates. + */ + progressiveChildSizes?: MutableRefObject; +}; diff --git a/packages/react-components/react-virtualizer/src/utilities/index.ts b/packages/react-components/react-virtualizer/src/utilities/index.ts new file mode 100644 index 00000000000000..a4f06905012b63 --- /dev/null +++ b/packages/react-components/react-virtualizer/src/utilities/index.ts @@ -0,0 +1 @@ +export * from './VirtualizerContext'; diff --git a/packages/react-components/react-virtualizer/stories/Virtualizer/Default.stories.tsx b/packages/react-components/react-virtualizer/stories/Virtualizer/Default.stories.tsx index 00ab141a061181..28b75b3f13a2f1 100644 --- a/packages/react-components/react-virtualizer/stories/Virtualizer/Default.stories.tsx +++ b/packages/react-components/react-virtualizer/stories/Virtualizer/Default.stories.tsx @@ -10,7 +10,7 @@ const useStyles = makeStyles({ overflowY: 'auto', width: '100%', height: '100%', - maxHeight: '60vh', + maxHeight: '750px', }, child: { height: '100px', diff --git a/packages/react-components/react-virtualizer/stories/Virtualizer/DefaultUnbounded.stories.tsx b/packages/react-components/react-virtualizer/stories/Virtualizer/DefaultUnbounded.stories.tsx index fea0580d0945c2..ded4e0d6c1f553 100644 --- a/packages/react-components/react-virtualizer/stories/Virtualizer/DefaultUnbounded.stories.tsx +++ b/packages/react-components/react-virtualizer/stories/Virtualizer/DefaultUnbounded.stories.tsx @@ -5,9 +5,6 @@ import { makeStyles } from '@fluentui/react-components'; import { useFluent } from '@fluentui/react-components'; const useStyles = makeStyles({ - root: { - maxHeight: '100vh', - }, container: { display: 'flex', flexDirection: 'column', diff --git a/packages/react-components/react-virtualizer/stories/Virtualizer/Dynamic.stories.tsx b/packages/react-components/react-virtualizer/stories/Virtualizer/Dynamic.stories.tsx index 73000e8da9fe5d..5d30169516e2c6 100644 --- a/packages/react-components/react-virtualizer/stories/Virtualizer/Dynamic.stories.tsx +++ b/packages/react-components/react-virtualizer/stories/Virtualizer/Dynamic.stories.tsx @@ -1,5 +1,9 @@ import * as React from 'react'; -import { Virtualizer } from '@fluentui/react-components/unstable'; +import { + Virtualizer, + useDynamicVirtualizerMeasure, + VirtualizerContextProvider, +} from '@fluentui/react-components/unstable'; import { makeStyles } from '@fluentui/react-components'; const smallSize = 100; @@ -29,6 +33,7 @@ const useStyles = makeStyles({ }); export const Dynamic = () => { + const [currentIndex, setCurrentIndex] = React.useState(-1); const [flag, toggleFlag] = React.useState(false); const styles = useStyles(); const childLength = 1000; @@ -52,29 +57,39 @@ export const Dynamic = () => { return sizeValue; }; + const { virtualizerLength, bufferItems, bufferSize, scrollRef } = useDynamicVirtualizerMeasure({ + defaultItemSize: 100, + getItemSize: getSizeForIndex, + numItems: childLength, + currentIndex, + }); + return ( -
- getSizeForIndex(index)} - numItems={childLength} - bufferSize={50} - virtualizerLength={25} - itemSize={100} - > - {(index: number) => { - const sizeValue = getSizeForIndex(index); - const sizeClass = sizeValue === smallSize ? styles.child : styles.childLarge; - return ( -
{`Node-${index}-size-${sizeValue}`}
- ); - }} -
-
+ +
+ getSizeForIndex(index)} + numItems={childLength} + bufferSize={bufferSize} + bufferItems={bufferItems} + virtualizerLength={virtualizerLength} + itemSize={100} + > + {(index: number) => { + const sizeValue = getSizeForIndex(index); + const sizeClass = sizeValue === smallSize ? styles.child : styles.childLarge; + return ( +
{`Node-${index}-size-${sizeValue}`}
+ ); + }} +
+
+
); }; diff --git a/packages/react-components/react-virtualizer/stories/VirtualizerScrollView/Default.stories.tsx b/packages/react-components/react-virtualizer/stories/VirtualizerScrollView/Default.stories.tsx index 458ead434fee54..b515b6a7b24046 100644 --- a/packages/react-components/react-virtualizer/stories/VirtualizerScrollView/Default.stories.tsx +++ b/packages/react-components/react-virtualizer/stories/VirtualizerScrollView/Default.stories.tsx @@ -3,9 +3,6 @@ import { VirtualizerScrollView } from '@fluentui/react-components/unstable'; import { makeStyles } from '@fluentui/react-components'; const useStyles = makeStyles({ - root: { - maxHeight: '100vh', - }, child: { height: '100px', lineHeight: '100px', diff --git a/packages/react-components/react-virtualizer/stories/VirtualizerScrollView/ScrollTo.stories.tsx b/packages/react-components/react-virtualizer/stories/VirtualizerScrollView/ScrollTo.stories.tsx new file mode 100644 index 00000000000000..736cb67e35dc8d --- /dev/null +++ b/packages/react-components/react-virtualizer/stories/VirtualizerScrollView/ScrollTo.stories.tsx @@ -0,0 +1,56 @@ +import * as React from 'react'; +import { VirtualizerScrollView } from '@fluentui/react-components/unstable'; +import { makeStyles } from '@fluentui/react-components'; +import { useImperativeScrolling } from '../../src/hooks/useImperativeScrolling'; +import { Button } from '@fluentui/react-components'; +const useStyles = makeStyles({ + child: { + height: '100px', + lineHeight: '100px', + width: '100%', + }, +}); + +export const ScrollTo = () => { + const styles = useStyles(); + const childLength = 1000; + const scrollCallbacks = useImperativeScrolling(); + + const scrollToRandomIndex = () => { + const randomIndex = Math.floor(Math.random() * childLength); + if (scrollCallbacks.scrollToItem.current) { + console.log('Scrolling to random index: ', randomIndex); + scrollCallbacks.scrollToItem.current(randomIndex); + } + }; + + const reachedIndexCallback = (index: number) => { + console.log('Reached index: ', index); + }; + + scrollCallbacks.didScrollToItem.current = reachedIndexCallback; + + return ( +
+ + + {(index: number) => { + return ( +
{`Node-${index}`}
+ ); + }} +
+
+ ); +}; diff --git a/packages/react-components/react-virtualizer/stories/VirtualizerScrollView/index.stories.ts b/packages/react-components/react-virtualizer/stories/VirtualizerScrollView/index.stories.ts index db5040009bd414..456f3e7ae733f8 100644 --- a/packages/react-components/react-virtualizer/stories/VirtualizerScrollView/index.stories.ts +++ b/packages/react-components/react-virtualizer/stories/VirtualizerScrollView/index.stories.ts @@ -2,6 +2,7 @@ import { VirtualizerScrollView } from '../../src/VirtualizerScrollView'; import descriptionMd from './VirtualizerScrollViewDescription.md'; export { Default } from './Default.stories'; +export { ScrollTo } from './ScrollTo.stories'; export default { title: 'Preview Components/VirtualizerScrollView', diff --git a/packages/react-components/react-virtualizer/stories/VirtualizerScrollViewDynamic/Default.stories.tsx b/packages/react-components/react-virtualizer/stories/VirtualizerScrollViewDynamic/Default.stories.tsx new file mode 100644 index 00000000000000..0b166fd70ca617 --- /dev/null +++ b/packages/react-components/react-virtualizer/stories/VirtualizerScrollViewDynamic/Default.stories.tsx @@ -0,0 +1,62 @@ +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 Default = () => { + const styles = useStyles(); + const childLength = 1000; + const minHeight = 42; + // Array size ref stores a list of random num for div sizing and callbacks + const arraySize = React.useRef(new Array(childLength).fill(minHeight)); + // totalSize flag drives our callback update + const [totalSize, setTotalSize] = React.useState(minHeight * childLength); + + useEffect(() => { + let _totalSize = 0; + for (let i = 0; i < childLength; i++) { + arraySize.current[i] = Math.random() * 150 + minHeight; + _totalSize += arraySize.current[i]; + } + setTotalSize(_totalSize); + }, []); + + const getItemSizeCallback = React.useCallback( + (index: number) => { + return arraySize.current[index]; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [arraySize, totalSize], + ); + + 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/ScrollLoading.stories.tsx b/packages/react-components/react-virtualizer/stories/VirtualizerScrollViewDynamic/ScrollLoading.stories.tsx new file mode 100644 index 00000000000000..16ed658f185d55 --- /dev/null +++ b/packages/react-components/react-virtualizer/stories/VirtualizerScrollViewDynamic/ScrollLoading.stories.tsx @@ -0,0 +1,64 @@ +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 ScrollLoading = () => { + const styles = useStyles(); + const childLength = 1000; + const minHeight = 42; + // Array size ref stores a list of random num for div sizing and callbacks + const arraySize = React.useRef(new Array(childLength).fill(minHeight)); + // totalSize flag drives our callback update + const [totalSize, setTotalSize] = React.useState(minHeight * childLength); + + useEffect(() => { + let _totalSize = 0; + for (let i = 0; i < childLength; i++) { + arraySize.current[i] = Math.random() * 150 + minHeight; + _totalSize += arraySize.current[i]; + } + setTotalSize(_totalSize); + }, []); + + const getItemSizeCallback = React.useCallback( + (index: number) => { + return arraySize.current[index]; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [arraySize, totalSize], + ); + + return ( + + {(index: number, isScrolling = false) => { + const backgroundColor = index % 2 ? '#FFFFFF' : '#ABABAB'; + return isScrolling ? ( +
LOADING
+ ) : ( +
{`Node-${index} - size: ${arraySize.current[index]}`}
+ ); + }} +
+ ); +}; diff --git a/packages/react-components/react-virtualizer/stories/VirtualizerScrollViewDynamic/ScrollTo.stories.tsx b/packages/react-components/react-virtualizer/stories/VirtualizerScrollViewDynamic/ScrollTo.stories.tsx new file mode 100644 index 00000000000000..78916e6fa5a04e --- /dev/null +++ b/packages/react-components/react-virtualizer/stories/VirtualizerScrollViewDynamic/ScrollTo.stories.tsx @@ -0,0 +1,85 @@ +import * as React from 'react'; +import { VirtualizerScrollViewDynamic } from '@fluentui/react-components/unstable'; +import { Button, makeStyles } from '@fluentui/react-components'; +import { useEffect } from 'react'; +import { useImperativeScrolling } from '../../src/hooks/useImperativeScrolling'; + +const useStyles = makeStyles({ + child: { + lineHeight: '42px', + width: '100%', + minHeight: '42px', + }, +}); + +export const ScrollTo = () => { + const styles = useStyles(); + const childLength = 1000; + const minHeight = 42; + // Array size ref stores a list of random num for div sizing and callbacks + const arraySize = React.useRef(new Array(childLength).fill(minHeight)); + // totalSize flag drives our callback update + const [totalSize, setTotalSize] = React.useState(minHeight * childLength); + + useEffect(() => { + let _totalSize = 0; + for (let i = 0; i < childLength; i++) { + arraySize.current[i] = Math.random() * 250 + minHeight; + _totalSize += arraySize.current[i]; + } + setTotalSize(_totalSize); + }, []); + + const getItemSizeCallback = React.useCallback( + (index: number) => { + return arraySize.current[index]; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [arraySize, totalSize], + ); + + /* Imperative scrolling hooks */ + + const scrollCallbacks = useImperativeScrolling(); + + const scrollToRandomIndex = () => { + const randomIndex = Math.floor(Math.random() * childLength); + if (scrollCallbacks.scrollToItem.current) { + console.log('Scrolling to random index: ', randomIndex); + scrollCallbacks.scrollToItem.current(randomIndex); + } + }; + + const reachedIndexCallback = (index: number) => { + console.log('Reached index: ', index); + }; + + scrollCallbacks.didScrollToItem.current = reachedIndexCallback; + + 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/VirtualizerScrollViewDynamicDescription.md b/packages/react-components/react-virtualizer/stories/VirtualizerScrollViewDynamic/VirtualizerScrollViewDynamicDescription.md new file mode 100644 index 00000000000000..78973fe04255e6 --- /dev/null +++ b/packages/react-components/react-virtualizer/stories/VirtualizerScrollViewDynamic/VirtualizerScrollViewDynamicDescription.md @@ -0,0 +1,12 @@ + + +> **⚠️ Preview components are considered unstable:** +> +> ```jsx +> +> import { VirtualizerScrollViewDynamic } from '@fluentui/react-components/unstable'; +> +> ``` +> +> - Features and APIs may change before final release +> - Please contact us if you intend to use this in your product diff --git a/packages/react-components/react-virtualizer/stories/VirtualizerScrollViewDynamic/index.stories.ts b/packages/react-components/react-virtualizer/stories/VirtualizerScrollViewDynamic/index.stories.ts new file mode 100644 index 00000000000000..cf5ac78e1cf052 --- /dev/null +++ b/packages/react-components/react-virtualizer/stories/VirtualizerScrollViewDynamic/index.stories.ts @@ -0,0 +1,18 @@ +import { VirtualizerScrollViewDynamic } from '../../src/VirtualizerScrollViewDynamic'; +import descriptionMd from './VirtualizerScrollViewDynamicDescription.md'; + +export { Default } from './Default.stories'; +export { ScrollTo } from './ScrollTo.stories'; +export { ScrollLoading } from './ScrollLoading.stories'; + +export default { + title: 'Preview Components/VirtualizerScrollViewDynamic', + component: VirtualizerScrollViewDynamic, + parameters: { + docs: { + description: { + component: [descriptionMd].join('\n'), + }, + }, + }, +};