dnd-kit icon indicating copy to clipboard operation
dnd-kit copied to clipboard

React 18: Autoscroll is broken with multiple horizontal drop zones which have vertical scroll

Open ThomDevine opened this issue 1 year ago • 10 comments

Hi,

I'm seeing this behavior only after updating to React 18. I was using 6.0.5, updating dnd-kit to 6.0.8 does not resolve this.

Setup

I have a horizontal, scrollable list of "useDroppable" columns. Each column contains a vertical list of "useDraggable" items. When the column contents' CSS overflow is set to auto, scroll, or hidden, this behavior occurs. If I change it to none, scrolling works as it did previously.

Behavior

When I try to drag an item from one column to another, as soon as the item is over a different drop zone, the main container (of columns) continues to scroll horizontally all the way to the end. I cannot drag in the other direction to scroll it back.

Changing DndContext's autoScroll with various options of order and acceleration has no effect.

Sandbox Eample

https://codesandbox.io/s/stoic-robinson-wgcrcx?file=/src/App.jsx There are no handlers attached to DndContext, no underlying data is changed, so this appears to be contained within autoScroll.

Try dragging from a middle column to see the "unable to reverse" behavior. Search for "changeme" in App.js to find the relevant style change in the Column component. In my app, this style is set by a CSS file instead of inline, and the behavior is the same.

Note: I'm not using Sortable here because the column contents have a strict order defined by the server. https://github.com/clauderic/dnd-kit/issues/1098 might be related, but that's for Sortable so I'm not sure.

ThomDevine avatar Apr 20 '23 19:04 ThomDevine

I have confirmed that by loading my app the old way, with render(app, mountPoint) from the "react-dom" package to get React 17 behavior, this bug goes away. This is not a valid workaround though, as this method is not supported in React 18.

ThomDevine avatar Apr 25 '23 19:04 ThomDevine

I encountered the same issue. Does anyone have a solution for React 18?

csc-bo avatar May 26 '23 14:05 csc-bo

Same here. @ThomDevine have you found a workaround yet?

alexdonets avatar Jun 27 '23 13:06 alexdonets

@alexdonets No, we are still stuck on React 17 because of this issue.

I poked around in the library code to try and figure it out, which is how I came across the order option for autoScroll; it's not found in the docs. I expected setting that to TreeOrder.ReversedTreeOrder to work, that seems like what it's designed for, but it didn't.

In our use case, some columns might have 3 items and others 300, so we need this functionality. I have no idea why React 18's way of rendering breaks this.

ThomDevine avatar Jun 28 '23 15:06 ThomDevine

has same issue

murrayee avatar Aug 30 '23 07:08 murrayee

I've just encountered this bug as well and can't find anyway work around.

Innders avatar Aug 30 '23 10:08 Innders

So this is my super hacky DIY fix.

1. First disable auto scroll

<DndContext autoScroll={false}>
   <ScrollableSection />
</DndContext>

2. Make sure your horizontal scrollable element is inside DndContext in another component like ScrollableSection.

3. Inside of ScrollableSection do your auto scrolling

const ScrollableSection = ({columns}) => {
  const { active } = useDndContext()
  const sectionRef = useRef(null)

  const [scrollDirection, setScrollDirection] = useState(null)

  // this scrolls the section based on the direction
  useEffect(() => {
    if (!scrollDirection) return

    const el = sectionRef.current
    if (!el) return

    const speed = 10

    const intervalId = setInterval(() => {
      el.scrollLeft += speed * scrollDirection
    }, 5)

    return () => {
      clearInterval(intervalId)
    }
  }, [scrollDirection, sectionRef.current])

  // if we are dragging, detect if we are near the edge of the section
  useEffect(() => {
    const handleMouseMove = (event) => {
      const el = sectionRef.current
      if (!active || !el) return
      const isOverflowing = el.scrollWidth > el.clientWidth
      if (!isOverflowing) return

      // get bounding box of the section
      const { left, right } = el.getBoundingClientRect()
      // xPos of the mouse
      const xPos = event.clientX
      const threshold = 200

      const newScrollDirection = xPos < left + threshold ? -1 : xPos > right - threshold ? 1 : null
      if (newScrollDirection !== scrollDirection) {
        setScrollDirection(newScrollDirection)
      }
    }
    if (active) {
      window.addEventListener('mousemove', handleMouseMove)
    } else {
      window.removeEventListener('mousemove', handleMouseMove)
      setScrollDirection(null)
    }
    return () => {
      window.removeEventListener('mousemove', handleMouseMove)
      setScrollDirection(null)
    }
  }, [active, sectionRef.current])

  return (
    <div
      style={{
        height: '100%',
        width: '100%',
        display: 'flex',
        overflowX: 'auto',
      }}
      ref={sectionRef}
    >
      {columns.map(({ id }) => {
        return (
          <Column
            key={id}
            id={id}
          />
        )
      })}
    </div>
  )
}

