長押しドラッグを活用した 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つです。
longtouchstart
: 長押しを開始した時longtouchend
: 長押しを終了した時longtouchenter
: 長押し中に特定の要素内にドラッグした時 (バブリングしない)longtouchleave
: 長押し中に特定の要素外にドラッグした時 (バブリングしない)
以後、Vue のテンプレート内で <div @longtouchstart="onLongTouchStart">
のように、他のイベントと同じ書き方でリスナー関数を登録できます。
ポップオーバーの実装 #
ポップオーバーのコンポーネント <Popover>
にはポップオーバーを開くボタンである activator スロットと、ポップオーバーのコンテンツである default スロットを持たせます。activator には click
と longtouchstart
のリスナーを渡し、クリックと長押しのどちらでもポップオーバーが開くようにします。
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
のイベントリスナーを登録します。longtouchenter
と longtouchleave
でボタンのアクティブ状態を変え、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>
要素の top
、left
、bottom
、right
を指定すれば良いです。
また、documentElement
に longtouchstart
、longtouchend
のリスナーを設定し、現在長押し中かどうかを判定します。上記の空の <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>
この実装を動かすと以下のようになります。
当たり判定の部分を描画するようにしたのが以下です。どのような挙動になっているかがよりわかりやすいと思います。
結論 #
長押しドラッグによってポップオーバーのメニュー項目を選択できるインタラクションは実際に使ってみるととても快適です。しかし、機能的にはタップでポップオーバーを開いて選択するのと変わりなく、実装もより大変になります。こういったインタラクションを、コードの複雑さを抑えて実装するのがフロントエンドエンジニアとしての腕の見せ所だと思います。