React-grid-layout 是一个基于 React 的网格布局库,它可以帮助我们轻松地在网格中管理和排列组件。它提供了一个直观的 API,可以通过配置网格的大小、列数和组件的位置来实现复杂的布局,还支持对网格中组件的拖拽和重新排列。
绘制:编排之后,可以根据配置对页面进行正确的绘制
拖拽:支持位置调整,拖拽之后,调整对应配置参数
缩放:支持大小调整,缩放之后,调整对应配置参数
自动调整:当配置有错误、拖拽时有碰撞时,进行自动调整;布局空间的自动优化
示例:
https://react-grid-layout.github.io/react-grid-layout/examples/0-showcase.html
const layout = [
{ i: 'a', x: 0, y: 1, w: 1, h: 1, static: true },
{ i: 'b', x: 1, y: 0, w: 3, h: 2 },
{ i: 'c', x: 4, y: 0, w: 1, h: 2 },
];
{layout.map((item) => (
{item.i}
xy{item.x}/{item.y}
wh{item.w}/{item.h}
))}
高度计算
const containerHeight = () => {
// autoSize的情况下,不计算容器高度
if (!this.props.autoSize) { return; }
// 底部坐标, 获取布局中y+h的最大值,即为容器的最大坐标h值
const nbRow = bottom(this.state.layout);
const containerPaddingY = this.props.containerPadding ? this.props.containerPadding[1] : this.props.margin[1];
// 计算包含margin/padding的真实高度
return ${nbRow * this.props.rowHeight + (nbRow - 1) * this.props.margin[1] + containerPaddingY * 2}px;
}
compact布局
如果直接安装上述的计算方式绘制,可能由于配置的问题,会带来页面的浪费。因此react-grid-layout会默认对纵向空间进行压缩,以避免空间的浪费,也支持设置为横向压缩,或者不压缩。在压缩过程中,很关键的一部分在于对模块间碰撞的处理。
function compact(layout, compactType, cols) {
// 对静态模块不进行压缩
const compareWith = getStatics(layout);
// 排序 horizontal | vertical
const sorted = sortLayoutItems(layout, compactType);
const out = Array(layout.length);
for (let i = 0, len = sorted.length; i < len; i++) {
let l = cloneLayoutItem(sorted[i]);
if (!l.static) {
l = compactItem(compareWith, l, compactType, cols, sorted);
compareWith.push(l);
}
// 放回正确的位置
out[layout.indexOf(sorted[i])] = l;
// 在处理冲突的时候设置flag
l.moved = false;
}
return out;
}
// 压缩方法
function compactItem(
compareWith: Layout,
l: LayoutItem,
compactType: CompactType,
cols: number,
fullLayout: Layout
): LayoutItem {
const compactV = compactType === "vertical";
const compactH = compactType === "horizontal";
if (compactV) {
// 垂直方向压缩,静态元素所需容器的高度 与 当前元素的纵向起始位置
l.y = Math.min(bottom(compareWith), l.y);
while (l.y > 0 && !getFirstCollision(compareWith, l)) {
l.y--;
}
} else if (compactH) {
while (l.x > 0 && !getFirstCollision(compareWith, l)) {
l.x--;
}
}
// 处理碰撞
let collides;
while ((collides = getFirstCollision(compareWith, l))) {
if (compactH) {
resolveCompactionCollision(fullLayout, l, collides.x + collides.w, "x");
} else {
resolveCompactionCollision(fullLayout, l, collides.y + collides.h, "y");
}
// 水平方向不超过最大值
if (compactH && l.x + l.w > cols) {
l.x = cols - l.w;
l.y++;
}
}
// 对上述的y--,x--做容错处理,确保没有负值
l.y = Math.max(l.y, 0);
l.x = Math.max(l.x, 0);
return l;
}
function getFirstCollision(
layout: Layout,
layoutItem: LayoutItem
): ?LayoutItem {
for (let i = 0, len = layout.length; i < len; i++) {
// collides方法:
// 当前节点 或者节点出现在另一个节点的上方(下边界 > 其他节点的起始位置)/下方/右侧/左侧
if (collides(layout[i], layoutItem)) return layout[i];
}
}
function resolveCompactionCollision(
layout: Layout,
item: LayoutItem,
moveToCoord: number,
axis: "x" | "y"
) {
const sizeProp = heightWidth[axis]; // x: w, y: h
item[axis] += 1;
const itemIndex = layout
.map(layoutItem => {
return layoutItem.i;
})
.indexOf(item.i);
// 至少要加1个位置的情况下,其下一个元素开始判断,是否与元素碰撞
for (let i = itemIndex + 1; i < layout.length; i++) {
const otherItem = layout[i];
// 静态元素不能动
if (otherItem.static) continue;
// 如果在当前元素的下方的话 break
if (otherItem.y > item.y + item.h) break;
if (collides(item, otherItem)) {
resolveCompactionCollision(
layout,
otherItem,
// moveToCoord想相当于新的y
moveToCoord + item[sizeProp],
axis
);
}
}
item[axis] = moveToCoord;
}
看一下紧凑布局的实际效果
const layout = [
{ i: "a", x: 0, y: 0, w: 5, h: 5 },
{ i: "b", x: 7, y: 6, w: 3, h: 2, minW: 2, maxW: 4, static: true },
{ i: "c", x: 0, y: 10, w: 12, h: 2 },
{ i: "d", x: 2, y: 16, w: 8, h: 2 },
{ i: "e", x: 7, y: 9, w: 3, h: 1 }
]; // 高度 (16 + 8) * 60 + 23 * 10 + 2 * 10 = 1690
compact调整之后
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7UCuxkBr-1693221682629)(https://tech-proxy.bytedance.net/tos/images/1693221360491_d79de77167d3c591075cdc27711f3ed8.png)]
看一下碰撞检测之后的处理结果
allowOverlap | preventCollision
**allowOverlap 为true时,**可以允许覆盖/重叠,并且不会对布局做压缩处理。
**preventCollision为true时,**阻止碰撞发生,在拖拽/缩放时,会有其作用
容器绘制
render() {
const { className, style, isDroppable, innerRef } = this.props;
//样式合并
const mergedClassName = classNames(layoutClassName, className);
const mergedStyle = {
height: this.containerHeight(),
...style,
};
return (
{React.Children.map(
this.props.children,
child => this.processGridItem(child)
)}
{isDroppable &&
this.state.droppingDOMNode &&
this.processGridItem(this.state.droppingDOMNode, true)}
{this.placeholder()}
);
}
{child}
计算实际位置(top/left/width/height)
function calcGridItemPosition(
positionParams,
x,
y,
w,
h,
state
){
const { margin, containerPadding, rowHeight } = positionParams;
// 计算列宽 (containerWidth - margin[0] * (cols - 1) - containerPadding[0] * 2) / cols
const colWidth = calcGridColWidth(positionParams);
const out = {};
// 如果gridItem正在缩放,就采用缩放时state记录的宽高(width,height)。
// 通过回调函数获取布局信息
if (state && state.resizing) {
out.width = Math.round(state.resizing.width);
out.height = Math.round(state.resizing.height);
}
// 反之,基于网格单元计算
else {
// gridUnits, colOrRowSize, marginPx
// Math.round(colOrRowSize * gridUnits + Math.max(0, gridUnits - 1) * marginPx)
// margin值计算,会单独留一个margin在下方或者右侧
out.width = calcGridItemWHPx(w, colWidth, margin[0]);
out.height = calcGridItemWHPx(h, rowHeight, margin[1]);
}
// 如果gridItem正在拖拽,就采用拖拽时state记录的位置(left,top)
// 通过回调函数获取布局信息
if (state && state.dragging) {
out.top = Math.round(state.dragging.top);
out.left = Math.round(state.dragging.left);
}
// 反之,基于网格单元计算
else {
out.top = Math.round((rowHeight + margin[1]) * y + containerPadding[1]);
out.left = Math.round((colWidth + margin[0]) * x + containerPadding[0]);
}
return out;
}
// 计算列宽
function calcGridColWidth(positionParams: PositionParams): number {
const { margin, containerPadding, containerWidth, cols } = positionParams;
return (
(containerWidth - margin[0] * (cols - 1) - containerPadding[0] * 2) / cols
);
}
// 计算宽高的实际值,最后一个margin会留在当前网格的下面
function calcGridItemWHPx(gridUnits, colOrRowSize, marginPx){
// 0 * Infinity === NaN, which causes problems with resize contraints
if (!Number.isFinite(gridUnits)) return gridUnits;
return Math.round(
colOrRowSize * gridUnits + Math.max(0, gridUnits - 1) * marginPx);
}
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CQGZ37uY-1693221682630)(https://tech-proxy.bytedance.net/tos/images/1693221360608_b333c2cc2256a2207154e26e5bbea246.png)]
onDrag
onDrag: (i: string, x: number, y: number, GridDragEvent) => void = (
i,
x,
y,
{ e, node }
) => {
const { oldDragItem } = this.state;
let { layout } = this.state;
const { cols, allowOverlap, preventCollision } = this.props;
const l = getLayoutItem(layout, i);
if (!l) return;
// Create placeholder (display only)
const placeholder = {
w: l.w,
h: l.h,
x: l.x,
y: l.y,
placeholder: true,
i: i
};
const isUserAction = true;
// 根据实时拖拽 生成布局
layout = moveElement(
layout,
l,
x,
y,
isUserAction,
preventCollision,
compactType(this.props),
cols,
allowOverlap
);
this.props.onDrag(layout, oldDragItem, l, placeholder, e, node);
this.setState({
layout: allowOverlap
? layout
: compact(layout, compactType(this.props), cols),
activeDrag: placeholder
});
};
function moveElement(
layout: Layout,
l: LayoutItem,
x: ?number,
y: ?number,
isUserAction: ?boolean,
preventCollision: ?boolean,
compactType: CompactType,
cols: number,
allowOverlap: ?boolean
): Layout {
if (l.static && l.isDraggable !== true) return layout;
if (l.y === y && l.x === x) return layout;
log(
`Moving element ${l.i} to [${String(x)},${String(y)}] from [${l.x},${l.y}]`
);
const oldX = l.x;
const oldY = l.y;
//只 修改 xy 更快速
if (typeof x === "number") l.x = x;
if (typeof y === "number") l.y = y;
l.moved = true;
// 排序,以便在发生碰撞的时候找最近碰撞的元素
let sorted = sortLayoutItems(layout, compactType);
const movingUp =
compactType === "vertical" && typeof y === "number"
? oldY >= y
: compactType === "horizontal" && typeof x === "number"
? oldX >= x
: false;
if (movingUp) sorted = sorted.reverse();
const collisions = getAllCollisions(sorted, l);
const hasCollisions = collisions.length > 0;
if (hasCollisions && allowOverlap) {
// 允许覆盖时 不处理
return cloneLayout(layout);
} else if (hasCollisions && preventCollision) {
// 不允许碰撞的时候,撤销本次拖拽
log(`Collision prevented on ${l.i}, reverting.`);
l.x = oldX;
l.y = oldY;
l.moved = false;
return layout;
}
//处理碰撞
for (let i = 0, len = collisions.length; i < len; i++) {
const collision = collisions[i];
log(
`Resolving collision between ${l.i} at [${l.x},${l.y}] and ${collision.i} at [${collision.x},${collision.y}]`
);
if (collision.moved) continue;
if (collision.static) {
// 与静态元素碰撞的时候,只能处理当前移动的元素
layout = moveElementAwayFromCollision(
layout,
collision,
l,
isUserAction,
compactType,
cols
);
} else {
layout = moveElementAwayFromCollision(
layout,
l,
collision,
isUserAction,
compactType,
cols
);
}
}
return layout;
}
function moveElementAwayFromCollision(
layout: Layout,
collidesWith: LayoutItem,
itemToMove: LayoutItem,
isUserAction: ?boolean,
compactType: CompactType,
cols: number
): Layout {
const compactH = compactType === "horizontal";
const compactV = compactType !== "horizontal";
const preventCollision = collidesWith.static;
if (isUserAction) {
isUserAction = false;
// 构建元素 出现在碰撞元素的左上方
const fakeItem: LayoutItem = {
x: compactH ? Math.max(collidesWith.x - itemToMove.w, 0) : itemToMove.x,
y: compactV ? Math.max(collidesWith.y - itemToMove.h, 0) : itemToMove.y,
w: itemToMove.w,
h: itemToMove.h,
i: "-1"
};
if (!getFirstCollision(layout, fakeItem)) {
log(
`Doing reverse collision on ${itemToMove.i} up to [${fakeItem.x},${fakeItem.y}].`
);
return moveElement(
layout,
itemToMove,
compactH ? fakeItem.x : undefined,
compactV ? fakeItem.y : undefined,
isUserAction,
preventCollision,
compactType,
cols
);
}
}
return moveElement(
layout,
itemToMove,
compactH ? itemToMove.x + 1 : undefined,
compactV ? itemToMove.y + 1 : undefined,
isUserAction,
preventCollision,
compactType,
cols
);
}
使用mixinDraggable函数,依赖react-draggable中的DraggableCore,为模块提供拖拽能力支持
onDrag
onDrag: (Event, ReactDraggableCallbackData) => void = (
e,
{ node, deltaX, deltaY }
) => {
const { onDrag } = this.props;
if (!onDrag) return;
if (!this.state.dragging) {
throw new Error("onDrag called before onDragStart.");
}
let top = this.state.dragging.top + deltaY;
let left = this.state.dragging.left + deltaX;
const { isBounded, i, w, h, containerWidth } = this.props;
const positionParams = this.getPositionParams();
// 不得超过边界
if (isBounded) {
const { offsetParent } = node;
if (offsetParent) {
const { margin, rowHeight } = this.props;
const bottomBoundary =
offsetParent.clientHeight - calcGridItemWHPx(h, rowHeight, margin[1]);
top = clamp(top, 0, bottomBoundary);
const colWidth = calcGridColWidth(positionParams);
const rightBoundary =
containerWidth - calcGridItemWHPx(w, colWidth, margin[0]);
left = clamp(left, 0, rightBoundary);
}
}
const newPosition: PartialPosition = { top, left };
this.setState({ dragging: newPosition });
// Call callback with this data
const { x, y } = calcXY(positionParams, top, left, w, h);
return onDrag.call(this, i, x, y, {
e,
node,
newPosition
});
};
onResize
onResize: (i: string, w: number, h: number, GridResizeEvent) => void = (
i,
w,
h,
{ e, node }
) => {
const { layout, oldResizeItem } = this.state;
const { cols, preventCollision, allowOverlap } = this.props;
// 把缩放之后的新元素set到layout中,cb单独处理不可碰撞时的场景
const [newLayout, l] = withLayoutItem(layout, i, l => {
let hasCollisions;
if (preventCollision && !allowOverlap) {
const collisions = getAllCollisions(layout, { ...l, w, h }).filter(
layoutItem => layoutItem.i !== l.i
);
hasCollisions = collisions.length > 0;
if (hasCollisions) {
// 只能向右下角伸缩,因此取x,y 的最小值,保证拖拽元素在碰撞
let leastX = Infinity,
leastY = Infinity;
collisions.forEach(layoutItem => {
if (layoutItem.x > l.x) leastX = Math.min(leastX, layoutItem.x);
if (layoutItem.y > l.y) leastY = Math.min(leastY, layoutItem.y);
});
if (Number.isFinite(leastX)) l.w = leastX - l.x;
if (Number.isFinite(leastY)) l.h = leastY - l.y;
}
}
if (!hasCollisions) {
// Set new width and height.
l.w = w;
l.h = h;
}
return l;
});
// Shouldn't ever happen, but typechecking makes it necessary
if (!l) return;
// Create placeholder element (display only)
const placeholder = {
w: l.w,
h: l.h,
x: l.x,
y: l.y,
static: true,
i: i
};
this.props.onResize(newLayout, oldResizeItem, l, placeholder, e, node);
// Re-compact the newLayout and set the drag placeholder.
this.setState({
layout: allowOverlap
? newLayout
: compact(newLayout, compactType(this.props), cols),
activeDrag: placeholder
});
};
使用mixinResizable函数,依赖react-resizable中的Resizable,为模块提供缩放能力支持
onResizeHandler
onResizeHandler(
e: Event,
{ node, size }: { node: HTMLElement, size: Position },
handlerName: string // onResizeStart, onResize, onResizeStop
): void {
const handler = this.props[handlerName];
if (!handler) return;
const { cols, x, y, i, maxH, minH } = this.props;
let { minW, maxW } = this.props;
// 获取新的宽高
let { w, h } = calcWH(
this.getPositionParams(), // margin, maxRows, cols, rowHeight
size.width,
size.height,
x,
y
);
// 保证宽度至少为1
minW = Math.max(minW, 1);
// 最大宽度不应超过当前行剩余的宽度,缩放 可以挤掉其右侧的模块,但是无法对左侧的模块进行改变
maxW = Math.min(maxW, cols - x);
// 获取在上下边界内的值
w = clamp(w, minW, maxW);
h = clamp(h, minH, maxH);
this.setState({ resizing: handlerName === "onResizeStop" ? null : size });
handler.call(this, i, w, h, { e, node, size });
}
// px 转 格数
function calcWH(
positionParams: PositionParams,
width: number,
height: number,
x: number,
y: number
): { w: number, h: number } {
const { margin, maxRows, cols, rowHeight } = positionParams;
const colWidth = calcGridColWidth(positionParams);
let w = Math.round((width + margin[0]) / (colWidth + margin[0]));
let h = Math.round((height + margin[1]) / (rowHeight + margin[1]));
// 在其边界内取值
w = clamp(w, 0, cols - x);
h = clamp(h, 0, maxRows - y);
return { w, h };
}
响应式布局,主要实现的是针对不同宽度的适配。值得注意的是,如果一定不想给每一个breakpoint配置layout的话,请一定给最大的那个breakpoint配置layout。
配置
breakpoints: ?Object = {lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0},
cols: ?Object = {lg: 12, md: 10, sm: 6, xs: 4, xxs: 2},
// 固定间隔或者响应式间隔 e.g.[10,10]| `{lg: [10, 10], md: [10, 10], ...}.
margin: [number, number] | {[breakpoint: $Keys]: [number, number]}
containerPadding: [number, number] | {[breakpoint: $Keys]: [number, number]}
// 由断点查找的 layouts 对象 e.g. {lg: Layout, md: Layout, ...}
layouts: {[key: $Keys]: Layout}
// 回调函数
onBreakpointChange: (newBreakpoint: string, newCols: number) => void,
onLayoutChange: (currentLayout: Layout, allLayouts: {[key: $Keys]: Layout}) => void,
// 页面宽度变化时的回调,可以根据需要修改 layout 或者处理其他内容
onWidthChange: (containerWidth: number, margin: [number, number], cols: number, containerPadding: [number, number]) => void;
流程
布局生成方法
function findOrGenerateResponsiveLayout(
layouts: ResponsiveLayout,
breakpoints: Breakpoints,
breakpoint: Breakpoint,
lastBreakpoint: Breakpoint,
cols: number,
compactType: CompactType
): Layout {
// 如果有提供对应配置的layout 直接使用
if (layouts[breakpoint]) return cloneLayout(layouts[breakpoint]);
// 另外生成
let layout = layouts[lastBreakpoint];
const breakpointsSorted = sortBreakpoints(breakpoints);
const breakpointsAbove = breakpointsSorted.slice(
breakpointsSorted.indexOf(breakpoint)
);
for (let i = 0, len = breakpointsAbove.length; i < len; i++) {
const b = breakpointsAbove[i];
if (layouts[b]) {
layout = layouts[b];
break;
}
}
layout = cloneLayout(layout || []);
return compact(correctBounds(layout, { cols: cols }), compactType, cols);
}
**性能好:**200个模块 约268.8ms
独立的是否更新layout的判断机制
用 transform: translateY(-5px) 只是改变了视觉位置,元素本身位置还是在 0px,不改动 css 布局,因为渲染行为大多数情况下在元素本身,所以效率比 top left 要高
功能齐全
https://react-grid-layout.github.io/react-grid-layout/examples/0-showcase.html
https://github.com/react-grid-layout/react-grid-layout/tree/master