You might want to do some throttling on that mouse event listener to improve performance.

If anyone has any other way of cleaning this up please feel free to!

Innders avatar Aug 30 '23 13:08 Innders

So this is my super hacky DIY fix.

1. First disable auto scroll

<DndContext autoScroll={false}>
   <ScrollableSection />
</DndContext>

2. Make sure your horizontal scrollable element is inside DndContext in another component like ScrollableSection.

3. Inside of ScrollableSection do your auto scrolling

const ScrollableSection = ({columns}) => {
  const { active } = useDndContext()
  const sectionRef = useRef(null)

  const [scrollDirection, setScrollDirection] = useState(null)

  // this scrolls the section based on the direction
  useEffect(() => {
    if (!scrollDirection) return

    const el = sectionRef.current
    if (!el) return

    const speed = 10

    const intervalId = setInterval(() => {
      el.scrollLeft += speed * scrollDirection
    }, 5)

    return () => {
      clearInterval(intervalId)
    }
  }, [scrollDirection, sectionRef.current])

  // if we are dragging, detect if we are near the edge of the section
  useEffect(() => {
    const handleMouseMove = (event) => {
      const el = sectionRef.current
      if (!active || !el) return
      const isOverflowing = el.scrollWidth > el.clientWidth
      if (!isOverflowing) return

      // get bounding box of the section
      const { left, right } = el.getBoundingClientRect()
      // xPos of the mouse
      const xPos = event.clientX
      const threshold = 200

      const newScrollDirection = xPos < left + threshold ? -1 : xPos > right - threshold ? 1 : null
      if (newScrollDirection !== scrollDirection) {
        setScrollDirection(newScrollDirection)
      }
    }
    if (active) {
      window.addEventListener('mousemove', handleMouseMove)
    } else {
      window.removeEventListener('mousemove', handleMouseMove)
      setScrollDirection(null)
    }
    return () => {
      window.removeEventListener('mousemove', handleMouseMove)
      setScrollDirection(null)
    }
  }, [active, sectionRef.current])

  return (
    <div
      style={{
        height: '100%',
        width: '100%',
        display: 'flex',
        overflowX: 'auto',
      }}
      ref={sectionRef}
    >
      {columns.map(({ id }) => {
        return (
          <Column
            key={id}
            id={id}
          />
        )
      })}
    </div>
  )
}

You might want to do some throttling on that mouse event listener to improve performance.

If anyone has any other way of cleaning this up please feel free to!

Thanks to @Innders for the solution, I also wanted to DIY my own scroll, but my business is that there are multiple boards, each board requires virtual scrolling vertically, each board has multiple columns, and each column also requires virtual scrolling, It's so hard

murrayee avatar Aug 30 '23 13:08 murrayee

Well I don't know if this "less hacky" or even better performance-wise, but I had to disable vertical autoscrolling, while allowing horizontal autoscrolling to proceed. To disable vertical auto scrolling:

(<DndContext
        autoScroll={{
            threshold: {
                x: 0.2,
                y: 0
            }
        }}
        ...otherProps
>
  ...
  <div>
    <DragOverlay
          style={{
              // important for vertical custom scrolling
              pointerEvents: 'none'
          }}
      >
    </DragOverlay>
  </div>
</DndContext>)

