44
38

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

【React】ドラッグでコンポーネントを動かせるようにするCustom Hookを作る

Last updated at Posted at 2019-10-19

最近React Hooksの素晴らしさに目覚めました。

なので今回は、そんなReact Hookの素晴らしさを知っていただくために、
既存のコンポーネントに2〜3行加えるだけで、そのコンポーネントが動かせるようになるCustom Hookを作りたいと思います。

たとえばこんな感じのコンポーネントがあるとして・・・

.tsx
const App: React.FC = () => {
  return (
    <div className="App">
      <div
        style={{
          border: '2px solid #0489B1',
          backgroundColor: '#A9D0F5'
        }}
      />
    </div>
  )
}

こんな感じで3行追加するだけで・・・

const App: React.FC = () => {
  const interact = useInteractJS() // <= 追加する
  return (
    <div className="App">
      <div
        ref={interact.ref}             // <= 追加する
        style={{
          ...interact.style,           // <= 追加する
          border: '2px solid #0489B1',
          backgroundColor: '#A9D0F5'
        }}
      />
    </div>
  )
}

こんな感じで移動とリサイズができるようになります。

画面収録 2019-10-18 23.52.06 (2).gif

Custom Hookを使えば、こんなHookをかんたんに作ることができます。
すごいでしょ?すごいとゆえ

今回作成するコードのサンプルは以下で動かすことができます。

Edit interact-react-sample

また、GitHubにもサンプルプロジェクトをおいているので、よろしければいじってみてください

InteractJSの導入

さて、Custom Hookを作るにあたって、DOMを動かすロジックを一から作るのは大変なので、DOMをドラッグで動かせるようにするライブラリを導入します。

いくつか候補はあり、なんならReact用のものもあるのですが、今回はInteractJSを使いたいと思います。

以下のコマンドで、InteractJSをインストールしましょう

npm install interactjs

Custom Hookを作る

早速Custom Hookを作っていきます。適当な場所に、hooks.tsというファイルを作り、以下のようにuseInteractJS()という関数を作ります。

引数は、動かしたいコンポーネントの初期座標と大きさです。指定されない場合は、initPosition の値になります。Partial<T> は Tの部分型を返す型です。要するに引数として幅だけ指定した、 { width: 100 } とかも引数として受け入れるようになります。

hooks.ts

type Partial<T> = {
  [P in keyof T]?: T[P];
};

const initPosition = {
  width: 100,
  height: 100,
  x: 0,
  y: 0
}

export function useInteractJS(position: Partial<typeof initPosition> = initPosition) {

}

InteractJSをコンポーネントに適用する

さて、useInteractJS() 関数の中身をゴリゴリ書いていきます。まずはInteractJSをDOMに適用しないと話にならないので、その部分を書いていきましょう。

公式ドキュメントを見ると、interact()関数の引数にクラス名、もしくはhtml elementを指定することで、対象の要素にinteractJSを作用させる事ができることがわかります。

今回は useRef() を使ってhtml elementを指定してあげることにしましょう。
以下のようなコードを useInteractJS()の中に追加します。

hooks.ts
export function useInteractJS(position: Partial<typeof initPosition> = initPosition) {
  const interactRef = useRef(null)    // <= HTML ELEMENTを取得
  
  // interactJSを有効化する
  const enable = () => {
    interact((interactRef.current as unknown) as HTMLElement)
  }

  // interactJSを無効化する
  const disable = () => {
    interact((interactRef.current as unknown) as HTMLElement).unset()
  }

  // マウント時にRefで取得した要素にinteractJSを作用させる。アンマウント時にはunsetする
  useEffect(() => {
    enable()
    return disable
  }, [])

  return { ref: interactRef }
}

ここまで書いたら、動かしたいコンポーネントに以下のように適用してみましょう

.tsx
const App: React.FC = () => {
  const interact = useInteractJS() // <= 追加する
  return (
    <div className="App">
      <div
        ref={interact.ref}             // <= 追加する
        style={{
          border: '2px solid #0489B1',
          backgroundColor: '#A9D0F5'
        }}
      />
    </div>
  )
}

これによってinteractJSがコンポーネントに適用されます。(ただし現時点では、何も変化はありません)

コンポーネントを動かす

次にコンポーネントを動かす処理を書いていきます。InteractJSでは取得した要素に様々なイベントリスナを生やすことで、その要素に対する操作を検知し、その操作に応じた処理を加えることができます。

