react-grid-layout 实现原理介绍

简介

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()}
); }

GridItem

        
   {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 };
}
    

ResponsiveGridLayout

响应式布局,主要实现的是针对不同宽度的适配。值得注意的是,如果一定不想给每一个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

你可能感兴趣的:(react.js,前端,网络)