😽

dnd-kitにFLIPアニメーションを組み込む

2023/09/24に公開

始めに

dnd-kitを使ってリストの並び替えをする方法を以前紹介しました。

https://zenn.dev/wintyo/articles/d39841c63cc9c9

これで確かに並び替えはできますが、追加・削除時はアニメーションすることができず、UX的に微妙だなと感じてました。この点でDiscussionにも上がっており、この方法で試してみましたがいくつか気になるところがありました。

https://github.com/clauderic/dnd-kit/discussions/108#discussioncomment-524987

実際に試したコードはこちらになり、以下の点で気になりました。

  • 追加・削除される項目自体にフェードインアニメーションなどを入れることができない
    • 特に一番下に追加する場合はパッと現れている感じになる
  • シャッフルした後に追加とかすると移動アニメーションが変になる

項目の追加・削除時にアニメーションするのはそもそもdnd-kitの領域ではないと思い、むしろFLIPアニメーションライブラリと組み合わせるべきでは?と思ったので、dnd-kitとreact-flip-toolkitを組み合わせて上記の要件を実装しました。

サンプルコード

実際に作ったコードはこちらになります。追加・削除時もアニメーションがされるようになり、大分UXが良くなって個人的には満足です😄

前提

この記事はdnd-kitとreact-flip-toolkitをどう組み合わせるかということに焦点を当てているため、それぞれの使い方はここでは説明しません。それぞれの使い方は以下の記事で書いており、またこれらの記事の応用版という位置付けですので先に見ておくことをオススメします。

https://zenn.dev/wintyo/articles/d39841c63cc9c9
https://zenn.dev/wintyo/articles/0d0bed193e6f80

シンプルなSortableにFLIPアニメーションを組み込む

dnd-kitとreact-flip-toolkitでそれぞれのtransform設定にバッティングしないように注意する必要がありますが、幸いなことにdnd-kitは計算するタイミングをかなり絞っており、デフォルトではドラッグ中以外は計算されないため、項目の追加・削除は問題なくFLIPアニメーションを組み込むことができます。ただしシャッフルだけは例外で、dnd-kitによって並び変わった時とシャッフルボタンを押して並び変わった時でアニメーションを発動させたいライブラリを変える必要があります。ドラッグ直後の並び替えはreact-flip-toolkitのアニメーションが発動しないようにdisableしておきます。

その辺を踏まえた上で、FLIPアニメーション対象の項目をラップするコンポーネントをまず作ります。子要素がコンポーネントの場合はflippedPropsが自動で注入されるのでそれをルート要素に渡すように書く必要がありますが、divをラップしても影響受けない場合はdivでラップしておくとその辺の設定もする必要がなくなるので今回はdivでラップしています。

FlippedItem.tsx
import { FC, ReactNode, useRef } from "react";
import { Flipped, spring } from "react-flip-toolkit";

export type FlippedItemProps = {
  flipId: string | number;
  disabled?: boolean;
  children: ReactNode;
};

export const FlippedItem: FC<FlippedItemProps> = ({
  flipId,
  disabled,
  children
}) => {
  const isExitingRef = useRef(false);
  const appearSpringRef = useRef<ReturnType<typeof spring> | null>(null);

  return (
    <Flipped
      flipId={flipId}
      onAppear={(el) => {
        appearSpringRef.current = spring({
          onUpdate: (val) => {
            if (typeof val !== "number") {
              return;
            }
            el.style.opacity = `${1 * val}`;
            el.style.transform = `translateY(${-30 * (1 - val)}px)`;
          },
          onComplete: () => {
            appearSpringRef.current = null;
          }
        });
      }}
      onStart={() => {
        if (appearSpringRef.current) {
          appearSpringRef.current.destroy();
          appearSpringRef.current = null;
        }
      }}
      onExit={(el, index, removeElement) => {
        isExitingRef.current = true;
        el.style.pointerEvents = "none";

        spring({
          onUpdate: (val) => {
            if (typeof val !== "number") {
              return;
            }
            el.style.opacity = `${1 * (1 - val)}`;
            el.style.transform = `translateY(${-30 * val}px)`;
          },
          onComplete: removeElement
        });
      }}
      shouldFlip={() => {
        return !disabled && !isExitingRef.current;
      }}
    >
      {/* styleを当てやすいように空のdivでラップする */}
      <div>{children}</div>
    </Flipped>
  );
};

