dhilt / vscroll

A JavaScript Virtual Scroll Engine
https://dhilt.github.io/vscroll/
MIT License
39 stars 2 forks source link

React implementation? #48

Open cayasso opened 1 year ago

cayasso commented 1 year ago

UPD Stackblitz demo link: https://stackblitz.com/edit/vscroll-react.


What is the best way to implement in react.

I currently have this, but I cant get the virtual scrolling to work, I just get regular scrolling:

import { useEffect, useRef, useState } from 'react'
import { Workflow } from 'vscroll'

const VirtualScroller = ({ row, get, settings }) => {
  const [data, setData] = useState([])
  const items = useRef([])
  const vscroll = useRef()

  useEffect(() => {
    if (!vscroll.current) return

    const element = vscroll.current

    const datasource = {
      get,
      settings
    }

    const run = newItems => {
      if (!newItems.length && !items.current.length) return
      items.current = newItems
      setData(newItems)
    }

    const workflow = new Workflow({ element, datasource, run })

    return () => {
      workflow.dispose()
    }
  }, [vscroll.current])

  console.log('DATA', items.current)

  return (
    <div id='vieweport' className='w-32 overflow-y-scroll h-[200px]'>
      <div id='vscroll' ref={vscroll}>
        <div data-padding-backward />
        {data.map(row)}
        <div data-padding-forward />
      </div>
    </div>
  )
}

const SETTINGS = {
  bufferSize: 100,
  minIndex: -9999,
  maxIndex: 100000
}

const getData = (index, count, callback) => {
  const data = []
  const start = Math.max(SETTINGS.minIndex, index)
  const end = Math.min(index + count - 1, SETTINGS.maxIndex)
  console.log(
    `request [${index}..${index + count - 1}] -> [${start}..${end}] items`
  )
  if (start <= end) {
    for (let i = start; i <= end; i++) {
      data.push({ index: i, text: `item ${i}` })
    }
  }

  return callback(data)
}

const renderItem = item => {
  return (
    <div className='item' key={item.nodeId}>
      {item.container.data.text}
    </div>
  )
}

export default () => (
  <VirtualScroller
    className='viewport'
    get={getData}
    settings={SETTINGS}
    row={renderItem}
  />
)
dhilt commented 1 year ago

@cayasso There are a few things related to rendering that you haven't implement in your sample. They vaguely described here: https://github.com/dhilt/vscroll#4-run (invisible positioning and data-sid attribute). It can be done by the following change in your code:

const renderItem = item => {
  return (
    <div
      className='item'
      key={item.nodeId}
      data-sid={item.nodeId}
      style={{
        position: item.invisible ? 'fixed' : null,
        left: item.invisible ? '-99999px' : null,
      }}
    >
        {item.container.data.text}
    </div>
  )
}

Also, I posted a stackblitz demo with your approach, please have a look: https://stackblitz.com/edit/vscroll-react. I think I will work on improving it. For example, the Adapter API should be available at the App level... I will post an update here.

cayasso commented 1 year ago

Awesome @dhilt thank you for your quick response, and putting together this example, was really helpful. This is so far my working code:

import { useEffect, useRef, useState } from 'react'
import { Workflow } from 'vscroll'

const consumer = {
  name: 'react-vscroll',
  version: '1.0.0'
}

const VirtualScroller = ({ get, settings }) => {
  const [, rerender] = useState(0)
  const items = useRef([])
  const vscroll = useRef()
  const workflow = useRef()

  useEffect(() => {
    if (!vscroll.current) return

    const element = vscroll.current

    const datasource = {
      get,
      settings
    }

    workflow = new Workflow({
      consumer,
      element,
      datasource,
      run: newItems => {
        if (!newItems.length && !items.current.length) return
        items.current = newItems
        rerender(i => i + 1)
      }
    })

    return () => {
      workflow.dispose()
    }
  }, [vscroll.current])

  // console.log('DATA', items.current)

  const renderItem = item => {
    return (
      <div
        className='item'
        key={item.nodeId}
        data-sid={item.nodeId}
        style={{
          position: item.invisible ? 'fixed' : null,
          left: item.invisible ? '-99999px' : null
        }}
      >
        {item.container.data.text}
      </div>
    )
  }

  return (
    <div id='vieweport' className='w-32 overflow-y-scroll h-[200px]'>
      <div id='vscroll' ref={vscroll}>
        <div data-padding-backward />
        {items.current.map(renderItem)}
        <div data-padding-forward />
      </div>
    </div>
  )
}

const SETTINGS = {
  bufferSize: 10,
  minIndex: 0,
  startIndex: 0,
  maxIndex: 100,
  inverse: true
}

const getData = (index, count, callback) => {
  const data = []
  const start = Math.max(SETTINGS.minIndex, index)
  const end = Math.min(index + count - 1, SETTINGS.maxIndex)
  console.log(
    `request [${index}..${index + count - 1}] -> [${start}..${end}] items`
  )
  if (start <= end) {
    for (let i = start; i <= end; i++) {
      data.push({ index: i, text: `item ${i}` })
    }
  }

  return callback(data)
}

export default () => (
  <VirtualScroller className='viewport' get={getData} settings={SETTINGS} />
)
cayasso commented 1 year ago

@dhilt hey btw I am working on chat application and I am still trying to figure out the best way to do two things with react + vscroll:

1 - Add dynamic items height support. Basically in my chat app, each item (message) can have different height base on content (images, text, etc).

2 - Invert scrolling, basically as any chat app, the last message is actually the first then you going scrolling in reverse order, new messages are appended at the bottom as well.

It would be awesome if you have some thoughts on how to implement this with vscroll, I tried with that inverse flag.

