Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Auto scroll solution for list while dragging an element #3482

Open
DanielSadovskiy opened this issue Jul 28, 2022 · 13 comments
Open

Auto scroll solution for list while dragging an element #3482

DanielSadovskiy opened this issue Jul 28, 2022 · 13 comments

Comments

@DanielSadovskiy
Copy link

DanielSadovskiy commented Jul 28, 2022

Describe the bug
So, as there are no any actual solutions to the last versions of react-dnd/React, I decided to suggest mine. It resolves the issue of auto scroll list while hovering with dragged item on top/bottom bounds of the list

Reproduction
https://codesandbox.io/s/mystifying-river-er5r9y?file=/src/index.tsx

Steps to reproduce the behavior:

  1. Go to codesandbox
  2. Drag item and hover on top/bottom bounds ( without drop )
  3. It doesn't scroll the list. List viewport is sticky.

Expected behavior
It should scroll the list according to hover mouse position top / bottom

@DanielSadovskiy
Copy link
Author

https://codesandbox.io/s/mystifying-river-er5r9y?file=/src/Container.tsx

My implementation of auto scroll of dragging item hovering on top / bottom bounds of scrollable container. Using useDragDropManager to get drag item in drag-n-drop container provider. We subscribe on any change of offset position of draggable item. Also created custom useScroll hook. The main logic there is scroll behavior and timer which will trigger scroll ( it made for that case if our cursor with draggable item inside top / bounds but listener of subscription have no any offset change )

@DanielSadovskiy DanielSadovskiy changed the title Auto scroll suggestion for list while dragging an element Auto scroll solution for list while dragging an element Aug 3, 2022
@alissaVrk
Copy link

there is a library for this, which worked great for me

https://www.npmjs.com/package/react-dnd-scrolling

@pranavmappoli
Copy link

Is there any solution available for this? without install other library like [react-dnd-scrolling].

@alissaVrk
Copy link

alissaVrk commented Jan 3, 2023

as far as I understand, there is no solution in the react-dnd lib. you can write the code yourself :) and track the mouse position while dragging

@xulww
Copy link

xulww commented Feb 6, 2023

I'm a bit late to the party but I think I came up with a simple (and "hacky") solution to the problem. Basically you need to create 2 functional React components and place them inside your DnD wrapper. Each one of these components will return a div that is a drop target for the draggable item. Position them at the top and at the bottom of the browser window with the help of CSS. By listening for the isOver event you can detect if a draggable item is (hovering) over these 2 fixed drop targets and scroll the page (or the parent container element) accordingly. To finish, render these divs conditionally by listening for the isDragging event on the draggable items (so they are only there if a user is dragging an item i.e. isDragging === true). Code for reference:

const [{ isDragging }, drag, preview] = useDrag({
    ...
    collect: (monitor) => ({
        isDragging: monitor.isDragging(),
    }),
    ...
});
function ScrollTopDropTarget() {
    const scrollUp = () => {
        window.scrollBy(0, -10);
    };

    const scrollTopDropRef = useRef(null);
    const [{ isOver }, drop] = useDrop({
        accept: "your drag item type here",
        collect: (monitor) => ({
            isOver: monitor.isOver(),
        }),
    });

    drop(scrollTopDropRef);

    useEffect(() => {
        const intervalId = setInterval(() => {
            if (isOver) {
                scrollUp();
            } else {
                clearInterval(intervalId);
            }
        }, 10);

        return () => {
            clearInterval(intervalId);
        };
    }, [isOver]);

    return <div ref={scrollTopDropRef} className={styles.scrollTopGuide} />;
}
function ScrollBottomDropTarget() {
    const scrollDown = () => {
        window.scrollBy(0, 10);
    };

    const scrollBottomDropRef = useRef(null);
    const [{ isOver }, drop] = useDrop({
        accept: "your drag item type here",
        collect: (monitor) => ({
            isOver: monitor.isOver(),
        }),
    });

    drop(scrollBottomDropRef);

    useEffect(() => {
        const intervalId = setInterval(() => {
            if (isOver) {
                scrollDown();
            } else {
                clearInterval(intervalId);
            }
        }, 10);

        return () => {
            clearInterval(intervalId);
        };
    }, [isOver]);

    return (
        <div ref={scrollBottomDropRef} className={styles.scrollBottomGuide} />
    );
}
.scrollTopGuide {
    position: fixed;
    z-index: 1010; /* fit to your needs */
    top: 0;
    left: 0;
    right: 0;
    height: 60px;
    width: 100%;
}

.scrollBottomGuide {
    position: fixed;
    z-index: 1010; /* fit to your needs */
    bottom: 0;
    left: 0;
    right: 0;
    height: 60px;
    width: 100%;
}

