!!!###!!!title=4.5 事件到状态的更新流程——VisActor/VTable 社区贡献者文档!!!###!!!!!!###!!!description=---title: 4.5 事件到状态的更新流程 key words: VisActor,VChart,VTable,VStrory,VMind,VGrammar,VRender,Visualization,Chart,Data,Table,Graph,Gis,LLM--- !!!###!!!

简介

VTable 将交互效果的实现拆到了三个模块中去处理,分别是:

  • 状态模块stateManager:状态模块负责维持表格当前各种交互的状态,状态的改变会导致场景树的重新渲染;

  • 事件模块为 eventManager:事件模块负责监听事件,并根据不同的事件来改变状态;

  • 场景树 scenegraph:场景树负责重新渲染表格,为实现交互的最后一步;

接下来将从六个常用的交互来看下事件到状态的更新流程。

交互实现

单元格 select

核心状态

在状态模块中,决定单元格选中的核心状态值为 select.ranges,VTable 通过该字段来判断当前单元格是否选中,改变 select.ranges 即可实现单元格选中状态的改变。

// packages\vtable\src\state\state.ts
select: {
    ranges: (CellRange & { skipBodyMerge?: boolean })[];
    //...
}    

我们来看下单元格选中是如何通过事件去影响状态的。

select 包含三种交互,分别是多选,拖拽多选和清空选择,三者所监听的事件各不相同。

单选

  • pointerdown 单选单元格

在处理完单元格选中事件之后,更新 interactionState

// packages\vtable\src\event\listener\table-group.ts
stateManager.updateInteractionState(InteractionState.grabing);    

至于是否更新当前单元格选中状态的逻辑,则位于状态模块 stateManager.updateSelectPos中。

拖拽多选

  • pointermove 多选单元格

清空选中

  • 事件模块接收 pointertap 事件,点击空白区域取消选中,结束 select 交互。
