Katashin .info

長押しドラッグを活用した iOS のポップオーバーメニューインタラクションを Vue.js で実装する

iOS の UI は細かいインタラクションが作り込まれていて、使っていて快適です。例えば、iOS のポップオーバーメニューは、タップではなく長押しでもポップオーバーを開くことができ、そのまま指を離さずにメニュー項目に指を動かせば、その項目を選択することができます。以下のデモはその挙動を再現したものです。

また、一部のアプリでは、メニュー項目を選択できる領域が拡大されていることがあり、項目に指が触れていなくても選択できるものもあります。例えば、以下のデモはポップオーバーで絵文字を選択できますが、指を絵文字の上まで動かさなくても、少し下に指を動かすだけで選択できます。これによって、指で選択したい項目が隠れるのを防げます。

この記事では Vue.js を使って、上記のような長押しドラッグによるポップオーバーのメニュー選択インタラクションの実装方法を、実際のコードを交えながら解説します。

長押しを判定するカスタムイベントの実装 #

まず、長押しを判定するイベントが Web にはないため、カスタムイベントを実装します。今回は長押しの開始と終了、および、長押し時に特定の要素内にドラッグした場合と、要素外にドラッグした場合のイベントを実装します。以下は、そのカスタムイベントの実装の全体です。

// @filename: long-touch.js
export function awareLongTouch() {
  let isLongTouch = false
  let isTouchMoved = false
  let pointerDownEvent
  let longTouchTimer

  /**
   * 指を置いた時 (pointerdown)
   */
  function onPointerDown(event) {
    isLongTouch = isTouchMoved = false
    pointerDownEvent = event

    // 長押しを判定するためのタイマーを開始
    // 300ms 経過したら長押しと判定する
    longTouchTimer = setTimeout(() => {
      isLongTouch = true

      // 長押しの開始を表すカスタムイベント longtouchstart をトリガーする
      const longTouchEvent = new CustomEvent('longtouchstart', {
        bubbles: true,
        cancelable: true,
        detail: event,
      })
      event.target.dispatchEvent(longTouchEvent)
    }, 300)
  }

  /**
   * 置いた指を動かした時 (pointermove)
   */
  function onPointerMove(event) {
    if (isLongTouch || isTouchMoved || !pointerDownEvent) {
      return
    }

    // pointerdown の時の指の座標と今の座標を比較し、ドラッグされた距離を計算する
    const deltaX = event.clientX - pointerDownEvent.clientX
    const deltaY = event.clientY - pointerDownEvent.clientY
    const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY)

    // 指を置いてから 10px 以上の距離をドラッグしたら長押し判定をキャンセルする
    if (distance > 10) {
      isTouchMoved = true
      clearTimeout(longTouchTimer)
    }
  }

  /**
   * 指を離した時 (pointerup)
   */
  function onPointerUp(event) {
    clearTimeout(longTouchTimer)

    if (isLongTouch) {
      // 長押し判定がされていれば、長押しの終了を表すカスタムイベント
      // longtouchend をトリガーする
      const longTouchEvent = new CustomEvent('longtouchend', {
        bubbles: true,
        cancelable: true,
        detail: event,
      })
      event.target.dispatchEvent(longTouchEvent)
    }

    isLongTouch = isTouchMoved = false
    pointerDownEvent = undefined
  }

  /**
   * 置いた指が特定の要素内に入った時 (pointerover)
   * ルート要素にイベントハンドラーを登録しているので、
   * pointerenter ではなく、バブリングする pointerover を使用する。
   */
  function onPointerOver(event) {
    if (isLongTouch) {
      // バブリングさせないように bubbles: false を指定する
      const longTouchEvent = new CustomEvent('longtouchenter', {
        bubbles: false,
        cancelable: true,
        detail: event,
      })
      event.target.dispatchEvent(longTouchEvent)
    }
  }

  /**
   * 置いた指が特定の要素外に出た時 (pointerout)
   * ルート要素にイベントハンドラーを登録しているので、
   * pointerleave ではなく、バブリングする pointerout を使用する。
   */
  function onPointerOut(event) {
    if (isLongTouch) {
      // バブリングさせないように bubbles: false を指定する
      const longTouchEvent = new CustomEvent('longtouchleave', {
        bubbles: false,
        cancelable: true,
        detail: event,
      })
      event.target.dispatchEvent(longTouchEvent)
    }
  }

  /**
   * 長押し時にスクロールを行わないように、preventDefault を実行する
   */
  function onTouchMove(event) {
    if (isLongTouch) {
      event.preventDefault()
    }
  }

  document.documentElement.addEventListener('pointerdown', onPointerDown)
  document.documentElement.addEventListener('pointermove', onPointerMove)
  document.documentElement.addEventListener('pointerup', onPointerUp)
  document.documentElement.addEventListener('pointerover', onPointerOver)
  document.documentElement.addEventListener('pointerout', onPointerOut)
  document.documentElement.addEventListener('touchmove', onTouchMove, {
    passive: false,
  })
}