Also, I posted a stackblitz demo with your approach, please have a look: https://stackblitz.com/edit/vscroll-react. I think I will work on improving it. For example, the Adapter API should be available at the App level... I will post an update here. 💯

Sound great love vscroll. Was searching for a simple implementation of virtual infinite scrolling and found vscroll (which is awesome) after reading your article on logrocket.

cayasso commented 1 year ago

I think I have the answer to Point 2 by just setting the startIndex to the maxItem like so?

const SETTINGS = {
  bufferSize: 10,
  minIndex: 0,
  startIndex: 100,
  maxIndex: 100
}
dhilt commented 1 year ago

@cayasso I got your request, it makes sense. So, let me spend some time on it, then I will post an update.

Speaking of the reverse order, I'd recommend to look at the inverted datasource demos:

dhilt commented 1 year ago

I have completed a minimal React-vscroll integration demo, it is available by the same link: https://stackblitz.com/edit/vscroll-react. It provides separation of the App and the Scroller consumer levels. And provides an adapter API which is essential for complex scenarios like chat.

Also, here's the doc describing some basic concepts for the Chat App in terms of vscroll: https://github.com/dhilt/ngx-ui-scroll/wiki/Chat-App. It is for the Angular consumer (powered with RxJs etc), but should be applicable for the React one.

cayasso commented 1 year ago

@dhilt thanks for your previous responses, they been very helpful. Is it posible to actually instead of using get for loading data to actually use it for only querying ad have the data passed in another way for example using something like React Query? I have this requirement but it seems to be incompatible with vscroll. Any suggestions will be greatly appreciated.

The implementation would be something like this:

function MyList () {
  const [query, setQuery] = useState({ index: 0, count: 50 })
  const { data, error, loading } = useQuery(query) // data result

  const loadMore = (index, count) => {
     setQuery({ index, count }) // just used for setting the query
  }

  // need to pass data here as prop then have vscroll consume it same as it would be with get.
   return <VirtualScroller data={data} get={loadMore} settings={SETTINGS} />
}
dhilt commented 1 year ago

@cayasso The vscroll datasource getter is no more than a javascript function, which implementation is fully in your hands. So inside the datasource.get body you need to run querying process and then handle its result in some way. So, in general, there should be no problems with using extra data layers like React Query, but I'm not sure about the implementation, it should be environment-specific. The setQuery state-method can be directly passed and used inside the Datasource.get body, this is for sure. Integration of the use-query data seems more complicated, and might require some additional efforts. One approach might be just a closuring around the data-success entities, like below:

const makeDS = request => {
  const box = { success: void 0 };
  return {
    trigger: data => box.success(data),
    datasource: new Datasource({
      get: (index, count, success) => {
        request({ index, count });
        box.success = success;
      }
    })
  }
};

function MyList () {
  const [{ datasource, trigger }] = useState(makeDS(setQuery));
  const [query, setQuery] = useState({ index: 0, count: 50 });
  const { data, error, loading } = useQuery(query);
  const { adapter } = datasource;

  useEffect(() => trigger(data), [data]);

   return <VirtualScroller datasource={datasource} />
}

Note, the Scroller doesn't need data, it needs the Datasource described in the doc: https://github.com/dhilt/vscroll/wiki/Datasource.

cayasso commented 1 year ago

Awesome @dhilt thank you for the response, here is a working version of the above https://stackblitz.com/edit/vscroll-react-cagypn Now in my real use case useQuery is also a subscription, so it will be emitting data which basically will update data whenever there is a change, data would return the last 50 items as in the MyList example or 12 in the stackblitz example, what is the best way to manage that case to update the scroller, would it be with the adapter by doing an update?

Again thank you for all the help!

dhilt commented 1 year ago

@cayasso So, as I understand it, new data can appear on the app side without the user having to scroll. In this case, I think the relationship between Scroller and useQuery needs to be revisited, since the resulting array can change over time regardless of the state of the Scroller. This means that we need to form a new strategy for the data layer on the app side (useQuery?), which should include 2 scenarios:

Why the second scenario is important... The fact is that when the Scroller reaches the data limit during the user scroll (EOF / BOF), it sets the index boundaries and no longer requests data that are beyond the index limits. The scroller thinks that no more data can appear and scrolling beyond the limit indexes is not possible. We need to inform the Scroller that we have new data that can be queried for new indexes.

Technically, these two scenarios are the scroll (A) and push (B) scenarios from the Chat-App doc (https://github.com/dhilt/ngx-ui-scroll/wiki/Chat-App). We add initialization (connect) to it and we get a good match with the document. What does this mean in practice?

  1. Connect. On the app start , we need to initialize the data layer so that the Scroller at its start would know the boundaries of the available data in order to give the user the ability to navigate through this data by scrolling.

  2. Scroll. We have already implemented the scrolling itself. Might have to rewrite something. But the essence is simple: the Scroller must be able to fetch the required number of indexed items from the App data layer through the Datasource.get method.

  3. Push. The appearance of new data on the data layer should update the UI in a certain way. If, let's say, 50 new items arrive, the user needs to know what can get them. Roughly speaking, the scrollbar should increase, there should be additional space for scrolling beyond the previous limits. To do this, we need to inform the Scroller about new available data through the Adapter API. In particular, if the data layer receives a new 50 elements (at the end of the data array), you can call the Adapter.append method with appropriate arguments. And the Scroller will allow the user to navigate through them. If the user is at the end-of-file, new items will be retrieved and appear immediately. If the user is not at the end-of-file, new items will be virtualized.

Sorry for the longread, I hope the above will be enough to implement your case.

stale[bot] commented 1 year ago

This issue has been automatically marked as stale because it has not had recent activity. It will be closed within seven days if no further activity occurs. If it needs to remain open, add the "permanent" label.