4

I'm using expandable panels (Material-UI) in rows with a react virtualized list and have been having issues with the heights auto-adjusting. I've read several SO posts and some issues on dynamic row heights on the react-virtualized site, but I have a specific problem where it seems like there's an 'off by one' issue with when the row height is adjusted after panel is expanded/collapsed.

Here's the expected behavior:

  1. Row panel expanded by default.
  2. User clicks expandable panel row.
  3. Row panel collapses.
  4. Row height adjusts to panel collapse.

Here's the actual behavior FOR THE FIRST CLICK:

  1. Row panel expanded by default.
  2. User clicks expandable panel row.
  3. Row panel collapses.
  4. Row height does NOT adjust to panel collapse.
  5. HOWEVER, on subsequent clicks the row height DOES adjust, but to the 'opposite' state, which leads to an inconsistency - i.e when the the row panel is clicked to expand again, the row height is adjusted to the row height as if it were collapsed, and vice versa. So when the panel is collapsed there's a bunch of white space after it, and when it's technically expanded the row height is too small to see the content.

I'm not sure what other info to include besides posting code and noting that the onRowClick() IS firing when the panels are collapsed/expanded.

Here's the parent component:

import React, { Component } from 'react';
import AutoSizer from 'react-virtualized/dist/commonjs/AutoSizer';
import List from 'react-virtualized/dist/commonjs/List';
import { CellMeasurer, CellMeasurerCache } from 'react-virtualized/dist/commonjs/CellMeasurer';
import EquipSummaryRow from './EquipSummaryRow';
import './EquipSummary.css';

class EquipSummary extends Component {
  constructor(props) {
    super(props);

    this.cache = new CellMeasurerCache({
      fixedWidth: true,
    });

    this.rowRenderer = this.rowRenderer.bind(this);
    this.getDatum = this.getDatum.bind(this);
    this.onRowClick = this.onRowClick.bind(this);
  }

  getDatum(index) {
    const list = this.props.equipData;

    return list[index];
  }

  saveRef = (ref) => this.containerNode = ref;

  saveListRef = (ref) => {
    this.list = ref;
  }

  componentDidUpdate() {
    console.log('component updated');
    this.cache.clearAll();
    this.list.recomputeRowHeights();
  }

  onRowClick(e, index) {
    e.preventDefault();
    this.cache.clear(index);
    this.list.recomputeRowHeights();
    this.list.forceUpdateGrid();
  }

  rowRenderer({ index, key, parent, style }) {
    const datum = this.getDatum(index);
    return (
      <div key={key} style={style}>
        <CellMeasurer
          cache={this.cache}
          columnIndex={0}
          key={key}
          rowIndex={index}
          parent={parent}
        >
          {({ measure }) => (
            <EquipSummaryRow
              onClick={(e, idx) => this.onRowClick(e, idx)}
              measure={measure}
              datum={datum}
              index={index}
            />
          )}
        </CellMeasurer>
      </div>
    );
  }

  render() {
    console.log('rendering..');
    return (
      <div className="EquipSummary-AutoSizer" ref={this.saveRef}>
        <AutoSizer>
          {({ width, height }) => (
            <List
              ref={this.saveListRef}
              width={width}
              height={height}
              rowHeight={this.cache.rowHeight}
              rowCount={this.props.equipData.length}
              rowRenderer={this.rowRenderer}
              deferredMeasurementCache={this.cache}
              equipData={this.props.equipData}
            />
          )}
        </AutoSizer>
      </div>
    );
  }
}

export default EquipSummary;

And here's the component that represents a row:

import React, { Component } from 'react';
import {
  Table,
  TableBody,
  TableHeader,
  TableHeaderColumn,
  TableRow,
  TableRowColumn,
} from 'material-ui/Table';
import { MuiThemeProvider } from 'material-ui/styles';
import ExpansionPanel from '@material-ui/core/ExpansionPanel';
import ExpansionPanelSummary from '@material-ui/core/ExpansionPanelSummary';
import ExpansionPanelDetails from '@material-ui/core/ExpansionPanelDetails';
import Typography from '@material-ui/core/Typography';


