Skip to content

Commit

Permalink
Virtualizer - feat: Auto-measurement (microsoft#29868)
Browse files Browse the repository at this point in the history
* Store first concept of auto-measure

* Add back in suspense

* hook up sizing

* Connect end to end

* Add key and direct ref call

* change file

* Remove react.suspense from default as it hurts our reference measure

* Update API

* lint and update for parser

* remove total length

* Update change and use average size

* Only access bounding box once per call

* Address comments
  • Loading branch information
Mitch-At-Work authored Nov 20, 2023
1 parent f72acbf commit 4dddcd6
Show file tree
Hide file tree
Showing 9 changed files with 255 additions and 5 deletions.
Original file line number Diff line number Diff line change
@@ -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": "[email protected]",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ export const virtualizerScrollViewDynamicClassNames: SlotClassNames<VirtualizerS
// @public (undocumented)
export type VirtualizerScrollViewDynamicProps = ComponentProps<Partial<VirtualizerScrollViewDynamicSlots>> & Partial<Omit<VirtualizerConfigProps, 'itemSize' | 'numItems' | 'getItemSize' | 'children' | 'flagIndex'>> & {
itemSize: number;
getItemSize: (index: number) => number;
getItemSize?: (index: number) => number;
numItems: number;
children: VirtualizerChildRenderFunction;
imperativeRef?: RefObject<ScrollToInterface>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<number[]>;
/**
* Ref for access to internal progressive size knowledge, can be used to measure updates
*/
childProgressiveSizes: React.MutableRefObject<number[]>;
};

export type VirtualizerState = ComponentState<VirtualizerSlots> & VirtualizerConfigState;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -521,5 +520,7 @@ export function useVirtualizer_unstable(props: VirtualizerProps): VirtualizerSta
axis,
bufferSize,
reversed,
childSizes,
childProgressiveSizes,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ export type VirtualizerScrollViewDynamicProps = ComponentProps<Partial<Virtualiz
/**
* Callback for acquiring size of individual items
* @param index - the index of the requested size's child
* If undefined, Virtualizer will auto-measure by default (performance tradeoff)
*/
getItemSize: (index: number) => number;
getItemSize?: (index: number) => number;
/**
* The total number of items to be virtualized.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,35 @@ 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,
): VirtualizerScrollViewDynamicState {
const contextState = useVirtualizerContextState_unstable(props.virtualizerContext);
const { imperativeRef, axis = 'vertical', reversed, imperativeVirtualizerRef } = props;

let sizeTrackingArray = React.useRef<number[]>(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,
});
Expand Down Expand Up @@ -74,6 +92,7 @@ export function useVirtualizerScrollViewDynamic_unstable(

const virtualizerState = useVirtualizer_unstable({
...props,
getItemSize: props.getItemSize ?? getChildSizeAuto,
virtualizerLength,
bufferItems,
bufferSize,
Expand All @@ -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] = (
<child.type
{...child.props}
key={child.key}
ref={(element: HTMLElement & IndexedResizeCallbackElement) => {
// 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: {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Array<TElement | undefined | null>>([]);
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);
}
Original file line number Diff line number Diff line change
@@ -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<number>(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 (
<VirtualizerScrollViewDynamic
numItems={childLength}
// We can use itemSize to set an average height for minimal size change impact
itemSize={minHeight + maxHeightIncrease / 2}
container={{ role: 'list', style: { maxHeight: '100vh' } }}
>
{(index: number) => {
const backgroundColor = index % 2 ? '#FFFFFF' : '#ABABAB';
return (
<div
role={'listitem'}
aria-posinset={index}
aria-setsize={childLength}
key={`test-virtualizer-child-${index}`}
className={styles.child}
style={{ minHeight: arraySize.current[index], backgroundColor }}
>{`Node-${index} - size: ${arraySize.current[index]}`}</div>
);
}}
</VirtualizerScrollViewDynamic>
);
};
Original file line number Diff line number Diff line change
@@ -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';
Expand Down

0 comments on commit 4dddcd6

Please sign in to comment.