framer / motion

Open source, production-ready animation and gesture library for React
https://framer.com/motion
MIT License
23.37k stars 783 forks source link

[FEATURE] allow scrolling of parent div with Reorder Items #1339

Open augustblack opened 2 years ago

augustblack commented 2 years ago

Is your feature request related to a problem? Please describe. A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]

There is an issue when a Reorder.Group has a number of items that overflows out of the Reorder.Group or a parent element.

see here: https://codesandbox.io/s/framer-motion-5-drag-to-reorder-lists-forked-beoy0

I would expect that when I drag a visible item towards the bottom of the Reorder.Group or parent div, that it would scroll with the cursor so that you can position the element.

Describe the solution you'd like

I'm not sure what the solution is. Would it be possible to use the drag controls somehow to achieve this?

Describe alternatives you've considered

One solution is to instead use dnd-kit that handles this.

thanks for the great library and for your consideration!

rbourdon commented 2 years ago

You can add layoutScroll to your scrolling parent element to fix this. https://codesandbox.io/s/framer-motion-5-drag-to-reorder-lists-forked-u3v91?file=/src/App.tsx:486-498

augustblack commented 2 years ago

Hi , thanks so much for taking a look!

I'm not sure I understand though. It doesn't appear like the layoutScroll has any effect. I'm still seeing the same issue in your example (unless I am missing something).

When you drag an Item to the bottom or top, I would expect it to auto scroll the parent like this:

https://5fc05e08a4a65d0021ae0bf2-ejxxqkfwok.chromatic.com/?path=/story/presets-sortable-vertical--scroll-container

rbourdon commented 2 years ago

Hey, sorry I didn't read your issue closely enough, I thought you were talking about the fact that in the example if you make the parent container overflow with scroll, then drag an item around, scroll the window, and try to drag another item, the items in the list jump around erratically and layoutScroll fixes that.

hoangbn commented 2 years ago

I had a solution for this in framer 4, but it does not work in framer 5 with Reorder component. Would be nice to have Reorder scrollable component to behave like in 5fc05e08a4a65d0021ae0bf2-ejxxqkfwok.chromatic.com/?path=/story/presets-sortable-vertical--scroll-container. as it is the behaviour users expect

tienne commented 2 years ago

Is there any update?

ljones87 commented 1 year ago

Has anyone found a workaround besides using Dnd?

MagnusHJensen commented 1 year ago

What is the status on a feature like this or #1493

piotrjanosz commented 1 year ago

Any update on this?

sethdumaguin commented 1 year ago

Are there any update on this issue?

maderesponsively commented 1 year ago

Did anyone come up with some solutions?

acrylicode commented 7 months ago

Any updates on this?

kauffecup commented 3 months ago

In the interim of waiting on a solution for the feature request, has anyone come up with a workaround? Or is this a hard stop?

thatsjonsense commented 3 months ago

I was really hoping this would get properly fixed by now, but here's a hacky workaround we've been using with some success. It reaches into the internals of dragging to mimic the scroll.

import { DragControls, PanInfo, Point, useDragControls } from 'framer-motion'
import { useCallback, useRef } from 'react'

const SCROLL_THRESHOLD = 25 // How close to the edge of the scroll container to start scrolling
const SCROLL_AMOUNT = 5 // How many pixels to move per scroll event