// packages\vtable\src\event\listener\table-group.ts
   table.scenegraph.stage.addEventListener('pointertap', (e: FederatedPointerEvent) => {
    // ...
      if (table.options.select?.blankAreaClickDeselect ?? true) {
        eventManager.dealTableSelect();
      }    
      // ...
  }    

状态更新

在状态模块关于 selct 单元格的流程中,单选单元格和框选单元格核心区别点在于 stateManger.interactionState 的不同:

  • stateManager.interactionState === 'grabing' 表示当前正在框选单元格的过程

  • stateManager.interactionState === 'default' 表示单选单元格


状态管理中关于选择状态的更新流程如下:

  • updateSelectPos

滚动条滚动

滚动效果主要是监听的 wheel 事件,通过 wheel 事件改变当前的滚动条的状态,更新 scrollTop 和 scrollLeft ,调整表格的 x,y 坐标,实现滚动效果。

核心状态

// packages\vtable\src\state\state.ts
  scroll: {
    horizontalBarPos: number;
    verticalBarPos: number;
  };
    

更新流程

hover 单元格

核心状态

// packages\vtable\src\state\state.ts
  hover: {
    cellPos: CellPosition; *// 记录当前hover的位置*
  };    

VTable 内部通过 hover.cellPos 判断当前单元格是否处于 hover 状态,从而实现 hover 单元格的功能。

处理流程

单元格 hover 效果是通过监听 pointermove 事件来完成的。

  • 首先由事件模块处理 pointermove 事件
// packages\vtable\src\event\listener\table-group.ts
  table.scenegraph.tableGroup.addEventListener('pointermove', (e: FederatedPointerEvent) => {
    // ...
    const eventArgsSet = getCellEventArgsSet(e);
    eventManager.dealTableHover(eventArgsSet);
    // ...
  })    

  • 事件模块 eventManager.dealTableHover 处理 hover 效果,通过 eventArgs 判断是清空还是更新 hover 状态。
// packages\vtable\src\event\event.ts
  dealTableHover(eventArgsSet?: SceneEvent) {
    if (!eventArgsSet) {
      this.table.stateManager.updateHoverPos(-1, -1);
      return;
    }
    const { eventArgs } = eventArgsSet;

    if (eventArgs) {
      this.table.stateManager.updateHoverPos(eventArgs.col, eventArgs.row);
    } else {
      this.table.stateManager.updateHoverPos(-1, -1);
    }
  }
    

  • 状态模块更新 hover 位置 stateManager.updateHoverPos
  • 整体流程图

行高列宽调整

核心状态

// packages\vtable\src\state\state.ts
columnResize: {
  col: number;
  */** x坐标是相对table内坐标 */*
  x: number;
  resizing: boolean;
};
rowResize: {
  row: number;
  */** y坐标是相对table内坐标 */*
  y: number;
  resizing: boolean;
};    

该状态中记录了当前拖拽行列的索引以及坐标,后续在实际的拖拽中,仅会去调整 columnResize.colrowResize.row 对应的行或列。

调整流程

  • 接收 pointerdown 事件,由事件模块检查是否进入拖拽调整列宽,如果确认进入调整行高列宽,则更新 state.interactionStategrabing
// packages\vtable\src\event\listener\table-group.ts
  table.scenegraph.tableGroup.addEventListener('pointerdown', (e: FederatedPointerEvent) => {
  // ...
  *// 处理列宽调整*
  if (
    !eventManager.checkCellFillhandle(eventArgsSet) &&
    (eventManager.checkColumnResize(eventArgsSet, true) || eventManager.checkRowResize(eventArgsSet, true))
  ) {
    table.scenegraph.updateChartState(null);
    stateManager.updateInteractionState(InteractionState.grabing);
    return;
  }
  // ...
 }    

  • 首先根据 pointerdown 提供的点击坐标,计算是否命中拖拽热区,如果命中的话,返回对应的行列索引。

  • 拖拽列宽检查

// packages\vtable\src\event\event.ts
  checkColumnResize(eventArgsSet: SceneEvent, update?: boolean): boolean {
    const { eventArgs } = eventArgsSet;
    // ...
    *// 如果是鼠标处理表格外部如最后一列的后面 也期望可以拖拽列宽*
    // 获取当前点击的单元格行列号
    const resizeCol = this.table.scenegraph.getResizeColAt(
      eventArgsSet.abstractPos.x,
      eventArgsSet.abstractPos.y,
      eventArgs?.targetCell
    );
    if (this.table._canResizeColumn(resizeCol.col, resizeCol.row) && resizeCol.col >= 0) {
      if (update) {
        this.table.stateManager.startResizeCol(
          resizeCol.col,
          eventArgsSet.abstractPos.x,
          eventArgsSet.abstractPos.y,
          resizeCol.rightFrozen
        );
      }
      return true;
    }
    // ...
  }    

  • 拖拽行高检查
// packages\vtable\src\event\event.ts
  checkRowResize(eventArgsSet: SceneEvent, update?: boolean): boolean {
  // ...
    const { eventArgs } = eventArgsSet;
    if (eventArgs) {
      const resizeRow = this.table.scenegraph.getResizeRowAt(
        eventArgsSet.abstractPos.x,
        eventArgsSet.abstractPos.y,
        eventArgs.targetCell
      );

      if (this.table._canResizeRow(resizeRow.col, resizeRow.row) && resizeRow.row >= 0) {
        if (update) {
          this.table.stateManager.startResizeRow(
            resizeRow.row,
            eventArgsSet.abstractPos.x,
            eventArgsSet.abstractPos.y,
            resizeRow.bottomFrozen
          );
        }
        return true;
      }
    }

  }
    

  • 根据行列索引,通过状态模块初始化 columnResizerowResize 的状态,触发下一帧渲染;
// packages\vtable\src\state\state.ts
  startResizeCol(col: number, x: number, y: number, isRightFrozen?: boolean) {
    this.columnResize.resizing = true;
    this.columnResize.col = col;
    this.columnResize.x = x;
    this.columnResize.isRightFrozen = isRightFrozen;

    this.table.scenegraph.component.showResizeCol(col, y, isRightFrozen);
    this.table.scenegraph.updateNextFrame();
  }    

  • 处理 pointermove 事件,由状态模块判断当前是拖拽行还是列;

  • 如果 interactionState === 'grabing' 代表当前处于拖拽行高列宽的交互中;

  • columnResize.resizingrowResize.resizing 判断当前是拖拽行高还是列宽;

  • 通过事件模块做中转,处理拖拽事件 eventManager.dealColumnResize(x, y)

  • 触发 RESIEZE_COLUMNRESIZE_ROW 事件;

  const globalPointermoveCallback = (e: MouseEvent) => {
  // ... 
    const { x, y } = table._getMouseAbstractPoint(e, false);
    if (stateManager.interactionState === InteractionState.grabing) {
      if (stateManager.isResizeCol()) {
        eventManager.dealColumnResize(x, y);
        if ((table as any).hasListeners(TABLE_EVENT_TYPE.RESIZE_COLUMN)) {
          table.fireListeners(TABLE_EVENT_TYPE.RESIZE_COLUMN, {
            col: table.stateManager.columnResize.col,
            colWidth: table.getColWidth(table.stateManager.columnResize.col)
          });
        }
      } else if (stateManager.isResizeRow()) {
        eventManager.dealRowResize(x, y);
        if ((table as any).hasListeners(TABLE_EVENT_TYPE.RESIZE_ROW)) {
          table.fireListeners(TABLE_EVENT_TYPE.RESIZE_ROW, {
            row: table.stateManager.rowResize.row,
            rowHeight: table.getRowHeight(table.stateManager.rowResize.row)
          });
        }
      }
    }
  // ...
  }
  document.body.addEventListener('pointermove', globalPointermoveCallback);    

  • 通过状态模块处理 pointermove 事件,通过当前指针的坐标,更新 columnResize.colrowResize.row 对应索引处的列宽/行高。
// packages\vtable\src\event\event.ts
  dealColumnResize(xInTable: number, yInTable: number) {
    this.table.stateManager.updateResizeCol(xInTable, yInTable);
  }

  dealRowResize(xInTable: number, yInTable: number) {
    this.table.stateManager.updateResizeRow(xInTable, yInTable);
  }    

  • 处理 pointerup 事件,将 state.interactionState 还原为 default
// packages\vtable\src\event\listener\table-group.ts
  table.scenegraph.stage.addEventListener('pointerup', (e: FederatedPointerEvent) => {
    *// 处理列宽调整  这里和tableGroup.addEventListener('pointerup' 逻辑一样*
    if (stateManager.interactionState === 'grabing') {
      stateManager.updateInteractionState(InteractionState.default);
      if (stateManager.isResizeCol()) {
        endResizeCol(table);
      } else if (stateManager.isResizeRow()) {
        endResizeRow(table);
      }
    }
  });    

  • 交由状态模块去重置 stateManager.columnResizestateManager.rowResize,随后触发 RESIZE_COLUMN_ENDRESIZE_ROW_END 事件
// packages\vtable\src\event\listener\table-group.ts
export function endResizeCol(table: BaseTableAPI) {
  table.stateManager.endResizeCol();
  const columns = [];
  *// 返回所有列宽信息*
  for (let col = 0; col < table.colCount; col++) {
    columns.push(table.getColWidth(col));
  }
  table.fireListeners(TABLE_EVENT_TYPE.RESIZE_COLUMN_END, {
    col: table.stateManager.columnResize.col,
    colWidths: columns
  });
}

export function endResizeRow(table: BaseTableAPI) {
  table.stateManager.endResizeRow();

  table.fireListeners(TABLE_EVENT_TYPE.RESIZE_ROW_END, {
    row: table.stateManager.rowResize.row,
    rowHeight: table.getRowHeight(table.stateManager.rowResize.row)
  });    

  • 重置 columnResize.resizingrowResize.resizing 为 false,隐藏拖拽基准线,进入下一帧渲染。
// packages\vtable\src\state\state.ts
  endResizeCol() {
    setTimeout(() => {
      this.columnResize.resizing = false;
    }, 0);
    // ...
    this.table.scenegraph.component.hideResizeCol();
    this.table.scenegraph.updateNextFrame();
  }
  endResizeRow() {
    setTimeout(() => {
      this.rowResize.resizing = false;
    }, 0);
    // ...
    this.table.scenegraph.component.hideResizeRow();
    this.table.scenegraph.updateNextFrame();
  }    

  • 流程图

拖拽换行换列

核心状态

// packages\vtable\src\state\state.ts
  columnMove: {
    colSource: number;
    colTarget: number;
    rowSource: number;
    rowTarget: number;
    x: number;
    y: number;
    moving: boolean;
  };    

columnRemove 中存储了拖拽行列的原始索引以及目标索引,还有是否处于移动中的标识,通过改变 colTargetrowTarget 即可实现将选中的行/列替换到目标位置的功能。

处理流程

拖拽换行换列也是依靠了三个事件来完成的:pointerdownpointermovepointerup

  • 流程图

固定列

VTable 提供了内置的冻结列操作,可以通过配置 allowFrozenColCount 开启。

核心状态

VTable 通过tableInstance.internalProps.frozenColCount状态维护了当前实际冻结的列数,内部会根据该字段调整左侧冻结列数,采取特殊样式。

处理流程

冻结列的操作主要由 pointertap 和 自定义事件 ICON_CLICK 协同实现。

  • 首先处理 pointertap 事件;
// packages\vtable\src\event\listener\table-group.ts
  table.scenegraph.tableGroup.addEventListener('pointertap', (e: FederatedPointerEvent) => {
  // ...
    if (
      !eventManager.touchMove &&
      e.button === 0 &&
      eventArgsSet.eventArgs &&
      (table as any).hasListeners(TABLE_EVENT_TYPE.CLICK_CELL)
    ) {
    // ...
    eventManager.dealIconClick(e, eventArgsSet);

  });    

  • 事件模块中通过 eventArgsSet 判断是否点击中图标图元,如果点击的为图标图元,则触发自定义事件 ICON_CLICK
// packages\vtable\src\event\event.ts
 dealIconClick(e: FederatedPointerEvent, eventArgsSet: SceneEvent): boolean {
    const { eventArgs } = eventArgsSet;

    const { target, event, col, row } = eventArgs || {
      target: e.target,
      event: e,
      col: -1,
      row: -1
    };
    const icon = target as unknown as Icon;

    if (icon.role && icon.role.startsWith('icon-')) {
      this.table.fireListeners(TABLE_EVENT_TYPE.ICON_CLICK, {
        name: icon.name,
        *// 默认位置:icon中部正下方*
        x: (icon.globalAABBBounds.x1 + icon.globalAABBBounds.x2) / 2,
        y: icon.globalAABBBounds.y2,
        col,
        row,
        funcType: icon.attribute.funcType,
        icon,
        event
      });

  }    

  • ICON_CLICK事件早在事件模块初始化时就已注册,ICON_CLICK 事件中会判断当前点击图标类型是否为 frozen;
// packages\vtable\src\event\event.ts
    *// 图标点击*
    this.table.on(TABLE_EVENT_TYPE.ICON_CLICK, iconInfo => {
      const { col, row, x, y, funcType, icon, event } = iconInfo;
      // ...
      if (funcType === IconFuncTypeEnum.frozen) {
        stateManager.triggerFreeze(col, row, icon);
      } 
      // ...
    });
    

  • 状态模块处理点击 fronzen 事件,根据当前点击的列索引,更新this.internalProps.frozenColCount,如果当前点击的列跟状态中维持的 frozenColCount 相同,则重置 frozenColCount 为 0,如果不同则更新 frozenColCount 为 col;
// packages\vtable\src\state\frozen\index.ts
export function dealFreeze(col: number, row: number, table: BaseTableAPI) {
  if (table.frozenColCount > 0) {
    if (col !== table.frozenColCount - 1) {
      table.setFrozenColCount(col + 1);
    } else {
      table.setFrozenColCount(0);
    }
  } else {
    table.setFrozenColCount(col + 1);
  }
}
    

  • 触发 FREEZE_CLICK 事件
  triggerFreeze(col: number, row: number, iconMark: Icon) {
  // ...
    if ((this.table as any).hasListeners(PIVOT_TABLE_EVENT_TYPE.FREEZE_CLICK)) {
      const fields: ColumnData[] = (this.table as ListTable).internalProps.layoutMap.columnObjects.slice(0, col + 1);
      this.table.fireListeners(PIVOT_TABLE_EVENT_TYPE.FREEZE_CLICK, {
        col: col,
        row: row,
        fields: fields.reduce((pre: any, cur: any) => pre.concat(cur.field), []),
        colCount: this.table.frozenColCount
      });
    }
    // ...
   }    

结语

本文从常用的六种交互效果出发,详细讲解了从事件到状态的更新流程。

VTable 将交互效果拆为事件模块和状态模块,使得处理交互事件时,在流程处理方面能够更加清晰。

本文档由以下人员提供

taiiiyang( https://github.com/taiiiyang)

本文档由以下人员修正整理

玄魂