awareLongTouch 関数を実行すると、ルートの要素にポインターイベントのリスナー関数が登録され、長押しのイベントがトリガーされるようになります。トリガーされるカスタムイベントは以下の4つです。

以後、Vue のテンプレート内で <div @longtouchstart="onLongTouchStart"> のように、他のイベントと同じ書き方でリスナー関数を登録できます。

ポップオーバーの実装 #

ポップオーバーのコンポーネント <Popover> にはポップオーバーを開くボタンである activator スロットと、ポップオーバーのコンテンツである default スロットを持たせます。activator には clicklongtouchstart のリスナーを渡し、クリックと長押しのどちらでもポップオーバーが開くようにします。

longtouchstart のリスナーでは、event.target に対して releasePointerCapture を実行しています。タッチデバイスでは自動的にポインターキャプチャーが行われるため、それを解除しています。これは後の節で、長押しドラッグでメニュー項目を選択できるようにするために必要です。

<!-- @filename: Popover.vue -->
<script setup>
import { ref, computed } from 'vue'

const props = defineProps({
  // ポップオーバーの表示位置を指定
  direction: String,
})

// ポップオーバーを開いていれば true
const isOpen = ref(false)

// direction の値に応じてポップオーバーの表示位置を変える
const popoverStyle = computed(() => {
  // 上側中央揃えで表示
  if (props.direction === 'top-center') {
    return {
      margin: 'auto',
      bottom: '100%',
      left: '-1000px',
      right: '-1000px',
      transformOrigin: 'bottom center',
    }
  }

  // …他の表示位置は実装を省略…

  // デフォルトは左下に表示
  return {
    top: '100%',
    right: '0',
    transformOrigin: 'top right',
  }
})

/**
 * activator がクリックされた時
 */
function onClick() {
  isOpen.value = !isOpen.value
}

/**
 * activator が長押しされた時
 */
function onLongTouchStart(event) {
  // releasePointerCapture をすることで、longtouchenter, longtouchleave が
  // ポップオーバー内のボタンに対してトリガーされるようにする。
  // event.dtail には PointerEvent が入っている
  event.target.releasePointerCapture(event.detail.pointerId)
  isOpen.value = true
}
</script>

<template>
  <div class="popover-wrapper">
    <!--
      activator スロット
      ポップオーバーを開くボタンを差し込む。
      on プロパティで click と longtouchstart のリスナーを渡し、
      クリックと長押しのどちらでもポップオーバーが開くようにする。
    -->
    <slot
      name="activator"
      :on="{
        click: onClick,
        longtouchstart: onLongTouchStart,
      }"
    ></slot>

    <!--
      デモのために簡単な実装にしているが、実際のアプリでは Teleport や
      popover 属性を使って、前面に配置した方がいい。
    -->
    <Transition>
      <div v-show="isOpen" class="popover" :style="popoverStyle">
        <!--
          default スロット
          ポップオーバーの中身を差し込む
        -->
        <slot :toggle="onClick"></slot>
      </div>
    </Transition>
  </div>
</template>

<style>
.popover-wrapper {
  position: relative;
}

.popover {
  position: absolute;
  width: fit-content;
  height: fit-content;
}

.popover:is(.v-enter-active, .v-leave-active) {
  transition: 0.3s cubic-bezier(0.2, 0, 0, 0.98);
}

.popover:is(.v-enter-from, .v-leave-to) {
  scale: 0;
  opacity: 0;
}
</style>

今回は簡単のために v-show だけを使ってポップオーバーの表示、非表示を切り替えていますが、実際のアプリでは <Teleport>popover 属性を使って、要素を前面に配置したほうが良いです。

ポップオーバーのメニュー項目の実装 #

ポップオーバー内に設置するメニュー項目は <PopoverButton> コンポーネントとして実装します。長押しドラッグに反応するように、ボタンに longtouchenter, longtouchleave, longtouchend のイベントリスナーを登録します。longtouchenterlongtouchleave でボタンのアクティブ状態を変え、longtouchend でクリックされたときと同じ処理を行います。

<!-- @filename: PopoverButton.vue -->
<script setup>
import { ref } from 'vue'

const emit = defineEmits(['click'])

const isActive = ref(false)

/**
 * 長押し時にボタン内にドラッグされたらアクティブにする
 */
