clauderic / react-tiny-virtual-list

A tiny but mighty 3kb list virtualization library, with zero dependencies 💪 Supports variable heights/widths, sticky items, scrolling to index, and more!
https://clauderic.github.io/react-tiny-virtual-list/
MIT License
2.46k stars 165 forks source link

I have to force render the list again, if the list item heights are dynamic #52

Closed marsonmao closed 6 years ago

marsonmao commented 6 years ago
  1. The list item heights are dynamic and are only known after their first render, so I collect the heights in the list item componentDidMount() and cache the heights.
  2. The list props itemSize={this.getItemSize} returns the cached heights.
  3. However itemSize is invoked before step 1 (it is invoked inside this.getStyle(index)), so the first list render gets the wrong height.
  4. So I now have to do recomputeSizes() and forceUpdate() in the list componentDidUpdate to trigger itemSize again, so that the correct height could be used.
  5. The list items are like varying length texts and images; I'm building a chat history list.

The question: is this necessary? I'd like to know if it's a must to always render twice since the item height can't be known in the first render?

My codes are below for reference:

  componentDidUpdate(prevProps, prevState, snapshot) {
    //...based on some conditions I do the following
    this.li.recomputeSizes();
    this.li.forceUpdate();
  }
  getItemSize = (index) => {
    const { chatObjects } = this.props;
    const chatObject = chatObjects[index];
    return this.heightCache[chatObject.messageId] || 40;
  }
  renderItem = ({ index, style }) => {
    const { chatObjects } = this.props;
    const chatObject = chatObjects[index];
    return (
      <div key={chatObject.messageId} style={style}>
        <ChatObject
          key={chatObject.messageId}
          chatObject={chatObject}
          onDidMount={(height) => { this.heightCache[chatObject.messageId] = height; }}
          onDidUpdate={(height) => { this.heightCache[chatObject.messageId] = height; }}
        />
      </div>
    );
  }
  render() {
    return (
          <VirtualList
            ref={this.addVirtualList}
            width={width}
            height={height}
            itemCount={chatObjects.length}
            itemSize={this.getItemSize}
            renderItem={this.renderItem}
            onItemsRendered={this.onItemsRendered}
            onScroll={this.onScroll}
            {...scrollToIndex}
            overscanCount={20}
          />
    );
  }
marsonmao commented 6 years ago

I'm sharing part of my codes to demonstrate how to overcome dynamic height items:

  componentDidMount() {
    if (this.props.chatObjects.length !== 0) {
      this.setState({
        scrollToIndex: this.props.chatObjects.length - 1,
      });
      // NOTE should 100% be true
      this.checkIfRerender();
    }
  }

  componentDidUpdate(prevProps, prevState, snapshot) {
    if (
      prevProps.chatObjects.length !== this.props.chatObjects.length &&
      this.enableAutoScroll
    ) {
      this.setState({
        scrollToIndex: this.props.chatObjects.length - 1,
      });
    }

    if (
      prevProps.chatObjects.length !== this.props.chatObjects.length &&
      this.isFetching
    ) {
      this.isFetching = false;
      this.setState({
        scrollToIndex: this.props.chatObjects.length - prevProps.chatObjects.length,
      });
    }

    if (prevProps.hasMoreChatObjects && !this.props.hasMoreChatObjects) {
      this.isFetching = false;
    }

    this.checkIfRerender();
  }

  checkIfRerender = () => {
    let needRerender = false;
    Object.entries(this.itemCache).forEach(([id, item]) => {
      if (item.fresh) {
        this.itemCache[id].fresh = false;
        needRerender = true;
      }
    });

    if (needRerender) {
      this.li.recomputeSizes();
      this.li.forceUpdate();
      this.forceUpdate();
      if (this.state.scrollToIndex !== null) {
        // HACK 100% relying on tiny-virtual-list's codes
        this.li.setState({
          offset: this.li.getOffsetForIndex(this.state.scrollToIndex, undefined, this.props.chatObjects.length),
          scrollChangeReason: 'requested',
        });
      }
    }
  }

  getItemSize = (index) => {
    const { chatObjects } = this.props;
    const chatObject = chatObjects[index];
    return (this.itemCache[chatObject.messageId] && this.itemCache[chatObject.messageId].height) || magicItemDefaultHeight;
  }

  onItemsRendered = ({ startIndex, stopIndex }) => {
    // HACK setTimeout is to avoid being affected by scroll event
    setTimeout(() => { this.checkIfRerender(); }, 0);
  }

  setItem = (id, height) => {
    let item = this.itemCache[id] || {};
    if (
      !item.height ||
      (item.height && item.height !== height)
    ) {
      item = {
        height,
        fresh: true,
      };
    }
    this.itemCache[id] = item;
  }

  renderItem = ({ index, style }) => {
    const { chatObjects, chatStatus } = this.props;
    const chatObject = chatObjects[index];
    return (
      <div key={chatObject.messageId} style={style}>
        <ChatObject
          key={chatObject.messageId}
          chatStatus={chatStatus}
          chatObject={chatObject}
          onDidMount={(height) => { this.setItem(chatObject.messageId, height); }}
          onDidUpdate={(height) => { this.setItem(chatObject.messageId, height); }}
        />
      </div>
    );
  }

render() {
    const { chatObjects } = this.props;

    if (chatObjects.length === 0) {
      return <div style={{ width: '100%', height: '100%' }} />;
    }

    return (
      <AutoSizer>
        {({ width, height }) => (
          <VirtualList
            ref={this.addVirtualList}
            width={width}
            height={height}
            itemCount={chatObjects.length}
            itemSize={this.getItemSize}
            renderItem={this.renderItem}
            onItemsRendered={this.onItemsRendered}
            onScroll={this.onScroll}
            scrollToIndex={this.state.scrollToIndex}
            overscanCount={20}
          />
        )}
      </AutoSizer>
    );
  }
clauderic commented 6 years ago

react-tiny-virtual-list uses PureComponent, and as such, it has no way to know when the sizes of your items changes when you use a function to get the item sizes.

You can call forceUpdate on the instance or pass it an extra prop that changes whenever the size of your items changes.

See https://github.com/clauderic/react-tiny-virtual-list#common-issues-with-purecomponent

marsonmao commented 6 years ago

Thank you @clauderic . Just want to make sure my solution is correct because I think it's really uncommon to use forceUpdate in componentDidMount and componentDidUpdate. Also want to make sure that I correctly use recomputeSizes. Hope my codes could help other people who also have dynamic height items, this is so far the minimal setup I could create.