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

Asynchronous reordering and drop animation #833

Open
a-type opened this issue Jul 14, 2022 · 7 comments
Open

Asynchronous reordering and drop animation #833

a-type opened this issue Jul 14, 2022 · 7 comments

Comments

@a-type
Copy link

a-type commented Jul 14, 2022

I'm building a sortable list, but the logic for reordering items is more complex than an array move and involves querying a local database to determine the appropriate sort key to apply to the dropped item. I can explain the constraints a bit more if needed, but suffice to say there's some logic to await before I actually modify the state of the list.

This works fine in theory (the operation is so fast that the item appears to reorder instantly), but the issue I've run into is that the drop animation for DragOverlay measures the destination position ASAP, and so it catches the 1 or 2 frames before my reorder is applied, and the item animates back to its original position before jumping to its final one.

2022-07-13.19-54-00.mp4

I realize this is a pretty obtuse constraint, but it would be a lot of work to introduce an immediate cached layer over my data to "optimistically" move the item before writing to storage. Before I commit to that I wanted to see if there were any options or deeper configuration I could leverage to fix the issue.

I think ultimately what would help is if I could manually trigger the drop animation as part of my reordering logic. Something like

const [paused, setPaused] = useState(false);

const onDragEnd = async ({ over, active }) => {
  setPaused(true);
  await reorder(over, active);
  setPaused(false);
}

// ...

return (
  <DndContext onDragEnd={onDragEnd}>
    {/* ... */}
    <DragOverlay paused={paused}>{/* ... */}</DragOverlay>
  </DndContext>
);

such that the drop animation wouldn't measure the destination transform until it was unpaused.

For now I've just disabled the drop animation, the difference is fairly subtle in most cases anyway.

@a-type
Copy link
Author

a-type commented Jul 14, 2022

Ok, it would appear I was hasty in my assumption that the async aspect was to blame - I found a way to refactor so that enough data was stored in the dragged item data field to avoid async lookups, but the behavior demonstrated above persists. I may read the source to see if I can determine how that destination position is calculated for the drop animation.

@porkopek
Copy link

I have the same issue. I'm using useliveQuery from Dexie.js (indexeddb) as a state management to storage my elements and it would be nice if we can trigger the moment when the drop animation happens. So after releasing the mouse, wait for an order to trigger the animation, like @a-type proposes.

To make it work currently, I'm storing a copy of the state locally in a variable with useState hook and then writing to db, but this way I need to maintain state in two places.

@a-type
Copy link
Author

a-type commented Jul 14, 2022

It seems to act better if I use onDragOver to pre-emptively move items around as you drag them. Since I have multiple sortable containers this gets a little dicey, since I want to reset the item back to its original spot if you drag over a different container but end up canceling. I think what I'll probably end up doing is storing the original container and position of the item at the start of the drag and forcing it back there if the drag is cancelled, or dropped on nothing, or the item is dropped over a non-sorted container. What I'm trying to avoid there is the user accidentally recategorizing an item when they were trying to drag it to some other place or cancel their gesture altogether.

But overall I'd say try to use onDragOver to resort the item before the gesture even ends - then it's ready for the drop animation by the time it fires.

@a-type
Copy link
Author

a-type commented Jul 14, 2022

Happy to say the revised approach works pretty well! It seems to smooth over a lot of issues I was having at once.

2022-07-13.21-27-11.mp4

When I drag outside of a category I reset the item's data to its snapshotted original state, and it animates back to its original position, even if it was moved into an entirely different category while it was dragged (because changing data in onDragOver moves things mid-flight). Not sure why that animation works, but it does. Oh of course, this animation works because it's literally the behavior I started out wanting to avoid - the DragOverlay animating back to the original location.

Doing that was relatively easy, I just store draggedItemOriginalCategory and draggedItemOriginalSortKey in a simple module-scope object and update it whenever a drag starts. Then in onDrageEnd, if there's no target drop value, I reset the dragged item's data instead of just returning. I also reset it in onDragCancel the same way.

@ghost
Copy link

ghost commented Jun 3, 2023

Happy to say the revised approach works pretty well! It seems to smooth over a lot of issues I was having at once.

2022-07-13.21-27-11.mp4
When I drag outside of a category I reset the item's data to its snapshotted original state, and it animates back to its original position, even if it was moved into an entirely different category while it was dragged (because changing data in onDragOver moves things mid-flight). Not sure why that animation works, but it does. Oh of course, this animation works because it's literally the behavior I started out wanting to avoid - the DragOverlay animating back to the original location.

Doing that was relatively easy, I just store draggedItemOriginalCategory and draggedItemOriginalSortKey in a simple module-scope object and update it whenever a drag starts. Then in onDrageEnd, if there's no target drop value, I reset the dragged item's data instead of just returning. I also reset it in onDragCancel the same way.

Do you have the code for your solution?

I'm the same issues :(

@a-type
Copy link
Author

a-type commented Jun 5, 2023

My app interactions have changed a lot since I wrote that reply, but it would be something like this (using very simple pseudo code for updating the item state, stored in active.data.current):

let originalCategory = null;

function DragDemo() {
	return (
		<Draggable
			onDragStart={({ active }) => {
				// store this at the start of the gesture so you can reset it if the drag is cancelled
				originalCategory = active.data.current.category;
			}}
			onDragOver={({ over, active }) => {
				// immediately move items during the drag to whatever place they are over
				active.data.current.category = over.data.current.id;
			}}
			onDragEnd={({ over, active }) => {
				if (!over) {
					// the drag ended in white space; reset to the original category
					active.data.current.category = originalCategory;
				}
			}}
			onDragCancel={({ active }) => {
				if (active) {
					// same thing
					active.data.current.category = originalCategory;
				}
			}}
		/>
	);
}

In plain language: store the original state when the drag starts, update item state in realtime during the drag using onDragOver, then reset the item state to the original if the drag ends in a cancel or empty space.

The reason I ended up switching away from this is I added Undo to my app, which meant that making these "temporary" immediate changes was no longer a good experience. However, I had also upgraded my custom IndexedDB framework to include instant in-memory changes, so the async problem was no longer a limiting factor anymore.

@alexnault
Copy link

alexnault commented Jul 25, 2024

It seems dnd-kit works fine with local state but not so much with async state.

A solution would be for dnd-kit to wait for onDragEnd to resolve before firing the drop animation.

And then users could write:

function Component() {
  const items = useMyRemoteItems();

  async function onDragEnd({ over, active }) {
    await updateMyRemoveItems(over, active);
  }

  return (
    <DndContext items={items} onDragEnd={onDragEnd}>
      {/* ... */}
    </DndContext>
  );
}

No need for setPaused().

That would be particularly useful with react-query (#921) and optimistic UI.

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

3 participants