GODRICH

スマホ ジェスチャー操作 9選 — 指で本当に動くUI

スマホ ジェスチャー操作とは、画面に触れた指が動いた距離と速さを読み取って表示を変える操作方法です。9つのうち2つは指を離した瞬間にはじめて結果が分かれ、1つは最後まで押し切らないと何も起きません。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

並び順は流行ではなく、指が画面に触れている時間の順です。最初は触れて離すだけの2回タップ(01)、次は端まで押し切ってはじめて終わる一方向の押し出し(02)です。続いて指が円を描いて値を変え(03)、画面を1枚まるごと横に送り(04)、押した距離で合格と不合格を分けます(05)。6番目からは指が動き始めた場所そのものが意味を持ちます。画面の端から始めれば戻る操作(06)になり、リストの端でさらに引けば抵抗が生まれ(07)、下から引き上げればシートが段階で止まります(08)。指が2本必要なのは最後の1つ(09)だけです。9つすべてが pointerdownpointermovepointerup の一組でマウスと指の両方を受け取り、どのジェスチャーにもボタンか矢印キーという代わりの操作が付いています。

01ダブルタップズーム

写真を2回続けてタップすると2回目に触れた位置を中心に拡大され、もう一度タップすると元の大きさに戻ります。拡大の中心はタップ座標をそのまま transformOrigin に入れて決めており、横のプラス・マイナスボタンも同じ段階の配列をたどります。

dblclicktransform-originaria-live
photo.addEventListener('pointerdown', function (e) {
  var now = Date.now();
  if (now - lastTap < 320) {
    var r = photo.getBoundingClientRect();
    var x = e.clientX - r.left, y = e.clientY - r.top;
    pulse(x, y);
    go(at === STEPS.length - 1 ? 0 : at + 1, x, y);
    lastTap = 0;
    e.preventDefault();
  } else { lastTap = now; }
});

02スライドロック解除トラック

ハンドルをトラックの端まで押し切ってはじめてロックが解け、途中で指を離すと元の位置に戻ります。ハンドルをつかんだ瞬間に setPointerCapture がそのポインターを押さえるので、指がトラックの外に出ても移動が途切れません。

setPointerCapturetranslateXArrowRight
handle.addEventListener('pointerdown', function (e) {
  if (done) return;
  dragging = true; sx = e.clientX; x0 = x;
  handle.setPointerCapture(e.pointerId);
  handle.style.transition = 'none';
  e.preventDefault();
});
handle.addEventListener('pointermove', function (e) {
  if (!dragging) return;
  paint(x0 + (e.clientX - sx));
});

03回して調節するダイヤル

丸いつまみをつまんで回した角度だけ値が増減し、ゲージの弧が同じ角度まで埋まります。角度はダイヤルの中心と指の座標から Math.atan2 で求め、開始角 −135度を基準に270度の範囲へ収めています。

atan2stroke-dashoffsetArrowUp
function fromPointer(e) {
  var r = dial.getBoundingClientRect();
  var dx = e.clientX - (r.left + r.width / 2);
  var dy = e.clientY - (r.top + r.height / 2);
  var deg = Math.atan2(dy, dx) * 180 / Math.PI;
  var rel = deg - BASE;
  while (rel > 180) rel -= 360;
  while (rel < -180) rel += 360;
  if (rel < -SPAN / 2) rel = -SPAN / 2;
  if (rel > SPAN / 2) rel = SPAN / 2;
  setValue((rel + SPAN / 2) / SPAN * 100);
}

04スワイプタブ切替

パネルが指にリアルタイムで追従し、離した瞬間に残りの距離と速さを合わせて見て、送り切るか元に戻るかを決めます。基準は距離44px・速さ0.45px/msで、どちらか一方を超えれば送られます。

translateXthresholdroving tabindex
function drop() {
  if (!dragging) return;
  dragging = false;
  track.style.transition = '';
  var dx = lastX - x0;
  if (dx < -44 || v < -.45) go(at + 1);
  else if (dx > 44 || v > .45) go(at - 1);
  else go(at);
}