By no means do I insist that this is a perfect solution but at the very least it is one. Feel free to iterate over it and make it better...

@BrilliantDecision
Copy link

BrilliantDecision commented Jul 7, 2023

Another solution. You must put this code in a component whose parent has dnd context. The meaning is that the top and the bottom have an area 200 pixels high. We track the offset of the mouse, and if the offset fall into these areas, then a scroll occurs.

 // inside container with scrollbar
 const [dragValue, setDragValue] = useState<boolean>(false);
 const dragDropManager = useDragDropManager();
 const monitor = dragDropManager.getMonitor();
 const timerRef = useRef<NodeJS.Timer>();
 const unsubscribeRef = useRef<Unsubscribe>();

 const setScrollIntervall = (speed: number, container: HTMLElement) => {
   timerRef.current = setInterval(() => {
     container.scrollBy(0, speed);
   }, 1);
 };

useEffect(() => {
    if (dragValue) {
      unsubscribeRef.current = monitor.subscribeToOffsetChange(() => {
        const offset = monitor.getClientOffset();
        const container = document.getElementById("main-container"); // container with scrollbar

        if (!offset || !container) return;

        if (offset.y < container.clientHeight / 2 - 200) {
          if (timerRef.current) clearInterval(timerRef.current);
          setScrollIntervall(-5, container);
        } else if (offset.y > container.clientHeight / 2 + 200) {
          if (timerRef.current) clearInterval(timerRef.current);
          setScrollIntervall(5, container);
        } else if (
          offset.y > container.clientHeight / 2 - 200 &&
          offset.y < container.clientHeight / 2 + 200
        ) {
          if (timerRef.current) clearInterval(timerRef.current);
        }
      });
    } else if (unsubscribeRef.current) {
      if (timerRef.current) clearInterval(timerRef.current);
      unsubscribeRef.current();
    }
  }, [dragValue, monitor]);

  useEffect(() => {
    const unsubscribe = monitor.subscribeToStateChange(() => {
      if (monitor.isDragging()) setDragValue(() => true);
      else if (!monitor.isDragging()) setDragValue(() => false);
    });

    return () => {
      unsubscribe();
    };
  }, [monitor]);

@maxmilianf
Copy link

@BrilliantDecision What is the Unsubscribe type you are using in the unsubscribe ref, please?

@BrilliantDecision
Copy link

@maxmilianf
import { Unsubscribe } from "redux";

@yelnyafacee
Copy link

Another solution. You must put this code in a component whose parent has dnd context. The meaning is that the top and the bottom have an area 200 pixels high. We track the offset of the mouse, and if the offset fall into these areas, then a scroll occurs.

 // inside container with scrollbar
 const [dragValue, setDragValue] = useState<boolean>(false);
 const dragDropManager = useDragDropManager();
 const monitor = dragDropManager.getMonitor();
 const timerRef = useRef<NodeJS.Timer>();
 const unsubscribeRef = useRef<Unsubscribe>();

 const setScrollIntervall = (speed: number, container: HTMLElement) => {
   timerRef.current = setInterval(() => {
     container.scrollBy(0, speed);
   }, 1);
 };

useEffect(() => {
    if (dragValue) {
      unsubscribeRef.current = monitor.subscribeToOffsetChange(() => {
        const offset = monitor.getClientOffset();
        const container = document.getElementById("main-container"); // container with scrollbar

        if (!offset || !container) return;

        if (offset.y < container.clientHeight / 2 - 200) {
          if (timerRef.current) clearInterval(timerRef.current);
          setScrollIntervall(-5, container);
        } else if (offset.y > container.clientHeight / 2 + 200) {
          if (timerRef.current) clearInterval(timerRef.current);
          setScrollIntervall(5, container);
        } else if (
          offset.y > container.clientHeight / 2 - 200 &&
          offset.y < container.clientHeight / 2 + 200
        ) {
          if (timerRef.current) clearInterval(timerRef.current);
        }
      });
    } else if (unsubscribeRef.current) {
      if (timerRef.current) clearInterval(timerRef.current);
      unsubscribeRef.current();
    }
  }, [dragValue, monitor]);

  useEffect(() => {
    const unsubscribe = monitor.subscribeToStateChange(() => {
      if (monitor.isDragging()) setDragValue(() => true);
      else if (!monitor.isDragging()) setDragValue(() => false);
    });

    return () => {
      unsubscribe();
    };
  }, [monitor]);

hi, do you mind creating a codesandbox for this?

@yelnyafacee
Copy link

I'm a bit late to the party but I think I came up with a simple (and "hacky") solution to the problem. Basically you need to create 2 functional React components and place them inside your DnD wrapper. Each one of these components will return a div that is a drop target for the draggable item. Position them at the top and at the bottom of the browser window with the help of CSS. By listening for the isOver event you can detect if a draggable item is (hovering) over these 2 fixed drop targets and scroll the page (or the parent container element) accordingly. To finish, render these divs conditionally by listening for the isDragging event on the draggable items (so they are only there if a user is dragging an item i.e. isDragging === true). Code for reference:

