diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index cdb2a1ecf04..6dd2ef7b926 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -215,7 +215,7 @@ export class S2TableLayout extends TableLayout { // we want the body to be sticky and only as wide as the table so it is always in view if loading/empty let isEmptyOrLoading = this.virtualizer?.collection.size === 0; if (isEmptyOrLoading) { - layoutInfo.rect.width = this.virtualizer!.visibleRect.width - 80; + layoutInfo.rect.width = this.virtualizer!.size.width - 80; } return [ @@ -228,7 +228,7 @@ export class S2TableLayout extends TableLayout { let layoutNode = super.buildLoader(node, x, y); let {layoutInfo} = layoutNode; layoutInfo.allowOverflow = true; - layoutInfo.rect.width = this.virtualizer!.visibleRect.width; + layoutInfo.rect.width = this.virtualizer!.size.width; // If performing first load or empty, the body will be sticky so we don't want to apply sticky to the loader, otherwise it will // affect the positioning of the empty state renderer let collection = this.virtualizer!.collection; @@ -246,7 +246,7 @@ export class S2TableLayout extends TableLayout { // If loading or empty, we'll want the body to be sticky and centered let isEmptyOrLoading = this.virtualizer?.collection.size === 0; if (isEmptyOrLoading) { - layoutInfo.rect = new Rect(40, 40, this.virtualizer!.visibleRect.width - 80, this.virtualizer!.visibleRect.height - 80); + layoutInfo.rect = new Rect(40, 40, this.virtualizer!.size.width - 80, this.virtualizer!.size.height - 80); layoutInfo.isSticky = true; } diff --git a/packages/react-aria-components/src/Virtualizer.tsx b/packages/react-aria-components/src/Virtualizer.tsx index 4410d8785fe..262daaa0c90 100644 --- a/packages/react-aria-components/src/Virtualizer.tsx +++ b/packages/react-aria-components/src/Virtualizer.tsx @@ -75,6 +75,7 @@ function CollectionRoot({collection, persistedKeys, scrollRef, renderDropIndicat let {layout, layoutOptions} = useContext(LayoutContext)!; let layoutOptions2 = layout.useLayoutOptions?.(); let state = useVirtualizerState({ + allowsWindowScrolling: true, layout, collection, renderView: (type, item) => { @@ -98,9 +99,11 @@ function CollectionRoot({collection, persistedKeys, scrollRef, renderDropIndicat let {contentProps} = useScrollView({ onVisibleRectChange: state.setVisibleRect, + onSizeChange: state.setSize, contentSize: state.contentSize, onScrollStart: state.startScrolling, - onScrollEnd: state.endScrolling + onScrollEnd: state.endScrolling, + allowsWindowScrolling: true }, scrollRef!); return ( diff --git a/packages/react-aria/src/virtualizer/ScrollView.tsx b/packages/react-aria/src/virtualizer/ScrollView.tsx index 975858679d8..462bbec1f8e 100644 --- a/packages/react-aria/src/virtualizer/ScrollView.tsx +++ b/packages/react-aria/src/virtualizer/ScrollView.tsx @@ -35,12 +35,14 @@ import {useResizeObserver} from '../utils/useResizeObserver'; interface ScrollViewProps extends Omit, 'onScroll'> { contentSize: Size, onVisibleRectChange: (rect: Rect) => void, + onSizeChange?: (size: Size) => void, children?: ReactNode, innerStyle?: CSSProperties, onScrollStart?: () => void, onScrollEnd?: () => void, scrollDirection?: 'horizontal' | 'vertical' | 'both', - onScroll?: (e: Event) => void + onScroll?: (e: Event) => void, + allowsWindowScrolling?: boolean } function ScrollView(props: ScrollViewProps, ref: ForwardedRef) { @@ -71,11 +73,13 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject { updateVisibleRect(); + onSizeChange?.(state.size); }); // If the clientWidth or clientHeight changed, scrollbars appeared or disappeared as @@ -243,12 +250,13 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject { updateVisibleRect(); + onSizeChange?.(state.size); }); } } isUpdatingSize.current = false; - }, [ref, state, updateVisibleRect]); + }, [ref, state, updateVisibleRect, onSizeChange]); let updateSizeEvent = useEffectEvent(updateSize); // Track the size of the entire window viewport, which is used to bound the size of the virtualizer's visible rectangle. diff --git a/packages/react-stately/src/layout/GridLayout.ts b/packages/react-stately/src/layout/GridLayout.ts index 6d06cf891b4..be2406c7238 100644 --- a/packages/react-stately/src/layout/GridLayout.ts +++ b/packages/react-stately/src/layout/GridLayout.ts @@ -112,22 +112,22 @@ export class GridLayout exte } = invalidationContext.layoutOptions || {}; this.dropIndicatorThickness = dropIndicatorThickness; - let visibleWidth = this.virtualizer!.visibleRect.width; + let virtualizerWidth = this.virtualizer!.size.width; // The max item width is always the entire viewport. // If the max item height is infinity, scale in proportion to the max width. - let maxItemWidth = Math.min(maxItemSize.width, visibleWidth); + let maxItemWidth = Math.min(maxItemSize.width, virtualizerWidth); let maxItemHeight = Number.isFinite(maxItemSize.height) ? maxItemSize.height : Math.floor((minItemSize.height / minItemSize.width) * maxItemWidth); // Compute the number of rows and columns needed to display the content - let columns = Math.floor(visibleWidth / (minItemSize.width + minSpace.width)); + let columns = Math.floor(virtualizerWidth / (minItemSize.width + minSpace.width)); let numColumns = Math.max(1, Math.min(maxColumns, columns)); this.numColumns = numColumns; // Compute the available width (minus the space between items) - let width = visibleWidth - (minSpace.width * Math.max(0, numColumns)); + let width = virtualizerWidth - (minSpace.width * Math.max(0, numColumns)); // Compute the item width based on the space available let itemWidth = Math.floor(width / numColumns); @@ -139,9 +139,9 @@ export class GridLayout exte itemHeight = Math.max(minItemSize.height, Math.min(maxItemHeight, itemHeight)); // Compute the horizontal spacing, content height and horizontal margin - let horizontalSpacing = Math.min(Math.max(maxHorizontalSpace, minSpace.width), Math.floor((visibleWidth - numColumns * itemWidth) / (numColumns + 1))); + let horizontalSpacing = Math.min(Math.max(maxHorizontalSpace, minSpace.width), Math.floor((virtualizerWidth - numColumns * itemWidth) / (numColumns + 1))); this.gap = new Size(horizontalSpacing, minSpace.height); - this.margin = Math.floor((visibleWidth - numColumns * itemWidth - horizontalSpacing * (numColumns + 1)) / 2); + this.margin = Math.floor((virtualizerWidth - numColumns * itemWidth - horizontalSpacing * (numColumns + 1)) / 2); // If there is a skeleton loader within the last 2 items in the collection, increment the collection size // so that an additional row is added for the skeletons. @@ -214,7 +214,7 @@ export class GridLayout exte y += maxHeight + minSpace.height; // Keep adding skeleton rows until we fill the viewport - if (skeleton && row === rows - 1 && y < this.virtualizer!.visibleRect.height) { + if (skeleton && row === rows - 1 && y < this.virtualizer!.size.height) { rows++; } } @@ -225,7 +225,7 @@ export class GridLayout exte if (skeletonCount > 0 || !lastNode.props.isLoading) { loaderHeight = 0; } - const loaderWidth = visibleWidth - horizontalSpacing * 2; + const loaderWidth = virtualizerWidth - horizontalSpacing * 2; // Note that if the user provides isLoading to their sentinel during a case where they only want to render the emptyState, this will reserve // room for the loader alongside rendering the emptyState let rect = new Rect(horizontalSpacing, y, loaderWidth, loaderHeight); @@ -235,7 +235,7 @@ export class GridLayout exte } this.layoutInfos = newLayoutInfos; - this.contentSize = new Size(this.virtualizer!.visibleRect.width, y); + this.contentSize = new Size(this.virtualizer!.size.width, y); } getLayoutInfo(key: Key): LayoutInfo | null { diff --git a/packages/react-stately/src/layout/ListLayout.ts b/packages/react-stately/src/layout/ListLayout.ts index 9f451f6084c..31a2416651e 100644 --- a/packages/react-stately/src/layout/ListLayout.ts +++ b/packages/react-stately/src/layout/ListLayout.ts @@ -376,7 +376,7 @@ export class ListLayout exte offset = Math.max(offset - this.gap, 0); offset += isEmptyOrLoading ? 0 : this.padding; - this.contentSize = this.orientation === 'horizontal' ? new Size(offset, this.virtualizer!.visibleRect.height) : new Size(this.virtualizer!.visibleRect.width, offset); + this.contentSize = this.orientation === 'horizontal' ? new Size(offset, this.virtualizer!.size.height) : new Size(this.virtualizer!.size.width, offset); return nodes; } @@ -445,8 +445,8 @@ export class ListLayout exte protected buildSection(node: Node, x: number, y: number): LayoutNode { let collection = this.virtualizer!.collection; - let width = this.virtualizer!.visibleRect.width - this.padding - x; - let height = this.virtualizer!.visibleRect.height - this.padding - y; + let width = this.virtualizer!.size.width - this.padding - x; + let height = this.virtualizer!.size.height - this.padding - y; let rect = this.orientation === 'horizontal' ? new Rect(x, y, 0, height) : new Rect(x, y, width, 0); let layoutInfo = new LayoutInfo(node.type, node.key, rect); @@ -497,7 +497,7 @@ export class ListLayout exte protected buildSectionHeader(node: Node, x: number, y: number): LayoutNode { let widthProperty = this.orientation === 'horizontal' ? 'height' : 'width'; let heightProperty = this.orientation === 'horizontal' ? 'width' : 'height'; - let width = this.virtualizer!.visibleRect[widthProperty] - this.padding - (this.orientation === 'horizontal' ? y : x); + let width = this.virtualizer!.size[widthProperty] - this.padding - (this.orientation === 'horizontal' ? y : x); let rectHeight = this.headingSize; let isEstimated = false; @@ -538,7 +538,7 @@ export class ListLayout exte let widthProperty = this.orientation === 'horizontal' ? 'height' : 'width'; let heightProperty = this.orientation === 'horizontal' ? 'width' : 'height'; - let width = this.virtualizer!.visibleRect[widthProperty] - this.padding - (this.orientation === 'horizontal' ? y : x); + let width = this.virtualizer!.size[widthProperty] - this.padding - (this.orientation === 'horizontal' ? y : x); let rectHeight = this.rowSize; let isEstimated = false; diff --git a/packages/react-stately/src/layout/TableLayout.ts b/packages/react-stately/src/layout/TableLayout.ts index f6b72220116..a9fc643b8ad 100644 --- a/packages/react-stately/src/layout/TableLayout.ts +++ b/packages/react-stately/src/layout/TableLayout.ts @@ -124,7 +124,7 @@ export class TableLayout exten } } else if (invalidationContext.sizeChanged || this.columnsChanged(newCollection, this.lastCollection)) { let columnLayout = new TableColumnLayout({}); - this.columnWidths = columnLayout.buildColumnWidths(this.virtualizer!.visibleRect.width - this.padding * 2, newCollection, new Map()); + this.columnWidths = columnLayout.buildColumnWidths(this.virtualizer!.size.width - this.padding * 2, newCollection, new Map()); invalidationContext.sizeChanged = true; } @@ -345,7 +345,7 @@ export class TableLayout exten // Make sure that the table body gets a height if empty or performing initial load let isEmptyOrLoading = collection?.size === 0; if (isEmptyOrLoading) { - y = this.virtualizer!.visibleRect.maxY; + y = this.virtualizer!.size.height; } else { y -= this.gap; } diff --git a/packages/react-stately/src/layout/WaterfallLayout.ts b/packages/react-stately/src/layout/WaterfallLayout.ts index d1f98b56e7a..f6a389f6592 100644 --- a/packages/react-stately/src/layout/WaterfallLayout.ts +++ b/packages/react-stately/src/layout/WaterfallLayout.ts @@ -107,21 +107,21 @@ export class WaterfallLayout h !== startingHeights[i]) || - Math.min(...columnHeights) < this.virtualizer!.visibleRect.height + Math.min(...columnHeights) < this.virtualizer!.size.height ) { let key = `${node.key}-${skeletonCount++}`; let content = this.layoutInfos.get(key)?.content || {...node}; @@ -200,7 +200,7 @@ export class WaterfallLayout 0 || !lastNode.props.isLoading) { loaderHeight = 0; } - const loaderWidth = visibleWidth - horizontalSpacing * 2; + const loaderWidth = virtualizerWidth - horizontalSpacing * 2; // Note that if the user provides isLoading to their sentinel during a case where they only want to render the emptyState, this will reserve // room for the loader alongside rendering the emptyState let rect = new Rect(horizontalSpacing, maxHeight, loaderWidth, loaderHeight); @@ -209,7 +209,7 @@ export class WaterfallLayout { readonly contentSize: Size; /** The currently visible rectangle. */ readonly visibleRect: Rect; + /** The size of the virtualizer scroll view. */ + readonly size: Size; /** The set of persisted keys that are always present in the DOM, even if not currently in view. */ readonly persistedKeys: Set; @@ -74,6 +76,7 @@ export class Virtualizer { this.layout = options.layout; this.contentSize = new Size; this.visibleRect = new Rect; + this.size = new Size; this.persistedKeys = new Set(); this._visibleViews = new Map(); this._renderedContent = new WeakMap(); @@ -288,19 +291,25 @@ export class Virtualizer { needsUpdate = true; } - if (!this.visibleRect.equals(opts.visibleRect)) { + if (!this.visibleRect.equals(opts.visibleRect) || !this.size.equals(opts.size)) { this._overscanManager.setVisibleRect(opts.visibleRect); - let shouldInvalidate = this.layout.shouldInvalidate(opts.visibleRect, this.visibleRect); + + // Create a rectangle using the scroll position and layout size of the scroll view. This is not the same + // as the visibleRect, whose width and height may change during window scrolling. + let oldRect = new Rect(this.visibleRect.x, this.visibleRect.y, this.size.width, this.size.height); + let newRect = new Rect(opts.visibleRect.x, opts.visibleRect.y, opts.size.width, opts.size.height); + let shouldInvalidate = this.layout.shouldInvalidate(newRect, oldRect); if (shouldInvalidate) { offsetChanged = !opts.visibleRect.pointEquals(this.visibleRect); - sizeChanged = !opts.visibleRect.sizeEquals(this.visibleRect); + sizeChanged = !this.size.equals(opts.size); needsLayout = true; } else { needsUpdate = true; } mutableThis.visibleRect = opts.visibleRect; + mutableThis.size = opts.size; } if (opts.invalidationContext !== this._invalidationContext) { diff --git a/packages/react-stately/src/virtualizer/types.ts b/packages/react-stately/src/virtualizer/types.ts index 6615b582531..b7e0adc3baa 100644 --- a/packages/react-stately/src/virtualizer/types.ts +++ b/packages/react-stately/src/virtualizer/types.ts @@ -13,6 +13,7 @@ import {Collection, Key} from '@react-types/shared'; import {Layout} from './Layout'; import {Rect} from './Rect'; +import {Size} from './Size'; export interface InvalidationContext { contentChanged?: boolean, @@ -34,6 +35,7 @@ export interface VirtualizerRenderOptions { collection: Collection, persistedKeys?: Set | null, visibleRect: Rect, + size: Size, invalidationContext: InvalidationContext, isScrolling: boolean, layoutOptions?: O diff --git a/packages/react-stately/src/virtualizer/useVirtualizerState.ts b/packages/react-stately/src/virtualizer/useVirtualizerState.ts index dfa5181991e..2a65125339a 100644 --- a/packages/react-stately/src/virtualizer/useVirtualizerState.ts +++ b/packages/react-stately/src/virtualizer/useVirtualizerState.ts @@ -32,12 +32,15 @@ interface VirtualizerProps { collection: Collection, onVisibleRectChange(rect: Rect): void, persistedKeys?: Set | null, - layoutOptions?: O + layoutOptions?: O, + allowsWindowScrolling?: boolean } export interface VirtualizerState { visibleViews: ReusableView[], setVisibleRect: (rect: Rect) => void, + size: Size, + setSize: (size: Size) => void, contentSize: Size, virtualizer: Virtualizer, isScrolling: boolean, @@ -47,6 +50,7 @@ export interface VirtualizerState { export function useVirtualizerState(opts: VirtualizerProps): VirtualizerState { let [visibleRect, setVisibleRect] = useState(new Rect(0, 0, 0, 0)); + let [size, setSize] = useState(new Size()); let [isScrolling, setScrolling] = useState(false); let [invalidationContext, setInvalidationContext] = useState({}); let visibleRectChanged = useRef(false); @@ -85,6 +89,7 @@ export function useVirtualizerState(opts: Virtuali persistedKeys: opts.persistedKeys, layoutOptions: opts.layoutOptions, visibleRect, + size: opts.allowsWindowScrolling ? size : visibleRect, invalidationContext: mergedInvalidationContext, isScrolling }); @@ -102,6 +107,8 @@ export function useVirtualizerState(opts: Virtuali virtualizer, visibleViews, setVisibleRect, + size, + setSize, contentSize, isScrolling, startScrolling, @@ -110,6 +117,8 @@ export function useVirtualizerState(opts: Virtuali virtualizer, visibleViews, setVisibleRect, + size, + setSize, contentSize, isScrolling, startScrolling,