プログラミング

メモアプリ開発③ ドラッグ&ドロップ【dnd kit】

この記事は約33分で読めます。

メモアプリ「Simpl」の開発手順をまとめるシリーズ
第3弾はメモのドラッグ&ドロップ機能です。

dnd kitライブラリを使用してメモの表示順を変更できるようにします。


これまでの学習内容や開発記録については以下の記事にまとめています。

【設計】データベース設計

memosテーブルにメモの表示順を保持するdisplay_orderを追加します。

【設計】API設計

複数メモの更新を行うupdateMemos(複数メモ更新)を作成します。
並べ替えによって複数のメモのdisplay_orderが変更されることを想定しています。

また、メモの表示・作成・修正のAPIにdisplay_orderを追加します。

【MySQL】テーブルの変更

データベース設計書に従ってmemosテーブルにdisplay_orderを追加します。

SQL
DROP TABLE memos;

CREATE TABLE memos (
    id INT AUTO_INCREMENT PRIMARY KEY,
    user_id INT NOT NULL,
    display_order INT NOT NULL,
    title VARCHAR(512),
    content VARCHAR(1024),
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    FOREIGN KEY (user_id) REFERENCES users(id)
);

【命名規則】display_order / displayOrder
プログラムにはその言語ごとに命名規則があり、一般的に以下のように使い分けられます。

  • MySQLではスネークケース(snake_case)
  • Javaではキャメルケース(camelCase)

そこで今回のメモ表示順に対しては、MySQLではdisplay_order、JavaではdisplayOrderとしています。

【Spring Boot】バックエンド処理

バックエンドであるSpring Bootに以下の変更を行います。

Model

モデルクラスは、DBから取得したデータやメソッドの引数データを格納する箱です。

まず、以下のモデルクラスにdisplayOrderを追加します。

  • Memoクラス(DBから取得したメモデータを格納する)
  • MemoRequestクラス(単一メモ系APIのリクエストボディを格納する)
src/main/java/com/example/simplememo/model/Memo.java
package com.example.simplememo.model;

import lombok.Data;
import java.time.LocalDateTime;

@Data
public class Memo {
    private int id;
    private int userId;
    private int displayOrder; // 追加
    private String title;
    private String content;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;

    public Memo(
            int id,
            int userId,
            int displayOrder, // 追加
            String title,
            String content,
            LocalDateTime createdAt,
            LocalDateTime updatedAt) {
        this.id = id;
        this.userId = userId;
        this.displayOrder = displayOrder; // 追加
        this.title = title;
        this.content = content;
        this.createdAt = createdAt;
        this.updatedAt = updatedAt;
    }
}
src/main/java/com/example/simplememo/controller/MemoRequest.java
package com.example.simplememo.controller;

import lombok.Data;

@Data
public class MemoRequest {
    private int displayOrder; // 追加
    private String title;
    private String content;
}

また、MemoRequestWithIdクラスを作成します。
並び替えによって更新対象になる複数のメモをリクエストで受け取る際に使用します。

パスパラメーターで受け取っていたメモIDをボディに持つため、MemoRequestモデルを継承してメモIDを追加しています。

src/main/java/com/example/simplememo/controller/MemoRequestWithId.java
package com.example.simplememo.controller;

import lombok.Data;
import lombok.EqualsAndHashCode;

@Data
@EqualsAndHashCode(callSuper = true) 
public class MemoRequestWithId extends MemoRequest {
    private int id;
}

Mapper/Service

Mapperクラスはデータベースとの直接的なやり取りを担当しており、Serviceクラスはビジネスロジックの処理を担当しています。

ビジネスロジックとは、データベースから値を取得するMapper層とAPIのリクエストを受け取るController層の間で行う処理のことです。

具体例としては、ユーザーが入力したデータを適切な形式に変換したり、データがルールに従っているかを確認したりします。

今回はMemoMapperクラスMemoServiceクラスの以下の関数にdisplayOrderを追加します。

  • insertMemo関数(単一メモの作成を行う)
  • updateMemo関数(単一メモの更新を行う)
src/main/java/com/example/simplememo/mapper/MemoMapper.java
package com.example.simplememo.mapper;