Then to add manual vertical scrolling to a container:

const [isNearTop, setIsNearTop] = useState(false)
const [isNearBottom, setIsNearBottom] = useState(false)
const isDragging = useRef(false)

useDndMonitor({
    onDragStart(e) {
        isDragging.current = true
    },
    onDragEnd(e) {
        isDragging.current = false
    }
})

function handleMouseMove(e: React.MouseEvent<HTMLDivElement>) {
        if (containerRef.current == null) {
            return
        }
        const { top, bottom } = containerRef.current.getBoundingClientRect()
        const nearTop = e.clientY - top < 50
        const nearBottom = bottom - e.clientY < 50

        setIsNearTop(nearTop)
        setIsNearBottom(nearBottom)
    }

    useEffect(() => {
        if (!isDragging.current) {
            return
        }
        const container = containerRef.current
        if (!container) return
    
        const scrollAmount = 20
    
        const scrollContainer = () => {
          if (isNearTop) {
            container.scrollTop -= scrollAmount
          } else if (isNearBottom) {
            container.scrollTop += scrollAmount
          }
        }

        const intervalId = setInterval(scrollContainer, 20)
    
        return () => clearInterval(intervalId)
      }, [isNearTop, isNearBottom])

<div
  onMouseMove={handleMouseMove}
  style={{
    ...overflowHeightAndOtherPropsToEnableScroll
  }}
>
  
</div>

gaburielcasado avatar Feb 09 '24 00:02 gaburielcasado

In order to stop scrolling when the mouse is located in the center. I have added these lines in the useEffect that sets the scroll direction:

else {
    setScrollDirection(null)
}

1. First disable auto scroll

<DndContext autoScroll={false}>
   <ScrollableSection />
</DndContext>

2. Make sure your horizontal scrollable element is inside DndContext in another component like ScrollableSection.

3. Inside of ScrollableSection do your auto scrolling

const ScrollableSection = ({columns}) => {
  const { active } = useDndContext()
  const sectionRef = useRef(null)

  const [scrollDirection, setScrollDirection] = useState(null)

  // this scrolls the section based on the direction
  useEffect(() => {
    if (!scrollDirection) return

    const el = sectionRef.current
    if (!el) return

    const speed = 10

    const intervalId = setInterval(() => {
      el.scrollLeft += speed * scrollDirection
    }, 5)

    return () => {
      clearInterval(intervalId)
    }
  }, [scrollDirection, sectionRef.current])

  // if we are dragging, detect if we are near the edge of the section
  useEffect(() => {
    const handleMouseMove = (event) => {
      const el = sectionRef.current
      if (!active || !el) return
      const isOverflowing = el.scrollWidth > el.clientWidth
      if (!isOverflowing) return

      // get bounding box of the section
      const { left, right } = el.getBoundingClientRect()
      // xPos of the mouse
      const xPos = event.clientX
      const threshold = 200

      const newScrollDirection = xPos < left + threshold ? -1 : xPos > right - threshold ? 1 : null
      if (newScrollDirection !== scrollDirection) {
        setScrollDirection(newScrollDirection)
      //Stops scrolling
      } else { 
       setScrollDirection(null)
      }
    }
    if (active) {
      window.addEventListener('mousemove', handleMouseMove)
    } else {
      window.removeEventListener('mousemove', handleMouseMove)
      setScrollDirection(null)
    }
    return () => {
      window.removeEventListener('mousemove', handleMouseMove)
      setScrollDirection(null)
    }
  }, [active, sectionRef.current])

  return (
    <div
      style={{
        height: '100%',
        width: '100%',
        display: 'flex',
        overflowX: 'auto',
      }}
      ref={sectionRef}
    >
      {columns.map(({ id }) => {
        return (
          <Column
            key={id}
            id={id}
          />
        )
      })}
    </div>
  )
}

hectormartinez-facephi avatar Mar 04 '24 12:03 hectormartinez-facephi