const [{ isDragging }, drag, preview] = useDrag({
    ...
    collect: (monitor) => ({
        isDragging: monitor.isDragging(),
    }),
    ...
});
function ScrollTopDropTarget() {
    const scrollUp = () => {
        window.scrollBy(0, -10);
    };

    const scrollTopDropRef = useRef(null);
    const [{ isOver }, drop] = useDrop({
        accept: "your drag item type here",
        collect: (monitor) => ({
            isOver: monitor.isOver(),
        }),
    });

    drop(scrollTopDropRef);

    useEffect(() => {
        const intervalId = setInterval(() => {
            if (isOver) {
                scrollUp();
            } else {
                clearInterval(intervalId);
            }
        }, 10);

        return () => {
            clearInterval(intervalId);
        };
    }, [isOver]);

    return <div ref={scrollTopDropRef} className={styles.scrollTopGuide} />;
}
function ScrollBottomDropTarget() {
    const scrollDown = () => {
        window.scrollBy(0, 10);
    };

    const scrollBottomDropRef = useRef(null);
    const [{ isOver }, drop] = useDrop({
        accept: "your drag item type here",
        collect: (monitor) => ({
            isOver: monitor.isOver(),
        }),
    });

    drop(scrollBottomDropRef);

    useEffect(() => {
        const intervalId = setInterval(() => {
            if (isOver) {
                scrollDown();
            } else {
                clearInterval(intervalId);
            }
        }, 10);

        return () => {
            clearInterval(intervalId);
        };
    }, [isOver]);

    return (
        <div ref={scrollBottomDropRef} className={styles.scrollBottomGuide} />
    );
}
.scrollTopGuide {
    position: fixed;
    z-index: 1010; /* fit to your needs */
    top: 0;
    left: 0;
    right: 0;
    height: 60px;
    width: 100%;
}

.scrollBottomGuide {
    position: fixed;
    z-index: 1010; /* fit to your needs */
    bottom: 0;
    left: 0;
    right: 0;
    height: 60px;
    width: 100%;
}

By no means do I insist that this is a perfect solution but at the very least it is one. Feel free to iterate over it and make it better...

hi, do you have a codesandbox example for this?

@BrilliantDecision
Copy link

@yelnyafacee https://codesandbox.io/s/autoscroll-react-dnd-83yqmf

@MariaIsabel68
Copy link

@BrilliantDecision worked just fine, thanks!

@mstosio
Copy link

mstosio commented Oct 12, 2023

I had a different need that required me to use a window as a drag-and-drop container. There was a problem: when I dropped an element on the bottom/top interval, it didn't stop Anyone has better idea? Here is potential solution:

    const [dragValue, setDragValue] = React.useState<boolean>(false);
    const dragDropManager = useDragDropManager();
    const monitor = dragDropManager.getMonitor();
    const timerRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
    const unsubscribeRef = useRef<Unsubscribe>();

    const setScrollIntervall = (speed: number, container: Window) => {
        timerRef.current = setInterval(() => {
            if(!monitor.isDragging()){
                clearInterval(timerRef.current)
            }
            container.scrollBy(0, speed);
        }, 1);
    };

    React.useEffect(() => {
        if (dragValue) {
            unsubscribeRef.current = monitor.subscribeToOffsetChange(() => {
                const offset = monitor.getClientOffset();
                const container = window

                if (!offset || !container) return;

                if (offset.y < container.innerHeight / 2 - 400) {
                    if (timerRef.current) clearInterval(timerRef.current);

                    setScrollIntervall(-5, container);
                    return
                } else if (offset.y > container.innerHeight / 2 + 200) {
                    if (timerRef.current) clearInterval(timerRef.current);

                    setScrollIntervall(5, container);
                    return
                } else if (
                    offset.y > container.innerHeight / 2 - 400 &&
                    offset.y < container.innerHeight / 2 + 200
                ) {
                    if (timerRef.current) clearInterval(timerRef.current);
                }

            });
        } else if (unsubscribeRef.current) {
            clearInterval(timerRef.current);
            unsubscribeRef.current();
        }
    }, [dragValue, monitor]);

    React.useEffect(() => {
        const unsubscribe = monitor.subscribeToStateChange(() => {
            if (monitor.isDragging()) setDragValue(() => true);
            else if (!monitor.isDragging()) setDragValue(() => false);
        });

        return () => {
            clearInterval(timerRef.current)
            unsubscribe();
        };
    }, [monitor]);
};`


Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

9 participants