import com.example.simplememo.model.Memo;
import org.apache.ibatis.annotations.*;

import java.util.List;

@Mapper
public interface MemoMapper {
    @Select("SELECT * FROM memos WHERE user_id = #{userId}")
    List<Memo> getMemosByUserId(int userId);

    @Select("SELECT * FROM memos WHERE id = #{id} AND user_id = #{userId}")
    Memo findMemo(@Param("userId") int userId, @Param("id") int id);

    // メモ作成にorderを追加
    @Insert("INSERT INTO memos(user_id, display_order, title, content, created_at, updated_at) VALUES(#{userId}, #{memo.displayOrder}, #{memo.title}, #{memo.content}, #{memo.createdAt}, #{memo.updatedAt})")
    void insertMemo(@Param("userId") int userId, @Param("memo") Memo memo);

    // メモ更新にorderを追加
    @Update("UPDATE memos SET display_order=#{memo.displayOrder}, title=#{memo.title}, content=#{memo.content}, updated_at=#{memo.updatedAt} WHERE id=#{id} AND user_id = #{userId}")
    void updateMemo(@Param("userId") int userId, @Param("id") int id, @Param("memo") Memo memo);

    @Delete("DELETE FROM memos WHERE id=#{id} AND user_id = #{userId}")
    void deleteMemo(@Param("userId") int userId, @Param("id") int id);
}

src/main/java/com/example/simplememo/service/MemoService.java
package com.example.simplememo.service;

import java.time.LocalDateTime;

import com.example.simplememo.controller.MemoRequest;
import com.example.simplememo.mapper.MemoMapper;
import com.example.simplememo.model.Memo;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class MemoService {
    @Autowired
    private MemoMapper memoMapper;

    public List<Memo> getMemosByUserId(int userId) {
        return memoMapper.getMemosByUserId(userId);
    }

    public Memo findMemo(int userID, int id) {
        return memoMapper.findMemo(userID, id);
    }

    public void insertMemo(int userId, MemoRequest request) {
        // displayOrderを追加
        Memo memo = new Memo(
                    0, 
                    userId, 
                    request.getDisplayOrder(), 
                    request.getTitle(), 
                    request.getContent(),
                    LocalDateTime.now(),
                    LocalDateTime.now());
        try {
            memoMapper.insertMemo(userId, memo);
        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException("メモの登録に失敗しました。");
        }
    }

    public void updateMemo(int userId, int id, MemoRequest request) {
        try {
            Memo memo = memoMapper.findMemo(userId, id);
            if (memo == null) {
                throw new RuntimeException("メモが見つかりません。");
            }
            Memo updateMemo = new Memo(
                    id,
                    userId,
                    request.getDisplayOrder(), // displayOrderを追加
                    request.getTitle(),
                    request.getContent(),
                    memo.getCreatedAt(),
                    memo.getUpdatedAt());
            // タイトルか内容が更新された場合のみupdatedAtを更新する(displayOrderのみの更新を想定)
            if (!memo.getTitle().equals(request.getTitle()) || !memo.getContent().equals(request.getContent())) {
                updateMemo.setUpdatedAt(LocalDateTime.now());
            }
            memoMapper.updateMemo(userId, id, updateMemo);
        } catch (Exception e) {
            throw new RuntimeException("メモの更新に失敗しました。");
        }
    }

    public void deleteMemo(int userId, int id) {
        try {
            memoMapper.deleteMemo(userId, id);
        } catch (Exception e) {
            throw new RuntimeException("メモの削除に失敗しました。");
        }
    }
}

メモのタイトルコンテンツが更新された場合のみ更新日時を更新しています。

Controller

コントローラークラスはリクエストを受け取り、レスポンスを返す役割を担当しています。

今回はMemoControllerに対して以下の関数を作成します。

  • updateMemos関数(複数メモの更新を行う)

複数のMemoを受け取り、1件ずつMemoMapperクラスのupdateMemo関数を呼び出します(42行目から)。

src/main/java/com/example/simplememo/controller/MemoController.java
package com.example.simplememo.controller;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.example.simplememo.model.Memo;
import com.example.simplememo.service.MemoService;