05スワイプカードデッキ

カードを左右に引くと傾きとともに合格・スキップのスタンプが濃くなり、64pxを超えて離すと判定が確定してカードが画面の外へ飛びます。傾きは引いた距離を9で割った値で、12度で頭打ちになります。

rotatevelocityaria-label
top.addEventListener('pointermove', function (e) {
  if (!dragging) return;
  var dx = e.clientX - x0;
  var r = Math.max(-12, Math.min(12, dx / 9));
  top.style.transform = 'translateX(' + dx + 'px) rotate(' + r + 'deg)';
  top.querySelector('.cd__stamp--pass').style.opacity = Math.max(0, Math.min(1, dx / 64));
  top.querySelector('.cd__stamp--skip').style.opacity = Math.max(0, Math.min(1, -dx / 64));
});

06エッジスワイプバック

画面左端から28px以内で始まったドラッグだけを戻る操作として認識し、それより内側で始まったドラッグは無視します。引いた距離がフレーム幅の55%のうち72%を超えると移動が確定し、届かなければ先に現れた前の画面が戻ります。

translateXthresholdhistory
frame.addEventListener('pointerdown', function (e) {
  var r = frame.getBoundingClientRect();
  if (e.clientX - r.left > 28) return;
  dragging = true; x0 = e.clientX;
  frame.setPointerCapture(e.pointerId);
  prev.style.transition = 'none';
  cur.style.transition = 'none';
  e.preventDefault();
});

07ラバーバンド境界

リストの端を越えて引くと、はみ出した距離の3分の1しか画面に伝わらず、指と表示がずれ始めます。そのずれこそが「ここが端だ」という合図で、指を離すと弾むイージングで元の位置に戻ります。

overscrollresistancecubic-bezier
function paint(y) {
  var b = bounds();
  var yd = y;
  if (yd < b.top) yd = b.top + (yd - b.top) / 3;
  if (yd > b.bottom) yd = b.bottom + (yd - b.bottom) / 3;
  cur = y;
  inner.style.transform = 'translateY(' + (-yd) + 'px)';
  topEnd.classList.toggle('is-on', y < -6);
  bottomEnd.classList.toggle('is-on', y > b.bottom + 6);
}

08引き上げるボトムシート

シートが指に乗って昇降し、離すと最も近い段階に吸い付きますが、下へ強く投げると位置に関係なく閉じます。投げたかどうかは直前2つのイベント間の速さで測り、0.5px/msが境目です。

translateYsnapEscape
function drop() {
  if (!dragging) return;
  dragging = false;
  sheet.style.transition = '';
  var cur2 = startPos + (lastY - startY);
  if (v0 > .5) { go(0); return; }
  if (v0 < -.5) { go(SNAPS.length - 1); return; }
  var best = 0, dist = 1e9;
  SNAPS.forEach(function (s, i) {
    var d = Math.abs(cur2 - s);
    if (d < dist) { dist = d; best = i; }
  });
  go(best);
}

09ピンチズーム

2本の指の座標をそれぞれ覚えておき、2点間の距離の比率が拡大率に、傾きの差が回転角になります。マウスには2本目の指がないので、ホイールで拡大し、ドラッグで移動できるように別に用意しました。

pointermapscalectrl+wheel
function pinchInfo() {
  var ks = Object.keys(ptrs);
  if (ks.length < 2) return null;
  var a = ptrs[ks[0]], b = ptrs[ks[1]];
  var r = frame.getBoundingClientRect();
  var ax = a.x - r.left, ay = a.y - r.top, bx = b.x - r.left, by = b.y - r.top;
  return {
    d: Math.hypot(ax - bx, ay - by),
    ang: Math.atan2(by - ay, bx - ax) * 180 / Math.PI
  };
}

