react-native-draggable-masonry-grid
Version:
React native flatlist based implementation of masonary grid with drag/drop functionality
296 lines (271 loc) • 9.09 kB
text/typescript
import { useRef, useState, type RefObject } from 'react';
import { type MeasureOnSuccessCallback, View } from 'react-native';
import type {
Coordinates,
DraggableItem,
DraggableMasonryGridListData,
Vertices,
} from './types';
export function asyncWaitForMS(waitForMS: number) {
return new Promise((res) => setTimeout(res, waitForMS));
}
/**
* @param min
* @param max
* @returns {number} random integer between min(inclusive) and max(inclusive)
*/
export const generateRandomInteger = (min: number, max: number) => {
return Math.floor(Math.random() * (max - min + 1)) + min;
};
/**
*
* @param array
* @param index
* @returns new array with item at added at given index
*/
export function arrayAddItemAtIndex<T>(array: T[], index: number, item: T) {
return [...array.slice(0, index), item, ...array.slice(index)];
}
/**
*
* @param array
* @param index
* @returns new array with item removed at given index
*/
export function arrayRemoveItemAtIndex<T>(array: T[], index: number) {
return [...array.slice(0, index), ...array.slice(index + 1)];
}
/**
*
* @param array
* @param indexFrom
* @param indexTo
* @returns new array with item that was in indexFrom moved to indexTo
*/
export function arrayMoveFromIndexToIndex<T>(
array: DraggableItem<T>[],
indexFrom: number,
indexTo: number
): DraggableItem<T>[] {
if (indexFrom < indexTo && array[indexFrom]) {
return [
...array.slice(0, indexFrom),
...array.slice(indexFrom + 1, indexTo + 1),
array[indexFrom],
...array.slice(indexTo + 1),
];
} else if (indexFrom > indexTo && array[indexFrom]) {
return [
...array.slice(0, indexTo),
array[indexFrom],
...array.slice(indexTo, indexFrom),
...array.slice(indexFrom + 1),
];
} else {
return [...array];
}
}
export function getGridColumns<T>(
data: DraggableItem<T>[],
numberOfColumns: number,
columnWidth: number
) {
const columnHeightsTracker: number[] = new Array(numberOfColumns);
const gridDataInSequence: DraggableMasonryGridListData<T>[] = new Array(
data.length
);
const columnsForGrid: DraggableMasonryGridListData<T>[][] = new Array(
numberOfColumns
);
for (let i = 0; i < numberOfColumns; i++) {
columnHeightsTracker[i] = 0;
columnsForGrid[i] = [];
}
if (!data.length) {
return {
columnsForGrid,
gridDataInSequence,
columnHeights: columnHeightsTracker,
};
}
data.forEach((item, index) => {
if (item.type === 'HEIGHT_EQUILIZER') {
// adds empty space to compensate for unaligned extra height of last columns item
const maxColumnHeight = Math.max(...columnHeightsTracker);
for (let i = 0; i < columnHeightsTracker.length; i++) {
const currentColumnHeight = columnHeightsTracker[i];
const height = maxColumnHeight - (currentColumnHeight ?? 0);
const data: DraggableMasonryGridListData<T> = {
columnIndex: i,
height,
isDraggable: false,
index: columnsForGrid[i]?.length ?? 0,
offsetY: columnHeightsTracker[i] ?? 0,
originalIndex: index,
position: { x: i * columnWidth, y: columnHeightsTracker[i] ?? 0 },
type: 'empty_space',
};
columnsForGrid[i]?.push(data);
gridDataInSequence[index] = data;
columnHeightsTracker[i] = (columnHeightsTracker[i] ?? 0) + height;
}
} else {
const minColumnHeight = Math.min(...columnHeightsTracker);
// add to earliest column if height with 10px of minColumnHeight
const columnIndexToAddTo = columnHeightsTracker.findIndex(
(height) => height < minColumnHeight + 10
);
const data: DraggableMasonryGridListData<T> = {
columnIndex: columnIndexToAddTo,
height: Math.round(item.height),
isDraggable: item.isDraggable,
index: columnsForGrid[columnIndexToAddTo]?.length ?? 0,
item: item.item,
offsetY: columnHeightsTracker[columnIndexToAddTo] ?? 0,
verticeoffsets: item.verticeoffsets,
originalIndex: index,
position: {
x: columnIndexToAddTo * columnWidth,
y: columnHeightsTracker[columnIndexToAddTo] ?? 0,
},
type: 'item',
};
columnsForGrid[columnIndexToAddTo]?.push(data);
gridDataInSequence[index] = data;
columnHeightsTracker[columnIndexToAddTo] =
(columnHeightsTracker[columnIndexToAddTo] ?? 0) + data.height;
}
});
return {
columnsForGrid,
gridDataInSequence,
columnHeights: columnHeightsTracker,
};
}
export function getRawDataFromGridColumns<T>(
columnsForGrid: DraggableMasonryGridListData<T>[][],
columnHeights: number[]
) {
const totalItems = columnsForGrid.reduce(
(length, column) => (length += column.length),
0
);
const rawDataInSequence: DraggableItem<T>[] = new Array(totalItems);
const columnHeightsTracker: number[] = new Array(...columnHeights);
const columnIndexTrackers: number[] = columnsForGrid.map(
(column) => column.length - 1
);
let lastItem: DraggableMasonryGridListData<T> | undefined = undefined;
for (let i = totalItems - 1; i >= 0; i--) {
const maxColumnHeight = Math.max(...columnHeightsTracker);
const columnIndexToExtractFrom = columnHeightsTracker.findIndex(
(height) => height === maxColumnHeight
);
// extract last item in the column
const item =
columnsForGrid[columnIndexToExtractFrom]?.[
columnIndexTrackers[columnIndexToExtractFrom] ?? 0
];
columnHeightsTracker[columnIndexToExtractFrom] =
(columnHeightsTracker[columnIndexToExtractFrom] ?? 0) -
(item?.height ?? 0);
columnIndexTrackers[columnIndexToExtractFrom] =
(columnIndexTrackers[columnIndexToExtractFrom] ?? 0) - 1;
if (item?.type === 'empty_space') {
if (lastItem?.type !== 'empty_space') {
rawDataInSequence[i] = {
type: 'HEIGHT_EQUILIZER',
};
}
} else if (item) {
rawDataInSequence[i] = {
height: item.height,
isDraggable: item.isDraggable,
item: item.item,
type: 'ITEM',
};
}
lastItem = item;
}
return { rawDataInSequence: rawDataInSequence.filter(Boolean) };
}
export const checkIfContainsCoordinates = (
coordinates: Coordinates,
vertices: Vertices
) =>
coordinates.x > vertices.topLeft.x &&
coordinates.y > vertices.topLeft.y &&
coordinates.x < vertices.bottomRight.x &&
coordinates.y < vertices.bottomRight.y;
export const checkForOverlap = (verticesA: Vertices, verticesB: Vertices) =>
checkIfContainsCoordinates(verticesA.center, verticesB) ||
checkIfContainsCoordinates(verticesB.center, verticesA);
export const getDistanceBetweenVertices = (
coordinatesA: Coordinates,
coordinatesB: Coordinates
) =>
Math.sqrt(
Math.pow(coordinatesA.x - coordinatesB.x, 2) +
Math.pow(coordinatesA.y - coordinatesB.y, 2)
);
const getViewMeasurements = (cardRef: RefObject<View | null> | undefined) =>
new Promise<Parameters<MeasureOnSuccessCallback>>((res) =>
cardRef?.current?.measure((...values) => res(values))
);
export const getViewVertices = async (
cardRef: RefObject<View | null> | undefined,
options: { scale?: number; offsets?: Partial<Vertices> } = {}
): Promise<Vertices> => {
const { scale = 1, offsets } = options;
const cornerScale = (scale - 1) / 2 + 1;
const [_x, _y, width, height, pageX, pageY] =
await getViewMeasurements(cardRef);
return {
topCenter: {
x: Math.round(pageX + width / 2 + (offsets?.center?.x ?? 0)),
y: Math.round(pageY * cornerScale + (offsets?.topLeft?.y ?? 0)),
},
topLeft: {
x: Math.round(pageX * cornerScale + (offsets?.topLeft?.x ?? 0)),
y: Math.round(pageY * cornerScale + (offsets?.topLeft?.y ?? 0)),
},
topRight: {
x: Math.round(
(pageX + width) / cornerScale + (offsets?.topRight?.x ?? 0)
),
y: Math.round(pageY * cornerScale + (offsets?.topRight?.y ?? 0)),
},
bottomCenter: {
x: Math.round(pageX + width / 2 + (offsets?.center?.x ?? 0)),
y: Math.round(
(pageY + height) / cornerScale + (offsets?.bottomRight?.y ?? 0)
),
},
bottomRight: {
x: Math.round(
(pageX + width) / cornerScale + (offsets?.bottomRight?.x ?? 0)
),
y: Math.round(
(pageY + height) / cornerScale + (offsets?.bottomRight?.y ?? 0)
),
},
bottomLeft: {
x: Math.round(pageX * cornerScale + (offsets?.bottomLeft?.x ?? 0)),
y: Math.round(
(pageY + height) / cornerScale + (offsets?.bottomLeft?.y ?? 0)
),
},
center: {
x: Math.round(pageX + width / 2 + (offsets?.center?.x ?? 0)),
y: Math.round(pageY + height / 2 + (offsets?.center?.y ?? 0)),
},
};
};
export function useStateRef<T>(
initialValue: T
): [RefObject<T>, React.Dispatch<React.SetStateAction<T>>] {
const [state, setState] = useState(initialValue);
const stateRef = useRef(initialValue);
stateRef.current = state;
return [stateRef, setState];
}