例えばドラッグで要素を動かす際には以下の様に書きます。

.ts
    let x = 0
    let y = 0
    interact((interactRef.current as unknown) as HTMLElement)
      .draggable({
        inertia: false
      })
      .on('dragmove', event => {
        x += event.dx
        y += event.dy
      })

このように書くことで、移動後の要素のx座標y座標を取得することができます。
ここで取得した座標をCSSに適用することによって、要素を動かすことができるようになります。

さて、これをReact上で実現するために以下の2つをuseInteractJS()に追加しましょう。

  • 現在のpositionの状態を保持するためのstate
  • positionの状態からCSSのStyleを作成して返り値としてコンポーネントに返す処理

具体的なコードは以下になります。

.ts
export function useInteractJS(
  position: Partial<typeof initPosition> = initPosition
) {

  // 引数で指定したpositionを初期値として、Stateを作る
  const [_position, setPosition] = useState({
    ...initPosition,
    ...position
  })

  const interactRef = useRef(null)
  let { x, y, width, height } = _position

  const enable = () => {
    interact((interactRef.current as unknown) as HTMLElement)
      // ドラッグでコンポーネントを動かすための処理を追加
      .draggable({
        inertia: false
      })
      .on('dragmove', event => {
        x += event.dx
        y += event.dy
        // ドラッグ後の座標をstateに保存する
        setPosition({
          width,
          height,
          x,
          y
        })
      })
  }

  const disable = () => {
    interact((interactRef.current as unknown) as HTMLElement).unset()
  }

  useEffect(() => {
    enable()
    return disable
  }, [])

  return {
    ref: interactRef,
    // 返り値にCSSのスタイルを追加する。このスタイルを動かしたいコンポーネントに適用することで、コンポーネントが実際に動くようになる
    style: {
      transform: `translate3D(${_position.x}px, ${_position.y}px, 0)`,
      position: 'absolute' as CSSProperties['position']
    },
  }
}

ここまで書いたら、動かしたいコンポーネント側にも以下の変更を加えましょう

.ts
const App: React.FC = () => {
  const interact = useInteractJS()
  return (
    <div className="App">
      <div
        ref={interact.ref}
        style={{
          ...interact.style,           // <= 追加する
          border: '2px solid #0489B1',
          backgroundColor: '#A9D0F5'
        }}
      />
    </div>
  )
}

これで、ドラッグでの移動ができるはずです。

リサイズできるようにする

移動ができるようになったので、ついでにリサイズもできるようにしましょう。公式サイトを読むと、resizeable()というリスナを登録することでリサイズができるようになるみたいです。さっそく、リサイズ用のリスナを追加していきましょう。

以下のようにuseInteractJS()を書き換えます。

.ts
export function useInteractJS(
  position: Partial<typeof initPosition> = initPosition
) {

  // 引数で指定したpositionを初期値として、Stateを作る
  const [_position, setPosition] = useState({
    ...initPosition,
    ...position
  })

  const interactRef = useRef(null)
  let { x, y, width, height } = _position

  const enable = () => {
    interact((interactRef.current as unknown) as HTMLElement)
      // ドラッグでコンポーネントを動かすための処理を追加
      .draggable({
        inertia: false
      })
      .on('dragmove', event => {
        x += event.dx
        y += event.dy
        // ドラッグ後の座標をstateに保存する
        setPosition({
          width,
          height,
          x,
          y
        })
      })
      // リサイズで要素の大きさを変えるための処理を追加
      .resizable({
        // resize from all edges and corners
        edges: { left: true, right: true, bottom: true, top: true },
        preserveAspectRatio: false,
        inertia: false
      })
      .on('resizemove', event => {
        width = event.rect.width
        height = event.rect.height
        x += event.deltaRect.left
        y += event.deltaRect.top
        // 大きさの変化をstateに反映する
        setPosition({
          x,
          y,
          width,
          height
        })
      })
  }

  const disable = () => {
    interact((interactRef.current as unknown) as HTMLElement).unset()
  }

  useEffect(() => {
    enable()
    return disable
  }, [])

  return {
    ref: interactRef,
    style: {
      transform: `translate3D(${_position.x}px, ${_position.y}px, 0)`,
      position: 'absolute' as CSSProperties['position'],
      width: _position.width + 'px', // <= 大きさを要素に適用するためにスタイルを追加
      height: _position.height + 'px', // <= 大きさを要素に適用するためにスタイルを追加
    },
  }
}

