One of our ongoing projects, Neptyne, introduces an Excel-like grid written in React. We used a library to apply virtual scroll to it, but we stumbled upon a problem with fixed rows and columns inside the grid. Here I would like to describe this problem, how it occurs, and how we handled it.
The main goal of a virtual scroll is to reduce client resource consumption by rendering only visible elements of a large scrollable list/table. Since this is a common technique, there are lots of articles you can read to catch up on how it works (here, here).
However, we cannot use a simple virtual scroll to implement a table with frozen rows and columns. For better understanding, let me show you how Google Sheets implement its frozen rows/cols:
As you can see, frozen rows/cols still need to be virtualized. Essentially, frozen columns A-B should have a virtual height, and rows 1-3 should have a virtual width. They must be scrollable and must not render cells out of the viewport. There is no CSS to make this work: we need to use something more complex than a single virtual scroll element.
To make a virtualized table with frozen rows/cols, we have to treat each area as a separate table. Each of these tables has a viewport visible to the user and a “canvas“, where the content is drawn.
Each table has its own set of data to be drawn. “Unfrozen“ table does not have access to content rendered in the frozen rows and columns. For example, if we have row 1 frozen, then “unfrozen“ part will work with rows 2-999, and “frozen rows” will be provided with data from row 1.
However, to achieve the effect we have seen with Google Sheets, we need to synchronize scroll states between tables. For instance, if we scroll the “unfrozen” table to the right, the frozen rows should be scrolled to the right as well. If we scroll “unfrozen“ table to the bottom, frozen columns should be scrolled to the bottom too.
We did a little research and found a couple of libraries that implemented this approach. The most prominent of them was react-virtualized - it was relatively simple and rich in features.
It implements a classic virtual scroll pattern mentioned above: we have a tiny DOM element for a viewport, which has a fixed width and height. Inside the viewport, we have a large DOM element that contains the actual grid cells. When the user scrolls, the library determines which cells are visible through a viewport, and draws only them.
That worked pretty well for us feature-wise, but there was one notable issue: scroll lag related to fixed rows and columns.
As you can see in the GIF, there is a slight lag between scrolling the main part and the fixed parts. This effect gets more apparent with weaker computers and more sophisticated grid cell components. So we had to find out the reason for this and re-implement this feature manually.
Frozen rows and columns came in separate tables. Each of these tables has an independent scroll state synchronized by a common parent.
So when the user scrolls any of the tables, react-virtualized reads its updated scroll position and sets the same position for the rest of the tables.
Lag comes from the fact that the browser draws scrolled content of the first table before the rest have a chance to re-render.
The solution is to somehow have the same scroll position confidence source set for all tables at the same time.
We decided to listen to onWheel event of the parent table. This event gives us the offset user scrolled for. Nothing has been scrolled so far. We then calculate the updated scroll position and set it to all tables.
The scroll state is updated and broadcasted to all the tables simultaneously, allowing them to adjust their position without the lag.
Since this “four synchronized tables“ approach is effectively hardcoded in the react-virtualized
library, we moved to a lighter react-window, which is easier to customise for our case.
Below you can see an example of React-component that allows rendering tables with frozen rows and cols. The code should be covered with comments well enough to understand what is going on, but here is a brief recap anyway.
We render four VariableSizeGrid
that reflect four tables with data;
Three of them are “frozen” tables - frozen rows, frozen cols, and this frozen rectangle in upper left part if we have both frozen rows and cols;
All “frozen“ parts have a CSS class scrollable-grid
that prevents them from scrolling natively;
The main table can be scrolled freely. It simplifies the flow if we only have unfrozen part, and does not affect the scrolling of frozen parts since we don’t synchronize them the usual way;
We also have an outer-grid-container
DOM element, that handles scroll synchronization. It owns an event handler described on line 232. This handler listens to any scroll event inside its children, simultaneously applying scroll to every grid that we have.
So, in order to synchronize the scrolling of several tables, you basically need to “mute“ scroll event in the tables and listen to scroll in a parent element. This would allow you to programmatically change the scroll state to all the tables, giving you fairly smooth scrolling.
// css
// .scrollable-grid {
// overflow: scroll;
// -ms-overflow-style: none;
//
// // prohibit scroll for "frozen" grid containers
// scrollbar-width: none;
// }
// .scrollable-grid::-webkit-scrollbar {
// display: none;
// }
// .main-grid {
// display: flex;
// }
import {
areEqual,
GridChildComponentProps,
GridOnScrollProps,
VariableSizeGrid,
} from "react-window";
import {
CSSProperties,
FunctionComponent,
memo,
useCallback,
useEffect,
useMemo,
useRef,
} from "react";
import { ReactWindowCellRenderer } from "./react-window-cell/ReactWindowCellRenderer";
interface Properties {
className?: string;
columnWidth: (index: number) => number;
rowHeight: (index: number) => number;
columnCount: number;
rowCount: number;
width: number;
height: number;
frozenRowCount: number;
frozenColumnCount: number;
hideScrollbars?: boolean;
gridData: unknown;
}
interface OffsetCellRendererProps extends GridChildComponentProps {
rowOffset: number;
colOffset: number;
}
const HORIZONTAL_TABLE_BLOCK_STYLE: CSSProperties = {
display: "flex",
flexDirection: "row",
};
// special cell renderer to reflect cells with particular position. Offset allows to "skip"
// rendering first "frozen" cells.
const OffsetCellRenderer: FunctionComponent = memo(
({ rowOffset, colOffset, ...props }) => (
),
areEqual
);
export const VirtualizedGrid = (props: Properties) => {
// number of rows and columns that should be rendered in separate "frozen" tables
const { frozenRowCount, frozenColumnCount, className } = props;
// calculator functions to get size of particular row and column.
// Used to calculate sizes of frozen/unfrozen tables for virtual scroll.
const { rowHeight, columnWidth } = props;
// the object that contains business data of an app. It can contain some parameters to be applied
// to the cells, and the actial cell data in a form of two-dimensional array.
const { gridData } = props;
// refs to actual containers that display data
const cornerRef = useRef(null);
const topHeaderRef = useRef(null);
const leftHeaderRef = useRef(null);
const mainGridRef = useRef(null);
// ref to a "canvas" div with size of entire grid. The actual DOM element that we scroll
const mainGridContainerRef = useRef(null);
// a special element that listens to scroll events and dispatches them to actual grid containers
const outerContainerRef = useRef(null);
useEffect(
() => {
const { current: grid } = mainGridRef;
const { current: top } = topHeaderRef;
const { current: left } = leftHeaderRef;
if (grid && top && left) {
grid.scrollTo({ scrollLeft: 0, scrollTop: 0 });
top.scrollTo({ scrollLeft: 0 });
left.scrollTo({ scrollTop: 0 });
}
},
[] /* only on initial render, make sure we're at grid origin */
);
// calculate summarized sizes of frozen rows/cols
const frozenRowHeight = useMemo(() => {
let height = 0;
for (let i = 0; i < frozenRowCount; i++) {
height += rowHeight(i);
}
return height;
}, [frozenRowCount, rowHeight]);
const frozenColWidth = useMemo(() => {
let width = 0;
for (let i = 0; i < frozenColumnCount; i++) {
width += columnWidth(i);
}
return width;
}, [columnWidth, frozenColumnCount]);
// calculate summarized sizes of unfrozen rows/cols
const unfrozenHeight = props.height - frozenRowHeight;
const unfrozenWidth = props.width - frozenColWidth;
const cornerGridStyle = useMemo(
(): CSSProperties => ({
display: "flex",
...(frozenColumnCount > 1 ? { borderRight: "2px solid gray" } : {}),
...(frozenRowCount > 1 ? { borderBottom: "2px solid gray" } : {}),
}),
[frozenColumnCount, frozenRowCount]
);
const topGridStyle = useMemo(
(): CSSProperties => ({
display: "flex",
...(frozenRowCount > 1 ? { borderBottom: "2px solid gray" } : {}),
}),
[frozenRowCount]
);
const leftGridStyle = useMemo(
(): CSSProperties => ({
display: "flex",
...(frozenColumnCount > 1 ? { borderRight: "2px solid gray" } : {}),
}),
[frozenColumnCount]
);
const offsetColumnWidth = useCallback(
(index: number) => columnWidth(index + frozenColumnCount),
[columnWidth, frozenColumnCount]
);
const offsetRowHeight = useCallback(
(index: number) => rowHeight(index + frozenRowCount),
[frozenRowCount, rowHeight]
);
// cell renderers
const FrozenCornerRenderer = useCallback(
(props: GridChildComponentProps) => (
),
[]
);
const FrozenRowRenderer = useCallback(
(props: GridChildComponentProps) => (
),
[frozenColumnCount]
);
const FrozenColumnRenderer = useCallback(
(props: GridChildComponentProps) => (
),
[frozenRowCount]
);
const FrozenCellRenderer = useCallback(
(props: GridChildComponentProps) => (
),
[frozenColumnCount, frozenRowCount]
);
const handleScroll = useCallback(({ scrollLeft, scrollTop }: GridOnScrollProps) => {
topHeaderRef.current?.scrollTo({ scrollLeft });
leftHeaderRef.current?.scrollTo({ scrollTop });
}, []);
const handleVerticalScroll = useCallback(({ scrollTop }: GridOnScrollProps) => {
mainGridRef.current?.scrollTo({ scrollTop });
}, []);
const handleHorizontalScroll = useCallback(({ scrollLeft }: GridOnScrollProps) => {
mainGridRef.current?.scrollTo({ scrollLeft });
}, []);
const outerGridStyle = { width: "100%", height: "100%" };
// calculate total grid size
const [gridHeight, gridWidth] = useMemo(() => {
let height = 0;
let width = 0;
for (let i = 0; i < props.rowCount; i++) {
height += rowHeight(i);
}
for (let i = 0; i < props.columnCount; i++) {
width += columnWidth(i);
}
return [height, width];
}, [columnWidth, rowHeight, props.columnCount, props.rowCount]);
// maximum value in pixels to which we can scroll
const maxVScroll = gridHeight - frozenRowHeight - unfrozenHeight;
const maxHScroll = gridWidth - frozenColWidth - unfrozenWidth;
// The magic happens here. We listen to scroll event in an external div, and apply scroll data to
// grids. This allows us to synchronize scroll events between grids, applying changes in one
// render "tick".
useEffect(() => {
const { current: outerGrid } = outerContainerRef;
if (outerGrid) {
const handler = (e: WheelEvent) => {
e.preventDefault();
const { deltaX, deltaY } = e;
const { current: grid } = mainGridRef;
const { current: top } = topHeaderRef;
const { current: left } = leftHeaderRef;
const { current: gridDiv } = mainGridContainerRef;
if (gridDiv && grid && top && left) {
let { scrollLeft, scrollTop } = gridDiv;
scrollLeft += deltaX;
scrollTop += deltaY;
if (scrollLeft > maxHScroll) {
scrollLeft = maxHScroll;
}
if (scrollTop > maxVScroll) {
scrollTop = maxVScroll;
}
grid.scrollTo({ scrollLeft, scrollTop });
top.scrollTo({ scrollLeft });
left.scrollTo({ scrollTop });
}
};
outerGrid.addEventListener("wheel", handler);
return () => outerGrid.removeEventListener("wheel", handler);
}
});
const unfrozenHorizontalDivStyles = useMemo(
(): CSSProperties => ({ position: "absolute", left: frozenColWidth, top: 0 }),
[frozenColWidth]
);
const mainGridInnerContainerStyles = useMemo(
(): CSSProperties => ({
position: "absolute",
left: frozenColWidth,
top: frozenRowHeight,
}),
[frozenColWidth, frozenRowHeight]
);
return (
{FrozenCornerRenderer}
{FrozenRowRenderer}
{FrozenColumnRenderer}
{FrozenCellRenderer}
);
};
This approach allowed us to provide smooth scrolling of frozen rows and columns while simultaneously reusing many of the features of a third-party virtual scroll library. Since this ideological approach relies on pure event handlers and HTML/CSS, it should be applicable to virtual scrolls with other libraries and frameworks.
Hope this article helps you, happy coding!