@RestController
@RequestMapping("/api/users/{user_id}/memos")
public class MemoController {
    @Autowired
    private MemoService memoService;

    @GetMapping
    public ResponseEntity<List<Memo>> getMemosByUserId(@PathVariable("user_id") int userId) {
        System.out.println("receive request");
        return ResponseEntity.ok(memoService.getMemosByUserId(userId));
    }

    @PostMapping
    public ResponseEntity<Integer> createMemo(@PathVariable("user_id") int userId, @RequestBody MemoRequest memo) {
        try {
            memoService.insertMemo(userId, memo);
            return ResponseEntity.ok().build();
        } catch (Exception e) {
            return ResponseEntity.badRequest().build();
        }
    }

    @PutMapping
    public ResponseEntity<Void> updateMemos(@PathVariable("user_id") int userId, @RequestBody MemoRequestWithId[] memos) {
        try {
            for (MemoRequestWithId memo : memos) {
                memoService.updateMemo(userId, memo.getId(), memo);
            }
            return ResponseEntity.ok().build();
        } catch (Exception e) {
            return ResponseEntity.badRequest().build();
        }
    }

    @PutMapping("/{id}")
    public ResponseEntity<Void> updateMemo(@PathVariable("user_id") int userId, @PathVariable("id") int id,
            @RequestBody MemoRequest memo) {
        System.out.println("receive request in updateMemo");
        try {
            memoService.updateMemo(userId, id, memo);
            return ResponseEntity.ok().build();
        } catch (Exception e) {
            return ResponseEntity.badRequest().build();
        }
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteMemo(@PathVariable("user_id") int userId, @PathVariable("id") int id) {
        try {
            memoService.deleteMemo(userId, id);
            return ResponseEntity.noContent().build();
        } catch (Exception e) {
            return ResponseEntity.badRequest().build();
        }
    }
}

動作確認

今回はSwaggerを用いて動作確認を行いました。

【React】フロントエンド処理の追加

ドラッグ&ドロップを導入するにあたり、これらの変更を行いました。

1つずつ解説します。

dnd kitの導入

今回はドラッグ&ドロップを実装するためにdnd kitライブラリを使用します。
dnd kitはReact専用のドラッグ&ドロップライブラリで、最近人気が出てきているそうです。
リスト形式だけでなくグリッド形式なども実装可能です。

dnd kit – a modern drag and drop toolkit for React
A modular, lightweight, performant, accessible and extensible drag & drop toolkit for React.
Zsh
npm install @dnd-kit/sortable @dnd-kit/core

今回は最もシンプルなリスト形式の並べ替えを実装します。
以下の2つのコンポーネントを作成しました。

SortableContainer

SortableContainerコンポーネントではドラッグ&ドロップ可能な範囲を定義します。
その際に使用するdnd kitのライブラリコンポーネントは以下の3つです。

  • DndContextコンポーネント(ドラッグ&ドロップ全体の設定と管理を行う)
  • SortableContextコンポーネント(リスト形式の設定と管理を行う)
  • DragOverlayコンポーネント(ドラッグ中の要素を表示するためのオーバーレイ)

DndContextコンポーネントは、センサー設定やコリジョン検知、イベントハンドラーの設定を担当するライブラリコンポーネントです。

  • センサー設定:ポインタやタッチ、キーボードなどの入力方法のどれでドラッグを検知するかを設定します。今回はマウスによる操作を検知するためにPointerSensorを使用しています。
  • コリジョン検知:ドロップ先の計算方法を設定します。今回は四隅が最も近いメモがドロップ先になるようclosestCornersを使用しています。
  • イベントハンドラー:ドラッグの開始から終了までの各タイミングにおける処理を設定します。今回はドラッグ終了時に並べ替え処理表示順の永続化関数(updateMemo)を呼び出しています。

SortableContextコンポーネントは、対象のアイテムを指定し、並べ替え形式を設定します。

