diff --git a/docs/integration-test-helpers/masonry/ExampleGridItem.tsx b/docs/integration-test-helpers/masonry/ExampleGridItem.tsx index 9e89277538..2bd3eb40f9 100644 --- a/docs/integration-test-helpers/masonry/ExampleGridItem.tsx +++ b/docs/integration-test-helpers/masonry/ExampleGridItem.tsx @@ -23,15 +23,12 @@ export default function ExampleGridItem({ data = {}, itemIdx, expanded }: Props) const isTwoColItem = data.columnSpan === 2; return ( -
+
>, {mountGrid && ( { const columnSpan = item.columnSpan as number | undefined; return columnSpan ?? 1; @@ -419,7 +421,7 @@ export default class MasonryContainer extends Component>, : undefined } columnWidth={columnWidth} - gutterWidth={0} + gutterWidth={14} items={items} layout={flexible ? 'flexible' : undefined} measurementStore={externalCache ? measurementStore : undefined} diff --git a/packages/gestalt/src/Masonry.tsx b/packages/gestalt/src/Masonry.tsx index 48b22d69b6..a7d9ef4a04 100644 --- a/packages/gestalt/src/Masonry.tsx +++ b/packages/gestalt/src/Masonry.tsx @@ -4,6 +4,7 @@ import FetchItems from './FetchItems'; import styles from './Masonry.css'; import { Cache } from './Masonry/Cache'; import recalcHeights from './Masonry/dynamicHeightsUtils'; +import recalcHeightsV2 from './Masonry/dynamicHeightsV2Utils'; import getLayoutAlgorithm from './Masonry/getLayoutAlgorithm'; import ItemResizeObserverWrapper from './Masonry/ItemResizeObserverWrapper'; import MeasurementStore from './Masonry/MeasurementStore'; @@ -148,6 +149,12 @@ type Props = { */ _dynamicHeights?: boolean; /** + * Experimental flag to enable an experiment to use a revamped version of dynamic heights (This needs _dynamicHeights enabled) + */ + _dynamicHeightsV2Experiment?: boolean; + /** + /** + * * Experimental prop to enable early bailout when positioning multicolumn modules * * This is an experimental prop and may be removed or changed in the future @@ -225,21 +232,34 @@ export default class Masonry extends ReactComponent, State> { const changedItem: T = this.state.items[idx]!; const newHeight = contentRect.height; + // TODO: DefaultGutter comes from getLayoutAlgorithm and their utils, everything should be in one place (this.gutter?) const { layout, gutterWidth } = this.props; let defaultGutter = 14; if ((layout && layout === 'flexible') || layout === 'serverRenderedFlexible') { defaultGutter = 0; } - triggerUpdate = - recalcHeights({ - items: this.state.items, - changedItem, - newHeight, - positionStore: this.positionStore, - measurementStore: this.state.measurementStore, - gutterWidth: gutterWidth ?? defaultGutter, - }) || triggerUpdate; + /* eslint-disable-next-line no-underscore-dangle */ + if (props._dynamicHeightsV2Experiment) { + triggerUpdate = + recalcHeightsV2({ + items: this.state.items, + changedItem, + newHeight, + positionStore: this.positionStore, + measurementStore: this.state.measurementStore, + gutterWidth: gutterWidth ?? defaultGutter, + }) || triggerUpdate; + } else { + triggerUpdate = + recalcHeights({ + items: this.state.items, + changedItem, + newHeight, + positionStore: this.positionStore, + measurementStore: this.state.measurementStore, + }) || triggerUpdate; + } } }); if (triggerUpdate) { diff --git a/packages/gestalt/src/Masonry/dynamicHeightsUtils.test.ts b/packages/gestalt/src/Masonry/dynamicHeightsUtils.test.ts index f0ce32e5b0..ea64cdebe9 100644 --- a/packages/gestalt/src/Masonry/dynamicHeightsUtils.test.ts +++ b/packages/gestalt/src/Masonry/dynamicHeightsUtils.test.ts @@ -1,5 +1,5 @@ import defaultLayout from './defaultLayout'; -import recalcHeights from './dynamicHeightsUtils'; +import recalcHeights from './dynamicHeightsV2Utils'; import MeasurementStore from './MeasurementStore'; import { Position } from './types'; diff --git a/packages/gestalt/src/Masonry/dynamicHeightsUtils.ts b/packages/gestalt/src/Masonry/dynamicHeightsUtils.ts index ea2511a6c2..238e81921a 100644 --- a/packages/gestalt/src/Masonry/dynamicHeightsUtils.ts +++ b/packages/gestalt/src/Masonry/dynamicHeightsUtils.ts @@ -8,89 +8,18 @@ function isBelowArea(area: { left: number; right: number }, position: Position) return position.left < area.right && position.left + position.width > area.left; } -function getColumnWidth(items: ReadonlyArray, positionStore: Cache): number { - let columnWidth = Infinity; - items.forEach((item) => { - const position = positionStore.get(item); - if (position) { - columnWidth = Math.min(columnWidth, position.width); - } - }); - return columnWidth; -} - -function getDelta( - deltasStack: Array<{ - left: number; - right: number; - delta: number; - }>, - position: Position, -): number { - for (let i = deltasStack.length - 1; i >= 0; i -= 1) { - const { left, right, delta } = deltasStack[i]!; - if (isBelowArea({ left, right }, position)) { - return delta; - } - } - - return 0; -} - -function getNewDelta({ - multicolumCurrentPosition, - allPreviousItems, - gutterWidth, -}: { - multicolumCurrentPosition: Position; - allPreviousItems: ReadonlyArray<{ item: T; position: Position }>; - gutterWidth: number; -}): number { - let closestItem: { item: T; position: Position }; - allPreviousItems.forEach(({ item, position }) => { - const multiColumnLeftLimit = multicolumCurrentPosition.left; - const multiColumnRightLimit = multicolumCurrentPosition.left + multicolumCurrentPosition.width; - const currentItemLeftLimit = position.left; - const currentItemRightLimit = position.left + position.width; - const itemIsAboveMulticolumn = - multiColumnLeftLimit <= currentItemLeftLimit && - multiColumnRightLimit >= currentItemRightLimit; - - if (itemIsAboveMulticolumn) { - if ( - (closestItem && - position.top + position.height > - closestItem!.position.top + closestItem!.position.height) || - !closestItem - ) { - closestItem = { item, position }; - } - } - - return itemIsAboveMulticolumn; - }); - const actualDelta = - closestItem!.position.top + - closestItem!.position.height - - multicolumCurrentPosition.top + - gutterWidth; - return actualDelta; -} - function recalcHeights({ items, changedItem, newHeight, positionStore, measurementStore, - gutterWidth, }: { items: ReadonlyArray; changedItem: T; newHeight: number; positionStore: Cache; measurementStore: Cache; - gutterWidth: number; }): boolean { const changedItemPosition = positionStore.get(changedItem); @@ -103,18 +32,9 @@ function recalcHeights({ } const { top, left, width, height } = changedItemPosition; - const oneColumnWidth = getColumnWidth(items.slice(0, 10), positionStore); // We don't need much items to know the column width - - // We use a stack in case we found multicolumn items that changes the deltas for their columns below - const deltasStack = [ - { - left, - right: left + width, - delta: newHeight - height, - }, - ]; + const heightDelta = newHeight - height; - const itemsFilteredAndSorted = items + items .map((item) => { const position = positionStore.get(item); return position && position.top >= changedItemPosition.top + changedItemPosition.height @@ -122,53 +42,24 @@ function recalcHeights({ : undefined; }) .filter((itemPosition) => !!itemPosition) - .sort((a, b) => a.position.top - b.position.top); + .sort((a, b) => a.position.top - b.position.top) + .reduce( + (area, { item, position }) => { + if (isBelowArea(area, position)) { + positionStore.set(item, { ...position, top: position.top + heightDelta }); + return { + left: Math.min(area.left, position.left), + right: Math.max(area.right, position.left + position.width), + }; + } + return area; + }, + { left, right: left + width } as { left: number; right: number }, + ); measurementStore.set(changedItem, newHeight); positionStore.set(changedItem, { top, left, width, height: newHeight }); - itemsFilteredAndSorted.reduce( - (area, { item, position }) => { - if (isBelowArea(area, position)) { - const itemIsMulticolumn = position.width > oneColumnWidth; - if (itemIsMulticolumn) { - const multicolumCurrentPosition = position; - - // Check all items above to check if movement is necessary - const allPreviousItems = items - .map((i) => { - const p = positionStore.get(i); - return p && p.top < multicolumCurrentPosition.top - ? { item: i, position: p } - : undefined; - }) - .filter((itemPosition) => !!itemPosition) - .sort((a, b) => a.position.top - b.position.top); - - const newDelta = getNewDelta({ - multicolumCurrentPosition, - allPreviousItems, - gutterWidth, - }); - deltasStack.push({ - left: position.left, - right: position.left + position.width, - delta: newDelta, - }); - } - - const currentDelta = getDelta(deltasStack, position); - positionStore.set(item, { ...position, top: position.top + currentDelta }); - return { - left: Math.min(area.left, position.left), - right: Math.max(area.right, position.left + position.width), - }; - } - return area; - }, - { left, right: left + width } as { left: number; right: number }, - ); - return true; } diff --git a/packages/gestalt/src/Masonry/dynamicHeightsV2Utils.ts b/packages/gestalt/src/Masonry/dynamicHeightsV2Utils.ts new file mode 100644 index 0000000000..7551be3d5f --- /dev/null +++ b/packages/gestalt/src/Masonry/dynamicHeightsV2Utils.ts @@ -0,0 +1,184 @@ +/** + * Util functions used to update positions when an item changes the height dynamically + */ +import { Cache } from './Cache'; +import { Position } from './types'; + +function isBelowArea(area: { left: number; right: number }, position: Position) { + return position.left < area.right && position.left + position.width > area.left; +} + +/* + * getColumnWidth + * This is a naive form of knowing the width of a column, so we can use it to know and item is + * multicolumn (has a bigger width than columnWidth). We can't use columnWidth prop because + * of flexible layouts (and that's an optional param)- + * TODO: We could standardize this by using _getColumnSpan, as in multicolumn modules + */ +function getColumnWidth(items: ReadonlyArray, positionStore: Cache): number { + let columnWidth = Infinity; + items.forEach((item) => { + const position = positionStore.get(item); + if (position) { + columnWidth = Math.min(columnWidth, position.width); + } + }); + return columnWidth; +} + +function getDelta( + deltasStack: Array<{ + left: number; + right: number; + delta: number; + }>, + position: Position, +): number { + for (let i = deltasStack.length - 1; i >= 0; i -= 1) { + const { left, right, delta } = deltasStack[i]!; + if (isBelowArea({ left, right }, position)) { + return delta; + } + } + + return 0; +} + +function getNewDelta({ + multicolumCurrentPosition, + allPreviousItems, + gutterWidth, +}: { + multicolumCurrentPosition: Position; + allPreviousItems: ReadonlyArray<{ item: T; position: Position }>; + gutterWidth: number; +}): number { + let closestItem: { item: T; position: Position }; + allPreviousItems.forEach(({ item, position }) => { + const multiColumnLeftLimit = multicolumCurrentPosition.left; + const multiColumnRightLimit = multicolumCurrentPosition.left + multicolumCurrentPosition.width; + const currentItemLeftLimit = position.left; + const currentItemRightLimit = position.left + position.width; + const itemIsAboveMulticolumn = + multiColumnLeftLimit <= currentItemLeftLimit && + multiColumnRightLimit >= currentItemRightLimit; + + if (itemIsAboveMulticolumn) { + if ( + (closestItem && + position.top + position.height > + closestItem!.position.top + closestItem!.position.height) || + !closestItem + ) { + closestItem = { item, position }; + } + } + + return itemIsAboveMulticolumn; + }); + const actualDelta = + closestItem!.position.top + + closestItem!.position.height - + multicolumCurrentPosition.top + + gutterWidth; + return actualDelta; +} + +function recalcHeights({ + items, + changedItem, + newHeight, + positionStore, + measurementStore, + gutterWidth, +}: { + items: ReadonlyArray; + changedItem: T; + newHeight: number; + positionStore: Cache; + measurementStore: Cache; + gutterWidth: number; +}): boolean { + const changedItemPosition = positionStore.get(changedItem); + + if ( + !changedItemPosition || + newHeight === 0 || + Math.floor(changedItemPosition.height) === Math.floor(newHeight) + ) { + return false; + } + + const { top, left, width, height } = changedItemPosition; + const oneColumnWidth = getColumnWidth(items.slice(0, 10), positionStore); // We don't need much items to know the column width + + // We use a stack in case we found multicolumn items that changes the deltas for their columns below + const deltasStack = [ + { + left, + right: left + width, + delta: newHeight - height, + }, + ]; + + const itemsFilteredAndSorted = items + .map((item) => { + const position = positionStore.get(item); + return position && position.top >= changedItemPosition.top + changedItemPosition.height + ? { item, position } + : undefined; + }) + .filter((itemPosition) => !!itemPosition) + .sort((a, b) => a.position.top - b.position.top); + + measurementStore.set(changedItem, newHeight); + positionStore.set(changedItem, { top, left, width, height: newHeight }); + + itemsFilteredAndSorted.reduce( + (area, { item, position }) => { + if (isBelowArea(area, position)) { + const itemIsMulticolumn = position.width > oneColumnWidth; + if (itemIsMulticolumn) { + // If it's a multicolumn module, we don't always use the same delta, because items above + // can limit the movement of the multicolumn module. We need to find the correct delta. + const multicolumCurrentPosition = position; + + // Check all items above to check if movement is necessary + const allPreviousItems = items + .map((i) => { + const p = positionStore.get(i); + return p && p.top < multicolumCurrentPosition.top + ? { item: i, position: p } + : undefined; + }) + .filter((itemPosition) => !!itemPosition) + .sort((a, b) => a.position.top - b.position.top); + + const newDelta = getNewDelta({ + multicolumCurrentPosition, + allPreviousItems, + gutterWidth, + }); + deltasStack.push({ + left: position.left, + right: position.left + position.width, + delta: newDelta, + }); + } + + const currentDelta = getDelta(deltasStack, position); + positionStore.set(item, { ...position, top: position.top + currentDelta }); + return { + left: Math.min(area.left, position.left), + right: Math.max(area.right, position.left + position.width), + }; + } + return area; + }, + { left, right: left + width } as { left: number; right: number }, + ); + + return true; +} + +export default recalcHeights; diff --git a/packages/gestalt/src/MasonryV2.tsx b/packages/gestalt/src/MasonryV2.tsx index 599ff92317..ad9fb5d11e 100644 --- a/packages/gestalt/src/MasonryV2.tsx +++ b/packages/gestalt/src/MasonryV2.tsx @@ -16,6 +16,7 @@ import debounce from './debounce'; import styles from './Masonry.css'; import { Cache } from './Masonry/Cache'; import recalcHeights from './Masonry/dynamicHeightsUtils'; +import recalcHeightsV2 from './Masonry/dynamicHeightsV2Utils'; import getLayoutAlgorithm from './Masonry/getLayoutAlgorithm'; import ItemResizeObserverWrapper from './Masonry/ItemResizeObserverWrapper'; import MeasurementStore from './Masonry/MeasurementStore'; @@ -161,6 +162,10 @@ type Props = { * Experimental flag to enable dynamic heights on items. This only works if multi column items are enabled. */ _dynamicHeights?: boolean; + /** + * Experimental flag to enable an experiment to use a revamped version of dynamic heights (This needs _dynamicHeights enabled) + */ + _dynamicHeightsV2Experiment?: boolean; /** * Experimental prop to enable early bailout when positioning multicolumn modules * @@ -703,6 +708,7 @@ function Masonry( _useRAF, _getColumnSpanConfig, _dynamicHeights, + _dynamicHeightsV2Experiment, _loadingStateItems = [], _renderLoadingStateItems, _earlyBailout, @@ -800,15 +806,26 @@ function Masonry( defaultGutter = 0; } - triggerUpdate = - recalcHeights({ - items, - changedItem, - newHeight, - positionStore, - measurementStore, - gutterWidth: gutter ?? defaultGutter, - }) || triggerUpdate; + if (_dynamicHeightsV2Experiment) { + triggerUpdate = + recalcHeightsV2({ + items, + changedItem, + newHeight, + positionStore, + measurementStore, + gutterWidth: gutter ?? defaultGutter, + }) || triggerUpdate; + } else { + triggerUpdate = + recalcHeights({ + items, + changedItem, + newHeight, + positionStore, + measurementStore, + }) || triggerUpdate; + } } }); if (triggerUpdate) { @@ -816,7 +833,15 @@ function Masonry( } }) : undefined, - [_dynamicHeights, items, measurementStore, positionStore, gutter, layout], + [ + _dynamicHeights, + _dynamicHeightsV2Experiment, + items, + measurementStore, + positionStore, + gutter, + layout, + ], ); const { hasPendingMeasurements, height, positions, renderLoadingState, updateMeasurement } =