どこで壊れるか — 落とし穴

ジェスチャーの本当の落とし穴は指の側ではなく、画面の外へ出た要素が文書の大きさにそのまま残ることでした。05のカードデッキは判定の終わったカードを translateX(230px) で飛ばしますが、見えなくなった後もその位置が文書の幅に数えられ続けます。320px幅で測ると、カード1枚が画面の右へ100px以上はみ出しており、ステージを囲む overflow: hidden はその値を減らしてくれませんでした。見えないことと、大きさに数えられないことは別の話です。直し方はデッキ自身に overflow: hidden と固定サイズを与え、カードがデッキの外へ出た瞬間に切り取られるようにすることで、同じアニメーションの変化ピクセル面積が29.6%から9.7%へ下がりました。飛ぶカードがステージの外まで描いていた分が、そっくり消えた値です。

次によく引っかかったのは回転です。03のダイヤルはつまみを rotate で回しますが、その中に入れたスピーカーのアイコンまで一緒に回り、値が大きくなるほど逆さまに立ちました。同じ角度を反対向きに回すアニメーションをアイコンだけに別途かけて、いつでもまっすぐ立つようにしています。ゲージの弧にも似た問題があり、stroke-dasharray に長さを1つだけ与えると円周がその長さより長い時点で繰り返され、弧がもつれました。270度だけ描くには、描く長さ197.92と空ける長さ66.06を並べて書く必要があります。これらの修正をすべて反映した9本のソースは、圧縮パスワード 49t2bve3 を入れると開くzipの中にあり、バニラ版とReact版が同じ値を使います。

アクセシビリティ

9つとも、ジェスチャーが唯一の入口にならないようにしています。01はプラス・マイナスボタン、02は右矢印キー、03は4方向の矢印とHome・End、04はタブボタンと左右の矢印にHome・Endまで、05は合格・スキップのボタンと左右の矢印、06は戻るボタン、07は上下の矢印、08は段階ボタンとEscape、09は拡大・縮小ボタンと矢印での移動が、それぞれ同じ動作をします。値を扱う03には role="slider"aria-valuenow が付いて現在の値を読み上げ、状態が変わる01・02・03・05・09の表示欄には aria-live="polite" を付けて、画面が見えない人にも結果が伝わるようにしました。prefers-reduced-motion: reduce では自動再生のループだけを止め、実際の操作と状態の値はそのまま残ります。マウス・タッチ・ペンを一組のハンドラで受け取る方法は MDNのポインターイベントの解説 にまとまっています。

指で押す部品をもっと探すなら、ナビのカテゴリスクロールのカテゴリ に同じ系統が集まっています。

FAQ

タッチイベントの代わりにポインターイベントだけでも大丈夫ですか

大丈夫です。9つとも touchstartmousedown を別に登録せず、pointerdownpointermovepointerup の一組だけを使っています。ポインターイベントはマウス・指・ペンを同じ形で渡してくれるので、マウスで引いても指で押しても同じ関数がそのまま動きます。ただし09のように複数のポインターを扱うときは、e.pointerId をキーにして座標を別々に覚えておく必要があります。

ドラッグ中に指が要素の外へ出ると止まってしまいます

ポインターを押さえていないからです。既定の動作では、指が要素の境界を越えた瞬間にその要素はイベントを受け取らなくなります。02のように pointerdown の中で setPointerCapture(e.pointerId) を一度呼べば、指を離すまでイベントがその要素へ届き続け、素早いドラッグでもハンドルが途中で止まりません。

スワイプがページのスクロールとぶつかります

CSSの touch-action で、どちらの方向をブラウザに任せるかを決めてください。横だけに送る04は touch-action: pan-y にして縦のスクロールはブラウザに残し、横だけを受け取ります。07・08・09のように方向をすべて自分で扱うところは touch-action: none でブラウザ既定のスクロールを完全に止めます。この1行がないと、指1本で2つのことが同時に動きます。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。