  • 対象アイテム:並べ替え対象のリストを渡します。今回はメモのリストを渡しています。
  • 並べ替え形式:縦方向や横方向などの形式を設定します。今回は垂直方向のverticalListSortingStrategyを使用しています。

DragOverlayコンポーネントは、ドラッグ中の要素を表示するためのオーバーレイです。ドラッグ中の要素が他の要素に隠れたり、サイズが変わったりすることなく、視覚的にドラッグされていることを示すことができます。

src/components/Main/SortableContainer/SortableContainer.tsx
import {
  closestCorners,
  DndContext,
  DragEndEvent,
  DragOverlay,
  PointerSensor,
  useSensor,
  useSensors,
} from "@dnd-kit/core";

import SortableItem from "./SortableItem";
import {
  arrayMove,
  SortableContext,
  verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { useMemoContext } from "../../../context/MemoContext";
import { useState } from "react";

const SortableContainer = () => {
  const { userMemos, setUserMemos, editingMemoId, updateMemos } =
    useMemoContext();
  const sensors = useSensors(
    // ドラッグしないとソート処理が動かないように設定(編集可能になる)
    useSensor(PointerSensor, { activationConstraint: { distance: 0 } })
  );
  const [activeId, setActiveId] = useState(-1);

  const handleDragEnd = (event: DragEndEvent) => {
    const { active, over } = event;
    setActiveId(-1); // ドラッグ終了後、activeIdをリセット
    if (active.id === over?.id) return; // 移動していない場合は何もしない

    // userMemosのindexを取得(=displayOrder≠)
    const activeIndex = userMemos.findIndex((memo) => memo.id === active.id);
    const overIndex = userMemos.findIndex((memo) => memo.id === over?.id);
    // userMemosの並び替え
    const sortedMemos = arrayMove(userMemos, activeIndex, overIndex);
    // displayOrderを再設定
    const newOriginalMemos = sortedMemos.map((memo, index) => {
      return { ...memo, displayOrder: index + 1 }; // 1始まりにするため
    });
    setUserMemos(newOriginalMemos); // 状態更新
    updateMemos(newOriginalMemos); // DB更新
  };

  return (
    <div className="dnd__container">
      <DndContext
        sensors={sensors}
        collisionDetection={closestCorners}
        onDragStart={({ active }) => setActiveId(Number(active.id))}
        onDragEnd={handleDragEnd}
      >
        <SortableContext
          items={userMemos}
          strategy={verticalListSortingStrategy}
        >
          {userMemos.length > 0 ? (
            userMemos.map((memo) => (
              <SortableItem
                key={memo.id}
                memo={memo}
                isDisabled={memo.id === editingMemoId} // 編集中のメモを移動不可に
                isTransparent={activeId === memo.id} // activeIdが一致する場合に透明度を変更
              />
            ))
          ) : (
            <p className="dnd__empty-message">No memos</p>
          )}
        </SortableContext>
        {/* ユーザーがドラッグしている要素を表示 */}
        <DragOverlay>
          {activeId ? (
            <SortableItem
              memo={userMemos.find((memo) => memo.id === activeId)}
              isDisabled={false} // true/falseどちらでもいい
              isTransparent={false} // ドラッグ対象のためactiveだけど透明にしない
            />
          ) : null}
        </DragOverlay>
      </DndContext>
    </div>
  );
};

export default SortableContainer;

SortableItem

SortableItemコンポーネントでは個々のドラッグ&ドロップ対象を定義します。
ドラッグ&ドロップ対象として宣言するためにはsetNodeRefが必要です。

setNodeRefは、対象のDOMへの参照を設定するために使用されます。
対象タグに指定することで、dnd kitが対象アイテムの位置やイベントを管理できるようになります。

src/components/Main/SortableContainer/SortableItem.tsx
import React from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Memo } from "../../../Types";
import MemoRow from "../MemoContainer/MemoRow";

const SortableItem = (props: { memo: Memo, isDisabled: boolean }) => {
  const { memo, isDisabled } = props;
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging,
  } = useSortable({ id: memo.id, disabled: isDisabled });

  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
    zIndex: isDragging ? 9999 : "auto", // ドラッグ中の要素に高い z-index を設定
    cursor: isDisabled ? "auto" : (isDragging ? "grabbing" : "grab"), // ドラッグ中のカーソルを掴むアイコンに設定
  };

  return (
    <div ref={setNodeRef} style={style} {...attributes} {...listeners}>
      <MemoRow originalMemo={memo} key={memo.id} />
    </div>
  );
};