これを使ってFLIPアニメーションがされるように組み込むと以下のようになります。

シンプルなSortableにFLIPアニメーションを組み込む
 // importや定数の設定は省略

 export const SimpleSortablePage: FC = () => {
+  const [isDragging, setIsDragging] = useState(false);
   const [items, setItems] = useState(INITIAL_ITEMS);

   return (
     <div>
       {/* ボタン部分は重要ではないので省略 */}
       <DndContext
         collisionDetection={closestCenter}
+        onDragStart={() => {
+          setIsDragging(true);
+        }}
         onDragEnd={(event) => {
           const { active, over } = event;
           if (over == null || active.id === over.id) {
             return;
           }
           const oldIndex = items.findIndex((item) => item.id === active.id);
           const newIndex = items.findIndex((item) => item.id === over.id);
           const newItems = arrayMove(items, oldIndex, newIndex);
           setItems(newItems);
+          // 項目を更新した瞬間はまだドラッグ中にしたいのでワンテンポ遅れてからfalseにする
+          setTimeout(() => {
+            setIsDragging(false);
+          });
         }}
       >
         <SortableContext items={items}>
+          <Flipper
+            // シャッフルがない場合は以下のように個数が変わっただけを条件にするとisDraggingフラグの参照が不要になる
+            // flipKey={items.length}
+            flipKey={items.map((item) => item.id).join(",")}
+          >
             {items.map((item) => (
+              <FlippedItem key={item.id} flipId={item.id} disabled={isDragging}>
                 <SimpleSortableItem
                   item={item}
                   onRemove={() => {
                     setItems(items.filter((it) => it.id !== item.id));
                   }}
                 />
+              </FlippedItem>
             ))}
+          </Flipper>
         </SortableContext>
       </DndContext>
     </div>
   );
 };

プレビュー表示のSortableにFLIPアニメーションを組み込む

DragOverlayを使ったパターンでもdnd-kitのデフォルトのSortableを使う場合はそこまで変更がないので割愛します。DragOverlayで移動先だけ示すプレビュー表示のケースだとアニメーションがdnd-kitとreact-flip-toolsでバッティングすることが多くなるのでそちらは設計を考える必要があります。
そもそもプレビュー表示だとdnd-kit側でtransformをする必要がほぼなく、flipアニメーションライブラリに寄せた方が楽になりそうです。そこで@dnd-kit/sortableを使うのをやめて、単純にdraggableとdroppableを組み合わせて実装します。

useSortableをやめてuseDraggable, useDroppableを使うように書き換えると以下のようになります。

useSortableをやめてuseDraggable, useDroppableを使う
 import { FC } from "react";
 import { clsx } from "clsx";