function onLongTouchEnter() {
  isActive.value = true
}

/**
 * 長押し時にボタン外にドラッグされたら非アクティブにする
 */
function onLongTouchLeave() {
  isActive.value = false
}

/**
 * ボタン上で長押し終了されたらクリックと同様の操作を行う
 */
function onLongTouchEnd() {
  isActive.value = false
  emit('click')
}
</script>

<template>
  <button
    type="button"
    class="popover-button"
    :class="{ active: isActive }"
    @longtouchenter="onLongTouchEnter"
    @longtouchleave="onLongTouchLeave"
    @longtouchend="onLongTouchEnd"
    @click="emit('click')"
  >
    <slot></slot>
  </button>
</template>

<style>
.popover-button {
  padding: 8px 12px;
  width: 100%;
  border: none;
  background: none;
  text-align: left;
}

.popover-button:is(.active, :active) {
  background-color: rgba(0, 0, 0, 0.1);
}
</style>

ここまでのコードを組み合わせて、ポップオーバーメニューを実装します。<Popover> コンポーネントの default スロット(ポップオーバーの中)のボタンには <PopoverButton> を使うことで、メニューを長押しで開いた後、指をボタンの上にドラッグして離すと、そのボタンをクリックしたことになります。

<script setup>
import { awareLongTouch } from './long-touch.js'
import Popover from './Popover.vue'
import PopoverButton from './PopoverButton.vue'

// 長押し関連のイベントがトリガーされるようにする
awareLongTouch()
</script>

<template>
  <div class="wrapper">
    <Popover>
      <!-- ポップオーバーを開くボタンを設置 -->
      <template #activator="{ on }">
        <!-- v-on="on" とすることで、クリックか長押しでポップオーバーが開く -->
        <button type="button" class="open-button" v-on="on">メニュー</button>
      </template>

      <!-- ポップオーバーの中身 -->
      <template #default="{ toggle }">
        <div class="menu">
          <!-- 今回はクリックされたら閉じるだけ -->
          <PopoverButton @click="toggle">コピー</PopoverButton>
          <PopoverButton @click="toggle">ペースト</PopoverButton>
          <PopoverButton @click="toggle">削除</PopoverButton>
        </div>
      </template>
    </Popover>
  </div>
</template>

<style>
.wrapper {
  display: flex;
  justify-content: flex-end;
  font-family: sans-serif;
}

.open-button {
  padding: 8px 12px;
  border-radius: 4px;
  background: none;
  border: none;
  font-weight: bold;
}

.open-button:active {
  background-color: rgba(0, 0, 0, 0.1);
}

.menu {
  overflow: hidden;
  width: 200px;
  background-color: #fff;
  border-radius: 4px;
  box-shadow: 0 0 5px 3px rgba(0, 0, 0, 0.05);
}
</style>

この実装を動かすと以下のようになります。

長押し時に当たり判定を拡大する #

iOS アプリの中には長押しした指がメニュー項目の上から少し外れていても、その項目をアクティブにするものがあります。これは対象のメニュー項目が小さく、指をその上にドラッグした時に、項目が指に隠れてしまうのを防ぐための実装です。この挙動も今まで実装した長押しイベントと <Popover><PopoverButton> コンポーネントを活用すればシンプルに実装できます。

<PopoverButton> コンポーネントが、メニュー項目から外れた場所に置かれた指に反応するように修正します。longTouchHitOffset プロパティを追加し、これに指定された方向に当たり判定を拡大します。例えば、:long-touch-hit-offset="{ bottom: 50 }" と渡した時、下方向に 50px 当たり判定を拡大します。

ボタンの中に空の <div> 要素を追加し、長押しのイベントリスナーをこの要素に移します。この要素が長押し中の実際の当たり判定を持つようになります。そして、longTouchHitOffset で指定された分だけ、この要素の座標を拡大します。これは <button> 要素に対して position: relative を指定し、空の <div> 要素の topleftbottomright を指定すれば良いです。

また、documentElementlongtouchstartlongtouchend のリスナーを設定し、現在長押し中かどうかを判定します。上記の空の <div> 要素を長押し中のみに表示します。

<!-- @filename: PopoverButton.vue -->
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'

const props = defineProps({
  // top、left、bottom、right の値分だけ長押し中の当たり判定を広げる
  longTouchHitOffset: Object,
})

const emit = defineEmits(['click'])

const isActive = ref(false)

// 長押し中に true になり、当たり判定を広げる要素を表示する
const isLongTouch = ref(false)