お疲れさまでした!
これでコンポーネントの移動とリサイズが行えるようになりました。🎉

その他の機能の追加

上記以外にも interactJSの有効/無効を切り替えたり、positionの情報を取得したりできるようにいくつかのAPIを加えました。最終版は以下になります。

hooks.ts
import { useRef, useEffect, useState, CSSProperties } from 'react'
import interact from 'interactjs'

type Partial<T> = {
  [P in keyof T]?: T[P]
}

const initPosition = {
  width: 100,
  height: 100,
  x: 0,
  y: 0
}

/**
 * HTML要素を動かせるようにする
 * 返り値で所得できるrefと、styleをそれぞれ対象となるHTML要素の
 * refとstyleに指定することで、そのHTML要素のリサイズと移動が可能になる
 * @param position HTML要素の初期座標と大きさ、指定されない場合はinitPositionで指定された値になる
 */
export function useInteractJS(
  position: Partial<typeof initPosition> = initPosition
) {
  const [_position, setPosition] = useState({
    ...initPosition,
    ...position
  })
  const [isEnabled, setEnable] = useState(true)

  const interactRef = useRef(null)
  let { x, y, width, height } = _position

  const enable = () => {
    interact((interactRef.current as unknown) as HTMLElement)
      .draggable({
        inertia: false
      })
      .resizable({
        // resize from all edges and corners
        edges: { left: true, right: true, bottom: true, top: true },
        preserveAspectRatio: false,
        inertia: false
      })
      .on('dragmove', event => {
        x += event.dx
        y += event.dy
        setPosition({
          width,
          height,
          x,
          y
        })
      })
      .on('resizemove', event => {
        width = event.rect.width
        height = event.rect.height
        x += event.deltaRect.left
        y += event.deltaRect.top
        setPosition({
          x,
          y,
          width,
          height
        })
      })
  }

  const disable = () => {
    interact((interactRef.current as unknown) as HTMLElement).unset()
  }

  useEffect(() => {
    if (isEnabled) {
      enable()
    } else {
      disable()
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [isEnabled])

  useEffect(()=>{
    return disable
  },[])

  return {
    ref: interactRef,
    style: {
      transform: `translate3D(${_position.x}px, ${_position.y}px, 0)`,
      width: _position.width + 'px',
      height: _position.height + 'px',
      position: 'absolute' as CSSProperties['position']
    },
    position: _position,
    isEnabled,
    enable: () => setEnable(true),
    disable: () => setEnable(false)
  }
}
App.tsx
import React from 'react'
import './App.css'
import { useInteractJS } from './hooks'

const App: React.FC = () => {
  const interact = useInteractJS()

  return (
    <div className="App">
      <button onClick={() => interact.enable()}>有効化</button>
      <button onClick={() => interact.disable()}>無効化</button>
      <div
        ref={interact.ref}
        style={{
          ...interact.style,
          border: '2px solid #0489B1',
          backgroundColor: '#A9D0F5'
        }}
      />
    </div>
  )
}

export default App

まとめ

今回作った useInteractJS()というCustom Hookの中では、useState() useRef() useEffect() の3つのHookを使いました。

Custom Hookという機能は、言ってしまえばReactHookの各関数の呼び出しを他のファイルに切り出しただけであり、なにか特別なテクニックが必要な機能ではありません。
普段何気なくやっている関数を別モジュールやクラスに切り出す作業と何も変わりません。

しかしCustom Hookの素晴らしいところは、useState()useEffect()といったような、状態を持っていたり、副作用を発火させるような関数を そのコンポーネントの外に切り出せるというところです。近い機能としては、VueのMixinがありますが、Mixinと違ってthisを汚さず、複数のCustom Hook同士を合成させるような場合でも、Mixinのように各々のプロパティがぶつかる心配をする必要もありません。
コンポーネント内の処理を機能単位で切り出して汎用化させることによって、より見通しの良い、再利用しやすいコンポーネントを作ることができます。

また、今回行ったように、interactJSやJQueryのような、直接DOMを弄る系のライブラリを使用する際には、 useRef() を使ってCostom Hookを作ると、とてもきれいに処理をまとめることができます。
JQueryはReactではあまり出番はないかもしれませんが、canvazを使う系のライブラリ(PIXI.jsやThree.jsなど)をラップする際には非常に便利です。

ぜひ皆さんも自分だけのCustom Hookを作ってみてください。とても楽しいですよ!

おわり

44
38
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
44
38

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?