export default SortableItem;

表示順の永続化

再読み込みしても表示順が保持されるよう、表示順が変化するタイミングでデータベースに保存します。

表示順が変化するケースは以下の3つに分けられます。

ドラッグ&ドロップ

ユーザーのドラッグ&ドロップ操作は、ドロップ後に発火するhandeDragEnd関数で処理します。

DndContextコンポーネントのonDragEndプロパティにhandleDragEnd関数を設定することで、ドラッグ操作が終了した時の処理をカスタマイズできます。

handeDragEnd関数では、dnd kitライブラリが提供するarrayMove関数で状態を更新します。
状態更新結果を元にDBを更新しました。

(抜粋再掲)src/components/Main/SortableContainer/SortableContainer.tsx
  const handleDragEnd = (event: DragEndEvent) => {
    const { active, over } = event;
    if (active.id === over?.id) return; // 移動していない場合は何もしない

    // userMemosのindexを取得(=displayOrder≠)
    const activeIndex = userMemos.findIndex((memo) => memo.id === active.id);
    const overIndex = userMemos.findIndex((memo) => memo.id === over?.id);
    // userMemosの並び替え
    const sortedMemos = arrayMove(userMemos, activeIndex, overIndex);
    // displayOrderを再設定
    const newOriginalMemos = sortedMemos.map((memo, index) => {
      return { ...memo, displayOrder: index + 1 }; // 1始まりにするため
    });
    setUserMemos(newOriginalMemos); // 状態更新
    updateMemos(newOriginalMemos); // DB更新
  };

  return (
    <div className="dnd__container">
      <DndContext
        sensors={sensors}
        collisionDetection={closestCorners}
        onDragEnd={handleDragEnd}
      >
      ...
      </DndContext>
    </div>
  );
};

export default SortableContainer;

メモの新規作成

新規作成したメモは一番上に追加され、既存メモの表示順を1ずつ加算する必要があります。

メモの作成はMemoContextに格納してあるhandleCreateMemo関数でハンドリングしています。
既存メモの表示順更新もhandleCreateMemo関数で実装しました。

src/context/MemoContext.tsx
const handleCreateMemo = async (targetMemo: Memo) => {
    // タイトルと内容が入力されていない場合は何もしない
    if (targetMemo.title === "" && targetMemo.content === "") {
      return;
    }

    // DBに新規メモを作成
    await createMemo(targetMemo);
    // 既存メモの並び順を更新(新規作成時は全て+1)
    const newOriginalMemos = userMemos.map((memo) => {
      return { ...memo, displayOrder: memo.displayOrder + 1 };
    });
    await updateMemos(newOriginalMemos);
    // メモ一覧を再取得
    fetchMemos();
    // 新規作成フォームをリセット
    setNewMemo(initialMemo);
  };

メモの削除

削除したメモより下に表示されていたメモは1つずつ繰り上がります。(displayOrderが1減算)

メモの削除はMemoContextに格納してあるhandleDelteMemo関数でハンドリングしています。
既存メモの表示順更新もhandleDelteMemo関数で実装しました。

src/context/MemoContext.tsx
  const handleDeleteMemo = async (targetMemo: Memo) => {
    // DBからメモを削除
    await deleteMemo(targetMemo.id);
    // 既存メモの並び順を更新
    const newOriginalMemos = userMemos
      .filter((memo) => memo.id !== targetMemo.id)
      .map((memo) => {
        if (memo.displayOrder > targetMemo.displayOrder) {
          // 削除対象以降を-1
          return { ...memo, displayOrder: memo.displayOrder - 1 };
        }
        return memo;
      });
    await updateMemos(newOriginalMemos);
    // メモ一覧を再取得
    fetchMemos();
  };

まとめ

今回はドラッグ&ドロップ機能について設計から実装までまとめました。
React専用のドラッグ&ドロップライブラリであるdnd kitライブラリを使用して、DB反映処理まで解説しました。

次回はメモの入力フォームにリッチテキストエディタを導入したいと思います。

参考文献

dnd kitの導入方法の参考にしました。

dnd-kitを使ってリストの並び替えを実装する

コメント