class EquipSummaryRow extends Component {
  render() {
    const { datum } = this.props;

    return (
      <div>
        <ExpansionPanel
          defaultExpanded
          onChange={e => this.props.onClick(e, this.props.index)}
        >
          <ExpansionPanelSummary expandIcon={<div>|</div>}>
            <Typography>{`${datum.type}      (id: ${datum.instance}, points: ${datum.points.length})`}</Typography>
          </ExpansionPanelSummary>
          <ExpansionPanelDetails>
            <Table>
              <TableHeader
                displaySelectAll={false}
                adjustForCheckbox={false}
              >
                <TableRow>
                  <TableHeaderColumn>Device</TableHeaderColumn>
                  <TableHeaderColumn>Object ID</TableHeaderColumn>
                  <TableHeaderColumn>Type</TableHeaderColumn>
                  <TableHeaderColumn>Name</TableHeaderColumn>
                  <TableHeaderColumn>Description</TableHeaderColumn>
                  <TableHeaderColumn>Units</TableHeaderColumn>
                  <TableHeaderColumn>Value</TableHeaderColumn>
                </TableRow>
              </TableHeader>
              <TableBody
                displayRowCheckbox={false}
              >
                {datum.points.map((row, index) => (
                  <TableRow key={row.id}>
                    <TableRowColumn>{row.device}</TableRowColumn>
                    <TableRowColumn>{row.objectID}</TableRowColumn>
                    <TableRowColumn>{row.type}</TableRowColumn>
                    <TableRowColumn>{row.name}</TableRowColumn>
                    <TableRowColumn>{row.description}</TableRowColumn>
                    <TableRowColumn>{row.units}</TableRowColumn>
                    <TableRowColumn>{row.value}</TableRowColumn>
                  </TableRow>
                  ))}
              </TableBody>
            </Table>
          </ExpansionPanelDetails>
        </ExpansionPanel>
      </div>
    );
  }
}

export default EquipSummaryRow;

Could this be an issue with how I'm using the cache? I've been beating my head with this so any suggestions appreciated!

Luke
  • 781
  • 2
  • 8
  • 11
  • Don't you give `recomputeRowHeights()` index? Also `recomputeRowHeights(index)` already include a `forceUpdate()` as I read react-virtualized's codes, so I guess `this.list.forceUpdateGrid();` can be omitted? – Marson Mao Jun 08 '18 at 05:48

2 Answers2

3

Figured out my problem. The issue is that the Material-UI expandable panel has an animated collapse so there is a delay between when the panel reaches its expanded/collapsed form. The 'onChange' event fires immediately so the measurement is taken while the animation is happening. I'm currently trying to figure out a way to trigger the measurement after the animation ends but this isn't an issue with react-virtualized.

Luke
  • 781
  • 2
  • 8
  • 11
0

