petyosi / react-virtuoso

The most powerful virtual list component for React
https://virtuoso.dev
MIT License
5.24k stars 301 forks source link

Animated item positions example #115

Open jmeistrich opened 4 years ago

jmeistrich commented 4 years ago

I made an animated Virtuoso list using framer-motion and I thought you might like to use it as an example. It makes the ItemContainer an animated motion.div, and uses a feature in framer-motion 2 beta that animates items automatically when their DOM position changes.

It's slightly complex because it requires disabling animations during scroll, or it conflicts with Virtuoso's position algorithms and elements jump around wildly. And it requires computeItemKey to ensure Virtuoso reuses the same item elements.

It would ideally use scrollingStateChange to disable animations, but that's currently being triggered by the list changing (https://github.com/petyosi/react-virtuoso/issues/114) so I'm using rangeChanged as a workaround.

I made a StackBlitz for you based on your prepend example (https://stackblitz.com/edit/react-je5vjv?file=example.js), but here's the code inline too:

import React, { useState, useRef, useCallback, useContext } from 'react'
import { Virtuoso } from 'react-virtuoso'
import { getUser } from './FakeData'
import { UserItem } from './ExampleComponents'
import { motion } from 'framer-motion'

const Context = React.createContext({
  animationEnabled: true
});

// This uses the context to set animate back to true when scrolling ends, because
// framer-motion will only animate changes if animate was already set before the change
const AnimatedContainer = props => {
  const { animationEnabled } = useContext(Context);

  return (
    <motion.div {...props} animate={animationEnabled ? true : undefined} />
  )
}

export default () => {
  const virtuoso = useRef(null)
  const timeoutScrolling = useRef(0)
  const initialIndexOffset = useRef(10000)
  const [isScrolling, setIsScrolling] = useState(false)
  const [users, setUsers] = useState(
    Array(200)
      .fill(true)
      .map((_, index) => getUser(10000 + index))
  )

  // Disable animation on scroll or Virtuoso will break while scrolling
  const onRangeChanged = useCallback(() => {
    setIsScrolling(true)
    if (timeoutScrolling.current)
    {
      clearTimeout(timeoutScrolling.current)
    }
    timeoutScrolling.current = setTimeout(() => setIsScrolling(false), 200)
  })

  // Prepend 2 items
  const prependItems = useCallback(() => {
    const usersToPrepend = 2
    initialIndexOffset.current -= usersToPrepend
    setUsers([
      ...Array(usersToPrepend)
        .fill(true)
        .map((_, index) =>
          getUser(initialIndexOffset.current + index)
        ),
      ...users,
    ])

    return false
  }, [initialIndexOffset, users, setUsers])

  // Swap items 1 and 4
  const swapItems = useCallback(() => {
    const cloned = [].concat(users);
    const itemAt1 = cloned[1];
    const itemAt4 = cloned[4];
    cloned.splice(4, 1, itemAt1)
    cloned.splice(1, 1, itemAt4)
    setUsers(cloned)

    return false
  }, [initialIndexOffset, users, setUsers])

  // Insert an item at 2
  const insertItem = useCallback(() => {
    initialIndexOffset.current -= 1
    const cloned = [].concat(users);
    cloned.splice(2, 0, getUser(initialIndexOffset.current))
    setUsers(cloned)

    return false
  }, [initialIndexOffset, users, setUsers])

  // computeItemKey is necessary for animation to ensure Virtuoso reuses the same elements
  const computeItemKey = useCallback((index) => {
    return users[index].name
  }, [users])

  return (
    <div style={{ display: 'flex' }}>
      <div>
      <Context.Provider value={{ animationEnabled: !isScrolling }}>
        <Virtuoso
          ref={virtuoso}
          totalCount={users.length}
          rangeChanged={onRangeChanged}
          computeItemKey={computeItemKey}
          item={index => (
            <UserItem user={users[index]} index={index} />
          )}
          style={{ height: '400px', width: '350px' }}
          ItemContainer={AnimatedContainer}
        />
        </Context.Provider>
      </div>
      <div>
        <ul className="knobs">
          <li>
            <button onClick={prependItems}>Prepend 2 items</button>
          </li>
          <li>
            <button onClick={swapItems}>Swap items</button>
          </li>
          <li>
            <button onClick={insertItem}>Insert 1 item</button>
          </li>
        </ul>
      </div>
    </div>
  )
}
petyosi commented 4 years ago

Thanks! I will look into #114 first, and then pick that for the docs.

jmeistrich commented 4 years ago

Now that you fixed #114 I updated the linked StackBlitz to use scrollingStateChange instead of rangeChanged, and it doesn't need he timeout anymore.

khuezy commented 1 year ago

Thanks for the example @jmeistrich . How would you use layoutId with this? The item jumps all over the place.

jmeistrich commented 1 year ago

@khuezy Unfortunately it's been three years since I made that example and haven't used this library in a while, so I don't know, sorry!

khuezy commented 1 year ago

No worries thanks for replying!