// 当たり判定を広げる style
const hitStyle = computed(() => {
  const { left, top, right, bottom } = props.longTouchHitOffset ?? {}
  return {
    left: `${-(left ?? 0)}px`,
    top: `${-(top ?? 0)}px`,
    right: `${-(right ?? 0)}px`,
    bottom: `${-(bottom ?? 0)}px`,
  }
})

function onLongTouchEnter() {
  isActive.value = true
}

function onLongTouchLeave() {
  isActive.value = false
}

function onLongTouchEnd() {
  isActive.value = false
  emit('click')
}

/**
 * 長押しを開始したら当たり判定を広げる
 */
function onLongTouchStartBody() {
  isLongTouch.value = true
}

/**
 * 長押しを終了したら当たり判定を戻す
 */
function onLongTouchEndBody() {
  isLongTouch.value = false
}

onMounted(() => {
  document.documentElement.addEventListener(
    'longtouchstart',
    onLongTouchStartBody,
  )
  document.documentElement.addEventListener('longtouchend', onLongTouchEndBody)
})

onBeforeUnmount(() => {
  document.documentElement.removeEventListener(
    'longtouchstart',
    onLongTouchStartBody,
  )
  document.documentElement.removeEventListener(
    'longtouchend',
    onLongTouchEndBody,
  )
})
</script>

<template>
  <button
    type="button"
    class="popover-button"
    :class="{ active: isActive }"
    @click="emit('click')"
  >
    <slot></slot>

    <!--
      ドラッグ時の当たり判定を広げる不可視要素
      longTouchHitOffset の値によって広げる量を設定する
    -->
    <div
      v-if="isLongTouch"
      class="drag-hit"
      :style="hitStyle"
      @longtouchenter="onLongTouchEnter"
      @longtouchleave="onLongTouchLeave"
      @longtouchend="onLongTouchEnd"
    ></div>
  </button>
</template>

<style>
.popover-button {
  position: relative;
  padding: 8px 12px;
  width: 100%;
  background: none;
  border: none;
  text-align: left;
  transition: 0.2s ease-in-out;
}

.popover-button:is(.active, :active) {
  scale: 1.5;
}

.drag-hit {
  position: absolute;
  inset: 0;
}
</style>

修正した <PopoverButton> を使って、絵文字選択のポップアップを実装します。絵文字は小さく、指に隠れてしまうので、ドラッグした指が下側にずれていても選択できるようにします。

<script setup>
import { ref } from 'vue'
import { awareLongTouch } from './long-touch.js'
import Popover from './Popover.vue'
import PopoverButton from './PopoverButton.vue'

awareLongTouch()

const selected = ref('😃')
const emojiList = ref(['😃', '🤣', '😇', '😍', '🥳', '😎'])

function onSelect(emoji, toggle) {
  selected.value = emoji
  toggle()
}
</script>

<template>
  <div class="wrapper">
    <Popover direction="top-center">
      <!-- ポップオーバーを開くボタンを設置 -->
      <template #activator="{ on }">
        <button
          type="button"
          class="open-button"
          v-on="on"
          v-text="selected"
        ></button>
      </template>

      <!-- ポップオーバーの中身 -->
      <template #default="{ toggle }">
        <div class="menu">
          <!-- 下側 50px 分、長押しドラッグ時の判定を伸ばす -->
          <PopoverButton
            v-for="(emoji, i) of emojiList"
            :key="i"
            :long-touch-hit-offset="{ bottom: 50 }"
            @click="onSelect(emoji, toggle)"
          >
            <span class="emoji" v-text="emoji"></span>
          </PopoverButton>
        </div>
      </template>
    </Popover>
  </div>
</template>

<style>
.wrapper {
  margin-top: 100px;
  display: flex;
  justify-content: center;
  font-family: sans-serif;
}

.open-button {
  padding: 8px 12px;
  border-radius: 4px;
  background: none;
  border: none;
  font-size: 26px;
}

.open-button:active {
  background-color: rgba(0, 0, 0, 0.1);
}

.menu {
  display: flex;
  width: max-content;
  background-color: #fff;
  border-radius: 50px;
  box-shadow: 0 0 5px 3px rgba(0, 0, 0, 0.05);
}

.emoji {
  font-size: 26px;
}
</style>

この実装を動かすと以下のようになります。

当たり判定の部分を描画するようにしたのが以下です。どのような挙動になっているかがよりわかりやすいと思います。

結論 #

長押しドラッグによってポップオーバーのメニュー項目を選択できるインタラクションは実際に使ってみるととても快適です。しかし、機能的にはタップでポップオーバーを開いて選択するのと変わりなく、実装もより大変になります。こういったインタラクションを、コードの複雑さを抑えて実装するのがフロントエンドエンジニアとしての腕の見せ所だと思います。