-import { useSortable, defaultAnimateLayoutChanges } from "@dnd-kit/sortable";
+import { useDraggable, useDroppable } from "@dnd-kit/core";
+import { useCombinedRefs } from "@dnd-kit/utilities";

 import { PreviewSortableSource } from "./PreviewSortableSource";
 import { Item } from "../../types/Item";

 import styles from "./PreviewSortableItem.module.scss";

 export type PreviewSortableItemProps = {
   item: Item;
   index: number;
   onRemove: () => void;
 };

 export const PreviewSortableItem: FC<PreviewSortableItemProps> = ({
   item,
   index,
   onRemove
 }) => {
-  const {
-    isOver,
-    activeIndex,
-    overIndex,
-    isSorting,
-    isDragging,
-    // DOM全体に設定するプロパティ
-    setNodeRef,
-    transform,
-    transition,
-    // つまみ部分に設定するプロパティ
-    setActivatorNodeRef,
-    attributes,
-    listeners
-  } = useSortable({
-    id: item.id,
-    animateLayoutChanges: ({ isSorting }) => !isSorting
-  });

-  const canTransform = !isSorting;
 
+  const {
+    isDragging,
+    active,
+    attributes,
+    listeners,
+    setNodeRef: setDraggableNodeRef,
+    setActivatorNodeRef
+  } = useDraggable({
+    id: item.id,
+    data: {
+      index
+    }
+  });
+  const { isOver, over, setNodeRef: setDroppableNodeRef } = useDroppable({
+    id: item.id,
+    data: {
+      index
+    }
+  });

+  // activeIndex, overIndexはdataから参照するように変更
+  const activeIndex: number = active?.data?.current?.index ?? -1;
+  const overIndex: number = over?.data?.current?.index ?? -1;

   /** どっち向きに並び変わるか */
   const sortDirection =
     activeIndex > overIndex
       ? "before"
       : activeIndex < overIndex
       ? "after"
       : null;

   /** 挿入先を表示するか */
   const isShowIndicator = isOver && sortDirection != null;

+  // 同じ場所にrefを渡す必要があるのでsortableのコードを見てconbineする
+  // https://github.com/clauderic/dnd-kit/blob/%40dnd-kit/sortable%407.0.2/packages/sortable/src/hooks/useSortable.ts#L108
+  const setNodeRef = useCombinedRefs(setDraggableNodeRef, setDroppableNodeRef);

   return (
     <div
       ref={setNodeRef}
       className={clsx(styles.Item, {
         [styles._active]: isShowIndicator,
         [styles._before]: sortDirection === "before",
         [styles._after]: sortDirection === "after"
       })}
       style={{
         opacity: isDragging ? 0.5 : undefined,
-        transform: canTransform ? CSS.Transform.toString(transform) : undefined,
-        transition
       }}
     >
       <PreviewSortableSource
         item={item}
         handlerProps={{
           ref: setActivatorNodeRef,
           attributes,
           listeners
         }}
         onRemove={onRemove}
       />
     </div>
   );
 };

これでdnd-kit側でアニメーションすることがないのでなんの心配もなくreact-flip-toolsに任せられます。シンプルなSortableではドラッグ中はdisabledにするなどの制御を入れていましたが、その必要も無くなりました。

プレビュー表示のSortableにFLIPアニメーションを組み込む
 // importや定数の設定は省略

 export const PreviewSortablePage: FC = () => {
   const [activeId, setActiveId] = useState<number | null>(null);
   const [items, setItems] = useState(INITIAL_ITEMS);

   const activeItem = useMemo(() => {
     return items.find((item) => item.id === activeId);
   }, [activeId, items]);

   return (
     <div>
       {/* ボタン部分は重要ではないので省略 */}
       <DndContext
         collisionDetection={closestCenter}
         onDragStart={(event) => {
           setActiveId(event.active.id as number);
         }}
         onDragEnd={(event) => {
           setActiveId(null);
           const { active, over } = event;
           if (over == null || active.id === over.id) {
             return;
           }
           const oldIndex = items.findIndex((item) => item.id === active.id);
           const newIndex = items.findIndex((item) => item.id === over.id);

-          const newItems = arrayMove(items, oldIndex, newIndex);
-          setItems(newItems);
+          // sortableライブラリのimportをやめられるようにarrayMoveを自前で実装
+          setItems(() => {
+            const newItems = [...items];
+            const movingItem = newItems.splice(oldIndex, 1)[0];
+            newItems.splice(newIndex, 0, movingItem);
+            return newItems;
+          });
         }}
       >
+        <Flipper flipKey={items.map((item) => item.id).join(",")}>
           {items.map((item, index) => (
+            <FlippedItem key={item.id} flipId={item.id}>
               <PreviewSortableItem
                 item={item}
                 index={index}
                 onRemove={() => {
                   setItems(items.filter((it) => it.id !== item.id));
                 }}
               />
+            </FlippedItem>
           ))}
+        </Flipper>
         <DragOverlay
           dropAnimation={{
             sideEffects: defaultDropAnimationSideEffects({
               styles: {
+                // dragOverlayの戻り先が並び替えする前の場所に移動して違和感なので非表示にした
+                dragOverlay: {
+                  display: "none"
+                }
               }
             })
           }}
         >
           {activeItem && <PreviewSortableSource item={activeItem} />}
         </DragOverlay>
       </DndContext>
     </div>
   );
 };

終わりに

以上がdnd-kitにFLIPアニメーションを組み込む方法でした。アニメーションがバッティングしないか心配でしたが、比較的役割は分担できて上手く組み合わせられたかなと思います。項目追加・削除でもアニメーションを入れたい場合に参考になれば幸いです。

Discussion