(This isn't a complete answer, but it does allow for the animation step to work as designed. Given enough time, I think this could work completely. Please see my comments at the end for more information.)

In the List component, there is an option to pass in a different cellRangeRenderer. This cellRangeRenderer is what is responsible for generating the style object that is attached to each individual cell. The default cellRangeRenderer uses absolute positioning to accomplish this. I've created a modified cellRangeRenderer that doesn't actually set anything valid in the style object, but rather generates a container for the cells. The container uses absolute positioning to show the cells where they need to be with respect to the scrollbar, but inside the container, each cell is just rendered as-is.

import React from 'react'

/**
 * Default implementation of cellRangeRenderer used by Grid.
 * This renderer supports cell-caching while the user is scrolling.
 */

export default function cellRangeRenderer({
  cellCache,
  cellRenderer,
  columnSizeAndPositionManager,
  columnStartIndex,
  columnStopIndex,
  deferredMeasurementCache,
  horizontalOffsetAdjustment,
  isScrolling,
  isScrollingOptOut,
  parent, // Grid (or List or Table)
  rowSizeAndPositionManager,
  rowStartIndex,
  rowStopIndex,
  styleCache,
  verticalOffsetAdjustment,
  visibleColumnIndices,
  visibleRowIndices,
}) {
  const renderedCells = [];

  // Browsers have native size limits for elements (eg Chrome 33M pixels, IE 1.5M pixes).
  // User cannot scroll beyond these size limitations.
  // In order to work around this, ScalingCellSizeAndPositionManager compresses offsets.
  // We should never cache styles for compressed offsets though as this can lead to bugs.
  // See issue #576 for more.
  const areOffsetsAdjusted = columnSizeAndPositionManager.areOffsetsAdjusted() || rowSizeAndPositionManager.areOffsetsAdjusted();

  const canCacheStyle = !isScrolling && !areOffsetsAdjusted;
  let styledBuffer = false
  let bufferStyle, containerStyle

  for (let rowIndex = rowStartIndex; rowIndex <= rowStopIndex; rowIndex++) {
    const rowDatum = rowSizeAndPositionManager.getSizeAndPositionOfCell(rowIndex);

    for (let columnIndex = columnStartIndex; columnIndex <= columnStopIndex; columnIndex++) {
      const columnDatum = columnSizeAndPositionManager.getSizeAndPositionOfCell(columnIndex);
      const isVisible = columnIndex >= visibleColumnIndices.start && columnIndex <= visibleColumnIndices.stop && rowIndex >= visibleRowIndices.start && rowIndex <= visibleRowIndices.stop;
      const key = `${rowIndex}-${columnIndex}`;
      let style;
      // this is the part that bugs out when react-virtualized re-renders part of the what's-showing-now list, rather than the entire what's-showing-now list
      // I'm just grabbing the first cell and assuming it's coordinates are the top of the what's-showing-now list
      if (!styledBuffer) {
        styledBuffer = true
        bufferStyle = {
          position: 'absolute',
          top: 0,
          left: 0,
          height: rowDatum.offset + verticalOffsetAdjustment,
          width: columnDatum.offset + horizontalOffsetAdjustment,
        }
        containerStyle = {
          position: 'absolute',
          top: rowDatum.offset + verticalOffsetAdjustment,
          left: columnDatum.offset + horizontalOffsetAdjustment,
          height: 'auto',
          width: 'auto',
        }
      }

      // Cache style objects so shallow-compare doesn't re-render unnecessarily.
      if (canCacheStyle && styleCache[key]) {
        style = styleCache[key];
      } else if (deferredMeasurementCache && !deferredMeasurementCache.has(rowIndex, columnIndex)) {
      // In deferred mode, cells will be initially rendered before we know their size.
      // Don't interfere with CellMeasurer's measurements by setting an invalid size.
        // Position not-yet-measured cells at top/left 0,0,
        // And give them width/height of 'auto' so they can grow larger than the parent Grid if necessary.
        // Positioning them further to the right/bottom influences their measured size.
        style = {
          height: 'auto',
          left: 0,
          position: 'absolute',
          top: 0,
          width: 'auto'
        };
      } else {
        // I'd go with a completely empty object, but that breaks other parts of react-virtualized that rely, at least, on 'width' being defined
        style = {
          height: 'auto',
          width: 'auto',
        }
        styleCache[key] = style;
      }

      const cellRendererParams = {
        columnIndex,
        isScrolling,
        isVisible,
        key,
        parent,
        rowIndex,
        style
      };

      let renderedCell;

      // Avoid re-creating cells while scrolling.
      // This can lead to the same cell being created many times and can cause performance issues for "heavy" cells.
      // If a scroll is in progress- cache and reuse cells.
      // This cache will be thrown away once scrolling completes.
      // However if we are scaling scroll positions and sizes, we should also avoid caching.
      // This is because the offset changes slightly as scroll position changes and caching leads to stale values.
      // For more info refer to issue #395
      //
      // If isScrollingOptOut is specified, we always cache cells.
      // For more info refer to issue #1028
      if ((isScrollingOptOut || isScrolling) && !horizontalOffsetAdjustment && !verticalOffsetAdjustment) {
        if (!cellCache[key]) {
          cellCache[key] = cellRenderer(cellRendererParams);
        }

        renderedCell = cellCache[key];

        // If the user is no longer scrolling, don't cache cells.
        // This makes dynamic cell content difficult for users and would also lead to a heavier memory footprint.
      } else {
        renderedCell = cellRenderer(cellRendererParams);
      }

      if (renderedCell === null || renderedCell === false) {
        continue;
      }

      if (process.env.NODE_ENV !== 'production') {
        warnAboutMissingStyle(parent, renderedCell);
      }

      renderedCells.push(renderedCell);
    }
  }

  // This is where the new "magic" happens
  return [(
    <div id="0-buffer-at-the-top" key="0-buffer-at-the-top" style={bufferStyle} />
  ), (
    <div id="0-container-at-the-top" key="0-container-at-the-top" style={containerStyle}>
      {renderedCells}
    </div>
  )];
}

function warnAboutMissingStyle(parent, renderedCellParam) {
  let renderedCell = renderedCellParam
  if (process.env.NODE_ENV !== 'production') {
    if (renderedCell) {
      // If the direct child is a CellMeasurer, then we should check its child
      // See issue #611
      if (renderedCell.type && renderedCell.type.__internalCellMeasurerFlag) {
        renderedCell = renderedCell.props.children;
      }

      if (renderedCell && renderedCell.props && renderedCell.props.style === undefined && parent.__warnedAboutMissingStyle !== true) {
        parent.__warnedAboutMissingStyle = true;

        console.warn('Rendered cell should include style property for positioning.');
      }
    }
  }
}

This code started off life as a copy of what is distributed in the npm package (to get around the babel compilation step, somewhat). It has, at least, the following issues:

  • It must be used in a List, not a Grid. A Grid would require that the cells be placed correctly within a Grid (Material-UI Grid, not react-virtualized Grid), rather than just thrown out there.
  • react-virtualized has some optimizations that allow this method to be called on sub-sections of the list, rather than the entire chunk being rendered (fixing this was outside the timebox I was allotted to attempting this fix). This new cellRangeRenderer would work about 90% correctly as-is if this problem were addressed.
  • Because you can expand a row, then scroll, the row size still needs the CellMeasurer to compute heights. Because I'm not applying the height to each individual cell, we need to recompute the "top" height for the container using the heights in a slightly smarter way. This is only broken if you completely scroll past the section where the expanded panel is rendered. It's possible that merely applying the height to the style object would be sufficient, but that is untested. Edit: You would still need to delay the call to measure as your answer implied.
  • Jumping to a specific cell hasn't been tested, and might work, or might not.
fishybell
  • 353
  • 4
  • 9