dnd-kitにFLIPアニメーションを組み込む
始めに
dnd-kitを使ってリストの並び替えをする方法を以前紹介しました。
これで確かに並び替えはできますが、追加・削除時はアニメーションすることができず、UX的に微妙だなと感じてました。この点でDiscussionにも上がっており、この方法で試してみましたがいくつか気になるところがありました。
実際に試したコードはこちらになり、以下の点で気になりました。
- 追加・削除される項目自体にフェードインアニメーションなどを入れることができない
- 特に一番下に追加する場合はパッと現れている感じになる
- シャッフルした後に追加とかすると移動アニメーションが変になる
項目の追加・削除時にアニメーションするのはそもそもdnd-kitの領域ではないと思い、むしろFLIPアニメーションライブラリと組み合わせるべきでは?と思ったので、dnd-kitとreact-flip-toolkitを組み合わせて上記の要件を実装しました。
サンプルコード
実際に作ったコードはこちらになります。追加・削除時もアニメーションがされるようになり、大分UXが良くなって個人的には満足です😄
前提
この記事はdnd-kitとreact-flip-toolkitをどう組み合わせるかということに焦点を当てているため、それぞれの使い方はここでは説明しません。それぞれの使い方は以下の記事で書いており、またこれらの記事の応用版という位置付けですので先に見ておくことをオススメします。
シンプルなSortableにFLIPアニメーションを組み込む
dnd-kitとreact-flip-toolkitでそれぞれのtransform設定にバッティングしないように注意する必要がありますが、幸いなことにdnd-kitは計算するタイミングをかなり絞っており、デフォルトではドラッグ中以外は計算されないため、項目の追加・削除は問題なくFLIPアニメーションを組み込むことができます。ただしシャッフルだけは例外で、dnd-kitによって並び変わった時とシャッフルボタンを押して並び変わった時でアニメーションを発動させたいライブラリを変える必要があります。ドラッグ直後の並び替えはreact-flip-toolkitのアニメーションが発動しないようにdisableしておきます。
その辺を踏まえた上で、FLIPアニメーション対象の項目をラップするコンポーネントをまず作ります。子要素がコンポーネントの場合はflippedPropsが自動で注入されるのでそれをルート要素に渡すように書く必要がありますが、divをラップしても影響受けない場合はdivでラップしておくとその辺の設定もする必要がなくなるので今回はdivでラップしています。
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アニメーションがされるように組み込むと以下のようになります。
// 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を使うように書き換えると以下のようになります。
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にするなどの制御を入れていましたが、その必要も無くなりました。
// 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