export const useHandleReorderScroll = () => {
  const dragControls = useDragControls()
  const scrollerRef = useRef<HTMLDivElement>(null)
  const scrollStartRef = useRef<number>() // scrollTop when the drag starts
  const dragStartRef = useRef<number>() // y position of the cursor when the drag starts
  const draggingEltControls = useRef<VisualElementDragControls>()

  const onScroll = useCallback((ev: UIEvent) => {
    if (!draggingEltControls.current) return
    const startPoint = getDragStartPoint(draggingEltControls.current)
    const target = ev.target as HTMLElement | null
    if (
      !startPoint ||
      !target ||
      scrollStartRef.current === undefined ||
      dragStartRef.current === undefined
    ) {
      return
    }
    const scrollDistance = target.scrollTop - scrollStartRef.current // Distance from where the drag started
    startPoint.y = dragStartRef.current - scrollDistance // Move the startPoint to account for the scroll
  }, [])

  const onDrag = useCallback((ev: Event, info: PanInfo) => {
    const scrollContainer = scrollerRef.current
    if (!scrollContainer) return
    const scrollContainerRect = scrollContainer.getBoundingClientRect()
    const dragPoint = info.point.y

    // Check if target is the last elt in its parent container
    const eventTarget = ev.target
    if (!(eventTarget instanceof Element)) return

    const item = eventTarget.closest('[draggable]')
    const parent = item?.parentElement
    if (!parent) return

    if (
      dragPoint < scrollContainerRect.top + SCROLL_THRESHOLD &&
      (item !== parent.firstElementChild || scrollContainer.scrollTop > 0)
    ) {
      // User is dragging card to the top of the scroll container
      scrollContainer.scrollTop -= SCROLL_AMOUNT
    } else if (
      dragPoint > scrollContainerRect.bottom - SCROLL_THRESHOLD &&
      (item !== parent.lastElementChild ||
        scrollContainer.scrollTop < scrollContainer.scrollHeight)
    ) {
      // User is dragging card to the bottom of the scroll container
      scrollContainer.scrollTop += SCROLL_AMOUNT
    }
  }, [])

  // Track the scroll distance by capturing scrollTop when the drag starts
  const onDragStart = useCallback(() => {
    const scroller = scrollerRef.current
    const controls = findDraggingElementControls(dragControls)
    if (!scroller || !controls) return
    draggingEltControls.current = controls
    scrollStartRef.current = scroller.scrollTop
    const startPoint = getDragStartPoint(controls)
    if (!startPoint) return
    dragStartRef.current = startPoint.y
  }, [dragControls])

  const onDragEnd = useCallback(() => {
    scrollStartRef.current = undefined
    dragStartRef.current = undefined
    draggingEltControls.current = undefined
  }, [])

  return {
    // On the scrolling container
    scrollerRef,
    onScroll,
    // On the item
    dragControls,
    onDrag,
    onDragStart,
    onDragEnd,
  }
}

// A private Framer class that we're just using one little piece of
// https://github.com/framer/motion/blob/fb227f8b700c6e2d9c3b33d0a2af1a2d2b7849e9/packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts#L53
type VisualElementDragControls = {
  panSession: {
    history: Point[]
  }
}

const findDraggingElementControls = (dragControls: DragControls) => {
  try {
    return Array.from<VisualElementDragControls>(
      // @ts-ignore - we're reaching into a private prop
      // https://github.com/framer/motion/blob/fb227f8b700c6e2d9c3b33d0a2af1a2d2b7849e9/packages/framer-motion/src/gestures/drag/use-drag-controls.ts#L29
      dragControls.componentControls
    ).find((c: any) => c.isDragging)
  } catch (err) {
    // If this private prop moves in the future, we'll start logging errors here
    console.error('[caught] findDraggingElementControls', err)
    return
  }
}

const getDragStartPoint = (
  controls: VisualElementDragControls
): Point | undefined => {
  try {
    // https://github.com/framer/motion/blob/fb227f8b700c6e2d9c3b33d0a2af1a2d2b7849e9/packages/framer-motion/src/gestures/pan/PanSession.ts#L257
    return controls.panSession.history[0]
  } catch (err) {
    // If this private prop moves in the future, we'll start logging errors here
    console.error('[caught] getDraggingElementStartPoint', err)
    return
  }
}
kauffecup commented 3 months ago

@thatsjonsense confirming that that did the trick! thank you!

ajayvignesh01 commented 1 month ago

@thatsjonsense Where would you call this hook from? I have my Reorder.Group in the parent, and Reorder.Item as a child under a map. When I put your hook in the parent, it seems like all the child Reorder.Items are getting the same dragControls and therefore only the first Reorder.Item is moving.